feat: account management — change email, password, and CLI reset (#10)

API:
- PATCH /auth/me — update email and display name
- PATCH /auth/me/password — change password (requires current)
- GET /auth/me now returns full profile (email, full_name, role)

CLI:
- python -m src.cli.reset_password --email <email> --password <pw>
  for recovery when locked out (run via docker exec)

Admin UI:
- User menu dropdown on the top nav (click username → Account /
  Sign out) replaces the inline sign-out link
- /account page with profile form (email + display name) and
  change password form (current + new + confirm)
This commit is contained in:
James Cottrill
2026-04-18 21:53:32 +01:00
committed by GitHub
parent 142e2373d3
commit d8e0a34e04
10 changed files with 484 additions and 21 deletions

View File

@@ -11,6 +11,7 @@ import {
import Layout from './components/Layout'; import Layout from './components/Layout';
import { trackPageView } from './services/analytics'; import { trackPageView } from './services/analytics';
import ProtectedRoute from './components/ProtectedRoute'; import ProtectedRoute from './components/ProtectedRoute';
import AccountPage from './pages/AccountPage';
import ConsentRecordsPage from './pages/ConsentRecordsPage'; import ConsentRecordsPage from './pages/ConsentRecordsPage';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import SettingsPage from './pages/SettingsPage'; import SettingsPage from './pages/SettingsPage';
@@ -59,6 +60,7 @@ function AppRoutes() {
<Route path="/sites/:siteId" element={<SiteDetailPage />} /> <Route path="/sites/:siteId" element={<SiteDetailPage />} />
<Route path="/groups/:groupId" element={<SiteGroupDetailPage />} /> <Route path="/groups/:groupId" element={<SiteGroupDetailPage />} />
<Route path="/consent" element={<ConsentRecordsPage />} /> <Route path="/consent" element={<ConsentRecordsPage />} />
<Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
{extensionPages {extensionPages
.filter((p) => p.protected !== false) .filter((p) => p.protected !== false)

View File

@@ -17,3 +17,28 @@ export async function getMe(): Promise<User> {
const { data } = await apiClient.get<User>('/auth/me'); const { data } = await apiClient.get<User>('/auth/me');
return data; return data;
} }
export interface Profile {
id: string;
email: string;
full_name: string;
role: string;
organisation_id: string;
}
export async function getProfile(): Promise<Profile> {
const { data } = await apiClient.get<Profile>('/auth/me');
return data;
}
export async function updateProfile(body: { email?: string; full_name?: string }): Promise<Profile> {
const { data } = await apiClient.patch<Profile>('/auth/me', body);
return data;
}
export async function changePassword(body: {
current_password: string;
new_password: string;
}): Promise<void> {
await apiClient.patch('/auth/me/password', body);
}

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom'; import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { getNavItems } from '../extensions/registry'; import { getNavItems } from '../extensions/registry';
@@ -13,7 +13,22 @@ const CORE_NAV_ITEMS = [
export default function Layout() { export default function Layout() {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(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 NAV_ITEMS = useMemo(() => {
const extensionItems = getNavItems().map((item) => ({ const extensionItems = getNavItems().map((item) => ({
@@ -65,18 +80,47 @@ export default function Layout() {
</nav> </nav>
</div> </div>
{/* Right: user info + mobile hamburger */} {/* Right: user menu + mobile hamburger */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="hidden items-center gap-3 md:flex"> <div className="relative hidden md:block" ref={userMenuRef}>
<span className="text-sm text-text-secondary">
{user?.full_name ?? user?.email}
</span>
<button <button
onClick={logout} type="button"
className="text-sm text-text-tertiary hover:text-foreground" onClick={() => setUserMenuOpen((v) => !v)}
className="flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm text-text-secondary transition-colors hover:bg-mist hover:text-foreground"
> >
Sign out <span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-copper/10 font-heading text-xs font-semibold text-copper">
{(user?.full_name ?? user?.email ?? '?')[0].toUpperCase()}
</span>
{user?.full_name ?? user?.email}
<svg className="h-4 w-4 text-text-tertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button> </button>
{userMenuOpen && (
<div className="absolute right-0 mt-1 w-48 overflow-hidden rounded-lg border border-border bg-card shadow-lg">
<button
type="button"
onClick={() => { setUserMenuOpen(false); navigate('/account'); }}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-foreground hover:bg-mist"
>
<svg className="h-4 w-4 text-text-tertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Account
</button>
<div className="border-t border-border" />
<button
type="button"
onClick={() => { setUserMenuOpen(false); logout(); }}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-text-tertiary hover:bg-mist hover:text-foreground"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign out
</button>
</div>
)}
</div> </div>
{/* Mobile hamburger */} {/* Mobile hamburger */}

View File

@@ -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 <div className="py-12 text-center text-sm text-text-secondary">Loading...</div>;
}
return (
<div className="mx-auto max-w-xl">
<div className="mb-6">
<h1 className="font-heading text-4xl font-semibold tracking-tight text-foreground">Account</h1>
<p className="mt-1 text-sm text-text-secondary">Manage your profile and password.</p>
</div>
{/* Profile */}
<form onSubmit={handleProfileSubmit} className="mb-6">
<Card className="p-6">
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Profile</h3>
<div className="space-y-4">
<FormField label="Email">
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</FormField>
<FormField label="Display name">
<Input
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
/>
</FormField>
</div>
{profileSaved && <Alert variant="success" className="mt-4">Profile updated.</Alert>}
{profileMutation.isError && (
<Alert variant="error" className="mt-4">
{(profileMutation.error as Error & { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Failed to update profile'}
</Alert>
)}
<div className="mt-4">
<Button type="submit" disabled={profileMutation.isPending}>
{profileMutation.isPending ? 'Saving...' : 'Save profile'}
</Button>
</div>
</Card>
</form>
{/* Password */}
<form onSubmit={handlePasswordSubmit}>
<Card className="p-6">
<h3 className="font-heading mb-4 text-sm font-semibold text-foreground">Change password</h3>
<div className="space-y-4">
<FormField label="Current password">
<Input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</FormField>
<FormField label="New password">
<Input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
/>
</FormField>
<FormField label="Confirm new password">
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
/>
</FormField>
</div>
{passwordSaved && <Alert variant="success" className="mt-4">Password changed.</Alert>}
{passwordError && <Alert variant="error" className="mt-4">{passwordError}</Alert>}
<div className="mt-4">
<Button type="submit" disabled={passwordMutation.isPending}>
{passwordMutation.isPending ? 'Changing...' : 'Change password'}
</Button>
</div>
</Card>
</form>
</div>
);
}

View File

@@ -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()

View File

@@ -8,11 +8,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.config.settings import get_settings from src.config.settings import get_settings
from src.db import get_db from src.db import get_db
from src.models.user import User 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 ( from src.services.auth import (
create_access_token, create_access_token,
create_refresh_token, create_refresh_token,
decode_token, decode_token,
hash_password,
verify_password, verify_password,
) )
from src.services.dependencies import get_current_user from src.services.dependencies import get_current_user
@@ -102,7 +111,74 @@ async def refresh(
) )
@router.get("/me", response_model=CurrentUser) @router.get("/me", response_model=ProfileResponse)
async def get_me(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser: async def get_me(
"""Return the currently authenticated user's profile from the JWT.""" current_user: CurrentUser = Depends(get_current_user),
return 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()

View File

@@ -29,6 +29,26 @@ class TokenPayload(BaseModel):
type: str = "access" # "access" or "refresh" 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): class CurrentUser(BaseModel):
"""Represents the authenticated user extracted from a JWT.""" """Represents the authenticated user extracted from a JWT."""

View File

@@ -4,6 +4,7 @@ import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import pytest import pytest
from httpx import ASGITransport, AsyncClient
from jose import JWTError, jwt from jose import JWTError, jwt
from src.config.settings import get_settings from src.config.settings import get_settings
@@ -140,7 +141,11 @@ class TestAuthEndpoints:
response = await client.get("/api/v1/auth/me") response = await client.get("/api/v1/auth/me")
assert response.status_code == 401 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() user_id = uuid.uuid4()
org_id = uuid.uuid4() org_id = uuid.uuid4()
token = create_access_token( token = create_access_token(
@@ -149,10 +154,34 @@ class TestAuthEndpoints:
role="editor", role="editor",
email="user@example.com", email="user@example.com",
) )
response = await client.get(
"/api/v1/auth/me", mock_user = MagicMock()
headers={"Authorization": f"Bearer {token}"}, 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 assert response.status_code == 200
data = response.json() data = response.json()
assert data["id"] == str(user_id) assert data["id"] == str(user_id)

View File

@@ -117,7 +117,8 @@ class TestMeEndpoint:
role="owner", role="owner",
email="admin@test.com", 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: async with await _client(mock_app, db) as client:
resp = await client.get( resp = await client.get(
"/api/v1/auth/me", "/api/v1/auth/me",

View File

@@ -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 ## Troubleshooting
| Symptom | Likely cause | Fix | | Symptom | Likely cause | Fix |