diff --git a/backend/app/api/routes/v1/auth.py b/backend/app/api/routes/v1/auth.py index a7f0dbc1..b0f76e2c 100644 --- a/backend/app/api/routes/v1/auth.py +++ b/backend/app/api/routes/v1/auth.py @@ -5,7 +5,7 @@ from app.config import settings from app.database import DbSession -from app.schemas import DeveloperRead, DeveloperUpdate +from app.schemas import DeveloperRead, DeveloperUpdate, PasswordChange from app.schemas.token import TokenResponse from app.services import DeveloperDep, developer_service, refresh_token_service from app.utils.security import create_access_token, verify_password @@ -59,7 +59,24 @@ async def logout(_developer: DeveloperDep): return {"message": "Successfully logged out"} -# TODO: Implement /forgot-password and /reset-password +@router.post("/change-password") +async def change_password( + payload: PasswordChange, + db: DbSession, + developer: DeveloperDep, +): + """Change password for the current authenticated developer.""" + # Verify the current password + if not verify_password(payload.current_password, developer.hashed_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect current password", + ) + + # Update using existing developer_service logic + developer_service.update_developer_info(db, developer.id, DeveloperUpdate(password=payload.new_password)) + + return {"message": "Password updated successfully"} @router.get("/me", response_model=DeveloperRead) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 4ef4680a..82d62817 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -52,6 +52,7 @@ DeveloperRead, DeveloperUpdate, DeveloperUpdateInternal, + PasswordChange, ) from .device_type import DeviceType from .device_type_priority import ( @@ -215,6 +216,7 @@ "DeveloperCreateInternal", "DeveloperUpdateInternal", "DeveloperUpdate", + "PasswordChange", "InvitationCreate", "InvitationRead", "InvitationAccept", diff --git a/backend/app/schemas/developer.py b/backend/app/schemas/developer.py index 297e1e00..c5ac7b95 100644 --- a/backend/app/schemas/developer.py +++ b/backend/app/schemas/developer.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone from uuid import UUID, uuid4 -from pydantic import BaseModel, ConfigDict, EmailStr, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator class DeveloperRead(BaseModel): @@ -45,3 +45,15 @@ class DeveloperUpdateInternal(BaseModel): email: EmailStr | None = None hashed_password: str | None = None updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class PasswordChange(BaseModel): + current_password: str + new_password: str + confirm_password: str + + @model_validator(mode="after") + def check_passwords_match(self) -> "PasswordChange": + if self.new_password != self.confirm_password: + raise ValueError("The confirmation password does not match the new password") + return self diff --git a/backend/tests/api/v1/test_auth.py b/backend/tests/api/v1/test_auth.py index 6e9eb199..bad290bb 100644 --- a/backend/tests/api/v1/test_auth.py +++ b/backend/tests/api/v1/test_auth.py @@ -145,6 +145,58 @@ def test_logout_invalid_token(self, client: TestClient, api_v1_prefix: str) -> N assert response.status_code == 401 +class TestChangePassword: + """Tests for POST /api/v1/auth/change-password.""" + + def test_change_password_success(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: + """Test successful password change with valid data.""" + # Arrange + developer = DeveloperFactory(password="OldPassword123") + headers = developer_auth_headers(developer.id) + payload = { + "current_password": "OldPassword123", + "new_password": "NewPassword456", + "confirm_password": "NewPassword456", + } + + # Act + response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) + + # Assert + assert response.status_code == 200 + assert response.json()["message"] == "Password updated successfully" + + def test_change_password_invalid_current(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: + """Test failure when the current password is wrong.""" + developer = DeveloperFactory(password="CorrectOld123") + headers = developer_auth_headers(developer.id) + payload = { + "current_password": "WrongOld123", + "new_password": "NewPassword789", + "confirm_password": "NewPassword789", + } + + response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) + + assert response.status_code == 400 + assert response.json()["detail"] == "Incorrect current password" + + def test_change_password_mismatch(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: + """Test failure when new_password and confirm_password do not match.""" + developer = DeveloperFactory(password="OldPassword123") + headers = developer_auth_headers(developer.id) + payload = { + "current_password": "OldPassword123", + "new_password": "NewPassword123", + "confirm_password": "DifferentPassword123", + } + + response = client.post(f"{api_v1_prefix}/auth/change-password", json=payload, headers=headers) + + assert response.status_code == 400 + assert "The confirmation password does not match" in str(response.json()) + + class TestGetCurrentDeveloper: """Tests for GET /api/v1/me.""" diff --git a/frontend/src/components/settings/security/security-settings.tsx b/frontend/src/components/settings/security/security-settings.tsx new file mode 100644 index 00000000..7219d509 --- /dev/null +++ b/frontend/src/components/settings/security/security-settings.tsx @@ -0,0 +1,124 @@ +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { authService } from '@/lib/api/services/auth.service'; +import type { ChangePasswordRequest } from '@/lib/api/types'; + +const passwordChangeSchema = z + .object({ + current_password: z.string().min(1, 'Current password is required'), + new_password: z.string().min(1, 'New password is required'), + confirm_password: z.string().min(1, 'Please confirm your password'), + }) + .refine((data) => data.new_password === data.confirm_password, { + message: 'Passwords do not match', + path: ['confirm_password'], + }); + +type PasswordChangeForm = z.infer; + +export function SecuritySettings() { + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(passwordChangeSchema), + }); + + const mutation = useMutation({ + mutationFn: (data: ChangePasswordRequest) => + authService.changePassword(data), + onSuccess: () => { + toast.success('Password updated successfully'); + reset(); + }, + onError: (error: any) => { + const detail = + error.response?.data?.detail || 'Failed to update password'; + toast.error(detail); + }, + }); + + return ( +
+
+

