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:
@@ -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() {
|
||||
<Route path="/sites/:siteId" element={<SiteDetailPage />} />
|
||||
<Route path="/groups/:groupId" element={<SiteGroupDetailPage />} />
|
||||
<Route path="/consent" element={<ConsentRecordsPage />} />
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
{extensionPages
|
||||
.filter((p) => p.protected !== false)
|
||||
|
||||
@@ -17,3 +17,28 @@ export async function getMe(): Promise<User> {
|
||||
const { data } = await apiClient.get<User>('/auth/me');
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<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 extensionItems = getNavItems().map((item) => ({
|
||||
@@ -65,18 +80,47 @@ export default function Layout() {
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right: user info + mobile hamburger */}
|
||||
{/* Right: user menu + mobile hamburger */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden items-center gap-3 md:flex">
|
||||
<span className="text-sm text-text-secondary">
|
||||
{user?.full_name ?? user?.email}
|
||||
</span>
|
||||
<div className="relative hidden md:block" ref={userMenuRef}>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-sm text-text-tertiary hover:text-foreground"
|
||||
type="button"
|
||||
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>
|
||||
{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>
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
|
||||
178
apps/admin-ui/src/pages/AccountPage.tsx
Normal file
178
apps/admin-ui/src/pages/AccountPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user