-
- {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 */}
+
+
+ {/* Password */}
+
+
+ );
+}
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 |