Security

+

+ Update your password to keep your developer account secure. +

+
+ +
mutation.mutate(data))} + className="p-6 space-y-5" + > +
+ + + {errors.current_password && ( +

+ {errors.current_password.message} +

+ )} +
+ +
+ + + {errors.new_password && ( +

+ {errors.new_password.message} +

+ )} +
+ +
+ + + {errors.confirm_password && ( +

+ {errors.confirm_password.message} +

+ )} +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/lib/api/config.ts b/frontend/src/lib/api/config.ts index c62df41b..569eb55f 100644 --- a/frontend/src/lib/api/config.ts +++ b/frontend/src/lib/api/config.ts @@ -13,6 +13,7 @@ export const API_ENDPOINTS = { me: '/api/v1/auth/me', forgotPassword: '/api/v1/auth/forgot-password', resetPassword: '/api/v1/auth/reset-password', + changePassword: '/api/v1/auth/change-password', // User endpoints users: '/api/v1/users', diff --git a/frontend/src/lib/api/services/auth.service.ts b/frontend/src/lib/api/services/auth.service.ts index f6b02b8e..c972eb0e 100644 --- a/frontend/src/lib/api/services/auth.service.ts +++ b/frontend/src/lib/api/services/auth.service.ts @@ -7,6 +7,7 @@ import type { RegisterResponse, ForgotPasswordRequest, ResetPasswordRequest, + ChangePasswordRequest, Developer, } from '../types'; @@ -38,4 +39,8 @@ export const authService = { async resetPassword(data: ResetPasswordRequest): Promise { return apiClient.post(API_ENDPOINTS.resetPassword, data); }, + + async changePassword(data: ChangePasswordRequest): Promise { + return apiClient.post(API_ENDPOINTS.changePassword, data); + }, }; diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index a6b8a303..11fde71b 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -144,6 +144,12 @@ export interface ResetPasswordRequest { password: string; } +export interface ChangePasswordRequest { + current_password: string; + new_password: string; + confirm_password: string; +} + export interface CountWithGrowth { count: number; weekly_growth: number; diff --git a/frontend/src/routes/_authenticated/settings.tsx b/frontend/src/routes/_authenticated/settings.tsx index 354e819c..9ad0145f 100644 --- a/frontend/src/routes/_authenticated/settings.tsx +++ b/frontend/src/routes/_authenticated/settings.tsx @@ -5,6 +5,7 @@ import { CredentialsTab } from './settings/credentials-tab'; import { ProvidersTab } from './settings/providers-tab'; import { PrioritiesTab } from './settings/priorities-tab'; import { TeamTab } from './settings/team-tab'; +import { SecurityTab } from './settings/security-tab'; export const Route = createFileRoute('/_authenticated/settings')({ component: SettingsPage, @@ -37,6 +38,11 @@ const tabs: TabConfig[] = [ label: 'Team', component: TeamTab, }, + { + id: 'security', + label: 'Security', + component: SecurityTab, + }, ]; function SettingsPage() { @@ -52,7 +58,7 @@ function SettingsPage() { - + {tabs.map((tab) => ( {tab.label} @@ -61,7 +67,11 @@ function SettingsPage() { {tabs.map((tab) => ( - + ))} diff --git a/frontend/src/routes/_authenticated/settings/security-tab.tsx b/frontend/src/routes/_authenticated/settings/security-tab.tsx new file mode 100644 index 00000000..c90817f7 --- /dev/null +++ b/frontend/src/routes/_authenticated/settings/security-tab.tsx @@ -0,0 +1,11 @@ +import { SecuritySettings } from '@/components/settings/security/security-settings'; + +export function SecurityTab() { + return ( +
+
+ +
+
+ ); +}