diff --git a/apps/admin-ui/src/App.tsx b/apps/admin-ui/src/App.tsx index 7f6cec6..e5891ac 100644 --- a/apps/admin-ui/src/App.tsx +++ b/apps/admin-ui/src/App.tsx @@ -11,6 +11,7 @@ import { import Layout from './components/Layout'; import { trackPageView } from './services/analytics'; import ProtectedRoute from './components/ProtectedRoute'; +import AccountPage from './pages/AccountPage'; import ConsentRecordsPage from './pages/ConsentRecordsPage'; import LoginPage from './pages/LoginPage'; import SettingsPage from './pages/SettingsPage'; @@ -59,6 +60,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> {extensionPages .filter((p) => p.protected !== false) diff --git a/apps/admin-ui/src/api/auth.ts b/apps/admin-ui/src/api/auth.ts index 61bc9d2..2b1108f 100644 --- a/apps/admin-ui/src/api/auth.ts +++ b/apps/admin-ui/src/api/auth.ts @@ -17,3 +17,28 @@ export async function getMe(): Promise { const { data } = await apiClient.get('/auth/me'); return data; } + +export interface Profile { + id: string; + email: string; + full_name: string; + role: string; + organisation_id: string; +} + +export async function getProfile(): Promise { + const { data } = await apiClient.get('/auth/me'); + return data; +} + +export async function updateProfile(body: { email?: string; full_name?: string }): Promise { + const { data } = await apiClient.patch('/auth/me', body); + return data; +} + +export async function changePassword(body: { + current_password: string; + new_password: string; +}): Promise { + await apiClient.patch('/auth/me/password', body); +} diff --git a/apps/admin-ui/src/components/Layout.tsx b/apps/admin-ui/src/components/Layout.tsx index b84a563..f2a7313 100644 --- a/apps/admin-ui/src/components/Layout.tsx +++ b/apps/admin-ui/src/components/Layout.tsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from 'react'; -import { Link, Outlet, useLocation } from 'react-router-dom'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useAuthStore } from '../stores/auth'; import { getNavItems } from '../extensions/registry'; @@ -13,7 +13,22 @@ const CORE_NAV_ITEMS = [ export default function Layout() { const { user, logout } = useAuthStore(); const location = useLocation(); + const navigate = useNavigate(); const [mobileOpen, setMobileOpen] = useState(false); + const [userMenuOpen, setUserMenuOpen] = useState(false); + const userMenuRef = useRef(null); + + // Close user menu on outside click + useEffect(() => { + if (!userMenuOpen) return; + const handler = (e: MouseEvent) => { + if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) { + setUserMenuOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [userMenuOpen]); const NAV_ITEMS = useMemo(() => { const extensionItems = getNavItems().map((item) => ({ @@ -65,18 +80,47 @@ export default function Layout() { - {/* Right: user info + mobile hamburger */} + {/* Right: user menu + mobile hamburger */}
-
- - {user?.full_name ?? user?.email} - +
+ {userMenuOpen && ( +
+ +
+ +
+ )}
{/* Mobile hamburger */} diff --git a/apps/admin-ui/src/pages/AccountPage.tsx b/apps/admin-ui/src/pages/AccountPage.tsx new file mode 100644 index 0000000..bf234f5 --- /dev/null +++ b/apps/admin-ui/src/pages/AccountPage.tsx @@ -0,0 +1,178 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import type { FormEvent } from 'react'; + +import { changePassword, getProfile, updateProfile } from '../api/auth'; +import { Alert } from '../components/ui/alert'; +import { Button } from '../components/ui/button'; +import { Card } from '../components/ui/card'; +import { FormField } from '../components/ui/form-field'; +import { Input } from '../components/ui/input'; + +export default function AccountPage() { + const queryClient = useQueryClient(); + + const { data: profile, isLoading } = useQuery({ + queryKey: ['profile'], + queryFn: getProfile, + }); + + // Profile form + const [email, setEmail] = useState(''); + const [fullName, setFullName] = useState(''); + const [profileInit, setProfileInit] = useState(false); + const [profileSaved, setProfileSaved] = useState(false); + + if (profile && !profileInit) { + setEmail(profile.email); + setFullName(profile.full_name); + setProfileInit(true); + } + + const profileMutation = useMutation({ + mutationFn: (body: { email?: string; full_name?: string }) => updateProfile(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['profile'] }); + setProfileSaved(true); + setTimeout(() => setProfileSaved(false), 2000); + }, + }); + + const handleProfileSubmit = (e: FormEvent) => { + e.preventDefault(); + const body: { email?: string; full_name?: string } = {}; + if (email !== profile?.email) body.email = email; + if (fullName !== profile?.full_name) body.full_name = fullName; + if (Object.keys(body).length === 0) return; + profileMutation.mutate(body); + }; + + // Password form + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordSaved, setPasswordSaved] = useState(false); + const [passwordError, setPasswordError] = useState(''); + + const passwordMutation = useMutation({ + mutationFn: (body: { current_password: string; new_password: string }) => changePassword(body), + onSuccess: () => { + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setPasswordError(''); + setPasswordSaved(true); + setTimeout(() => setPasswordSaved(false), 2000); + }, + onError: (err: Error & { response?: { data?: { detail?: string } } }) => { + setPasswordError(err.response?.data?.detail ?? 'Failed to change password'); + }, + }); + + const handlePasswordSubmit = (e: FormEvent) => { + e.preventDefault(); + setPasswordError(''); + if (newPassword.length < 8) { + setPasswordError('Password must be at least 8 characters'); + return; + } + if (newPassword !== confirmPassword) { + setPasswordError('Passwords do not match'); + return; + } + passwordMutation.mutate({ + current_password: currentPassword, + new_password: newPassword, + }); + }; + + if (isLoading) { + return
Loading...
; + } + + return ( +
+
+

Account

+

Manage your profile and password.

+
+ + {/* Profile */} +
+ +

Profile

+
+ + setEmail(e.target.value)} + required + /> + + + setFullName(e.target.value)} + required + /> + +
+ {profileSaved && Profile updated.} + {profileMutation.isError && ( + + {(profileMutation.error as Error & { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Failed to update profile'} + + )} +
+ +
+
+
+ + {/* Password */} +
+ +

Change password

+
+ + setCurrentPassword(e.target.value)} + required + /> + + + setNewPassword(e.target.value)} + required + minLength={8} + /> + + + setConfirmPassword(e.target.value)} + required + minLength={8} + /> + +
+ {passwordSaved && Password changed.} + {passwordError && {passwordError}} +
+ +
+
+
+
+ ); +} diff --git a/apps/api/src/cli/reset_password.py b/apps/api/src/cli/reset_password.py new file mode 100644 index 0000000..4e895de --- /dev/null +++ b/apps/api/src/cli/reset_password.py @@ -0,0 +1,67 @@ +"""Reset a user's password from the command line. + +Usage: + docker exec consentos-api python -m src.cli.reset_password \\ + --email admin@example.com --password new-secret + +For use when the password has been forgotten and the admin UI is +inaccessible. Connects directly to the database, so it must run +inside a container (or host) that can reach PostgreSQL. +""" + +from __future__ import annotations + +import argparse +import sys + +import sqlalchemy as sa + + +def _build_sync_url(async_url: str) -> str: + return async_url.replace("postgresql+asyncpg://", "postgresql://") + + +def reset(email: str, password: str) -> bool: + """Reset the password for the given email. Returns True on success.""" + from src.config.settings import get_settings + from src.services.auth import hash_password + + settings = get_settings() + engine = sa.create_engine(_build_sync_url(settings.database_url)) + + with engine.begin() as conn: + result = conn.execute( + sa.text("SELECT id FROM users WHERE email = :email AND deleted_at IS NULL"), + {"email": email}, + ) + row = result.fetchone() + if row is None: + return False + + conn.execute( + sa.text("UPDATE users SET password_hash = :pw, updated_at = NOW() WHERE id = :id"), + {"pw": hash_password(password), "id": str(row[0])}, + ) + + return True + + +def main() -> None: + parser = argparse.ArgumentParser(description="Reset a user's password") + parser.add_argument("--email", required=True, help="User email address") + parser.add_argument("--password", required=True, help="New password") + args = parser.parse_args() + + if len(args.password) < 8: + print("Error: password must be at least 8 characters", file=sys.stderr) + sys.exit(1) + + if reset(args.email, args.password): + print(f"Password reset for {args.email}") + else: + print(f"Error: no active user found with email {args.email}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/apps/api/src/routers/auth.py b/apps/api/src/routers/auth.py index f876487..5f112ac 100644 --- a/apps/api/src/routers/auth.py +++ b/apps/api/src/routers/auth.py @@ -8,11 +8,20 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.config.settings import get_settings from src.db import get_db from src.models.user import User -from src.schemas.auth import CurrentUser, LoginRequest, RefreshRequest, TokenResponse +from src.schemas.auth import ( + ChangePasswordRequest, + CurrentUser, + LoginRequest, + ProfileResponse, + RefreshRequest, + TokenResponse, + UpdateProfileRequest, +) from src.services.auth import ( create_access_token, create_refresh_token, decode_token, + hash_password, verify_password, ) from src.services.dependencies import get_current_user @@ -102,7 +111,74 @@ async def refresh( ) -@router.get("/me", response_model=CurrentUser) -async def get_me(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser: - """Return the currently authenticated user's profile from the JWT.""" - return current_user +@router.get("/me", response_model=ProfileResponse) +async def get_me( + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> User: + """Return the currently authenticated user's profile.""" + result = await db.execute( + select(User).where(User.id == current_user.id, User.deleted_at.is_(None)) + ) + user = result.scalar_one_or_none() + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return user + + +@router.patch("/me", response_model=ProfileResponse) +async def update_profile( + body: UpdateProfileRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> User: + """Update the current user's email or display name.""" + result = await db.execute( + select(User).where(User.id == current_user.id, User.deleted_at.is_(None)) + ) + user = result.scalar_one_or_none() + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + if body.email is not None: + # Check uniqueness + existing = await db.execute( + select(User).where(User.email == body.email, User.id != current_user.id) + ) + if existing.scalar_one_or_none() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Email already in use", + ) + user.email = body.email + + if body.full_name is not None: + user.full_name = body.full_name + + await db.flush() + await db.refresh(user) + return user + + +@router.patch("/me/password", status_code=status.HTTP_204_NO_CONTENT) +async def change_password( + body: ChangePasswordRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + """Change the current user's password. Requires the current password.""" + result = await db.execute( + select(User).where(User.id == current_user.id, User.deleted_at.is_(None)) + ) + user = result.scalar_one_or_none() + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + if not verify_password(body.current_password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is incorrect", + ) + + user.password_hash = hash_password(body.new_password) + await db.flush() diff --git a/apps/api/src/schemas/auth.py b/apps/api/src/schemas/auth.py index ec33787..78a0be6 100644 --- a/apps/api/src/schemas/auth.py +++ b/apps/api/src/schemas/auth.py @@ -29,6 +29,26 @@ class TokenPayload(BaseModel): type: str = "access" # "access" or "refresh" +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + + +class UpdateProfileRequest(BaseModel): + email: EmailStr | None = None + full_name: str | None = None + + +class ProfileResponse(BaseModel): + id: uuid.UUID + email: str + full_name: str + role: str + organisation_id: uuid.UUID + + model_config = {"from_attributes": True} + + class CurrentUser(BaseModel): """Represents the authenticated user extracted from a JWT.""" diff --git a/apps/api/tests/test_auth.py b/apps/api/tests/test_auth.py index 07879da..e7a7f1a 100644 --- a/apps/api/tests/test_auth.py +++ b/apps/api/tests/test_auth.py @@ -4,6 +4,7 @@ import uuid from datetime import UTC, datetime, timedelta import pytest +from httpx import ASGITransport, AsyncClient from jose import JWTError, jwt from src.config.settings import get_settings @@ -140,7 +141,11 @@ class TestAuthEndpoints: response = await client.get("/api/v1/auth/me") assert response.status_code == 401 - async def test_me_with_valid_token(self, client): + async def test_me_with_valid_token(self, app): + from unittest.mock import AsyncMock, MagicMock + + from src.db import get_db + user_id = uuid.uuid4() org_id = uuid.uuid4() token = create_access_token( @@ -149,10 +154,34 @@ class TestAuthEndpoints: role="editor", email="user@example.com", ) - response = await client.get( - "/api/v1/auth/me", - headers={"Authorization": f"Bearer {token}"}, - ) + + mock_user = MagicMock() + mock_user.id = user_id + mock_user.organisation_id = org_id + mock_user.email = "user@example.com" + mock_user.full_name = "Test User" + mock_user.role = "editor" + mock_user.deleted_at = None + + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = mock_user + mock_session.execute.return_value = mock_result + + async def _override(): + yield mock_session + + app.dependency_overrides[get_db] = _override + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {token}"}, + ) + + app.dependency_overrides.pop(get_db, None) + assert response.status_code == 200 data = response.json() assert data["id"] == str(user_id) diff --git a/apps/api/tests/test_routers_auth.py b/apps/api/tests/test_routers_auth.py index 2d5aed7..1a09bb6 100644 --- a/apps/api/tests/test_routers_auth.py +++ b/apps/api/tests/test_routers_auth.py @@ -117,7 +117,8 @@ class TestMeEndpoint: role="owner", email="admin@test.com", ) - db = _mock_db() + mock_user = _make_user(id=user_id, org_id=org_id, email="admin@test.com", role="owner") + db = _mock_db(scalar_one_or_none=mock_user) async with await _client(mock_app, db) as client: resp = await client.get( "/api/v1/auth/me", diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 7feb916..c2d3b03 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -690,6 +690,27 @@ Regardless of deployment method, verify these before going live: --- +## Password Reset + +If you've forgotten your password and can't log in to the admin UI, reset it from the host machine: + +```bash +docker exec consentos-api python -m src.cli.reset_password \ + --email admin@example.com \ + --password new-secret-here +``` + +The password must be at least 8 characters. The change takes effect immediately — no restart needed. On Kubernetes, run it as a one-off pod: + +```bash +kubectl exec -it deploy/consentos-api -n consentos -- \ + python -m src.cli.reset_password --email admin@example.com --password new-secret-here +``` + +Once logged back in, you can change your email and password from the **Account** page (click your name in the top nav → Account). + +--- + ## Troubleshooting | Symptom | Likely cause | Fix |