From 464b8b911fa092093dcbbe1845a5cc52a8b79cdf Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 26 Feb 2026 18:39:18 +0800 Subject: [PATCH 01/31] Phase 8: Registration flow & MFA enforcement UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend: add POST /auth/totp/enforce-setup and /auth/totp/enforce-confirm endpoints that operate on mfa_enforce_token (not session cookie), generate TOTP secret/QR/backup codes, verify confirmation code, enable TOTP, clear mfa_enforce_pending flag, and issue a full session cookie - frontend: expand LockScreen to five modes — login, first-run setup, open registration, TOTP challenge, MFA enforcement setup (QR -> verify -> backup codes), and forced password change; all modes share AmbientBackground and the existing card layout; registration visible only when authStatus.registration_open is true Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/totp.py | 112 +++ frontend/src/components/auth/LockScreen.tsx | 854 +++++++++++++++----- 2 files changed, 765 insertions(+), 201 deletions(-) diff --git a/backend/app/routers/totp.py b/backend/app/routers/totp.py index e8b9be7..e147892 100644 --- a/backend/app/routers/totp.py +++ b/backend/app/routers/totp.py @@ -39,6 +39,7 @@ from app.services.auth import ( verify_password_with_upgrade, hash_password, verify_mfa_token, + verify_mfa_enforce_token, create_session_token, ) from app.services.totp import ( @@ -94,6 +95,15 @@ class BackupCodesRegenerateRequest(BaseModel): code: str # Current TOTP code required to regenerate +class EnforceSetupRequest(BaseModel): + mfa_token: str + + +class EnforceConfirmRequest(BaseModel): + mfa_token: str + code: str # 6-digit TOTP code from authenticator app + + # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- @@ -394,6 +404,108 @@ async def regenerate_backup_codes( return {"backup_codes": plaintext_codes} +@router.post("/totp/enforce-setup") +async def enforce_setup_totp( + data: EnforceSetupRequest, + db: AsyncSession = Depends(get_db), +): + """ + Generate TOTP secret + QR code + backup codes during MFA enforcement. + + Called after login returns mfa_setup_required=True. Uses the mfa_enforce_token + (not a session cookie) because the user is not yet fully authenticated. + + Idempotent: regenerates secret if called again before confirm. + Returns { secret, qr_code_base64, backup_codes }. + """ + user_id = verify_mfa_enforce_token(data.mfa_token) + if user_id is None: + raise HTTPException(status_code=401, detail="Invalid or expired enforcement token — please log in again") + + result = await db.execute(select(User).where(User.id == user_id, User.is_active == True)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=401, detail="User not found or inactive") + + if not user.mfa_enforce_pending: + raise HTTPException(status_code=400, detail="MFA enforcement is not pending for this account") + + if user.totp_enabled: + raise HTTPException(status_code=400, detail="TOTP is already enabled for this account") + + # Generate new secret (idempotent — overwrite any unconfirmed secret) + raw_secret = generate_totp_secret() + encrypted_secret = encrypt_totp_secret(raw_secret) + user.totp_secret = encrypted_secret + user.totp_enabled = False # Not enabled until enforce-confirm called + + # Generate backup codes — hash before storage, return plaintext once + plaintext_codes = generate_backup_codes(10) + await _delete_backup_codes(db, user.id) + await _store_backup_codes(db, user.id, plaintext_codes) + + await db.commit() + + uri = get_totp_uri(encrypted_secret, user.username) + qr_base64 = generate_qr_base64(uri) + + return { + "secret": raw_secret, + "qr_code_base64": qr_base64, + "backup_codes": plaintext_codes, + } + + +@router.post("/totp/enforce-confirm") +async def enforce_confirm_totp( + data: EnforceConfirmRequest, + request: Request, + response: Response, + db: AsyncSession = Depends(get_db), +): + """ + Confirm TOTP setup during enforcement, clear the pending flag, issue a full session. + + Must be called after /totp/enforce-setup while totp_enabled is still False. + On success: enables TOTP, clears mfa_enforce_pending, sets session cookie, + returns { authenticated: true }. + """ + user_id = verify_mfa_enforce_token(data.mfa_token) + if user_id is None: + raise HTTPException(status_code=401, detail="Invalid or expired enforcement token — please log in again") + + result = await db.execute(select(User).where(User.id == user_id, User.is_active == True)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=401, detail="User not found or inactive") + + if not user.mfa_enforce_pending: + raise HTTPException(status_code=400, detail="MFA enforcement is not pending for this account") + + if not user.totp_secret: + raise HTTPException(status_code=400, detail="TOTP setup not started — call /totp/enforce-setup first") + + if user.totp_enabled: + raise HTTPException(status_code=400, detail="TOTP is already enabled") + + # Verify the confirmation code + matched_window = verify_totp_code(user.totp_secret, data.code) + if matched_window is None: + raise HTTPException(status_code=400, detail="Invalid code — check your authenticator app time sync") + + # Enable TOTP and clear the enforcement flag + user.totp_enabled = True + user.mfa_enforce_pending = False + user.last_login_at = datetime.now() + await db.commit() + + # Issue a full session + token = await _create_full_session(db, user, request) + _set_session_cookie(response, token) + + return {"authenticated": True} + + @router.get("/totp/status") async def totp_status( db: AsyncSession = Depends(get_db), diff --git a/frontend/src/components/auth/LockScreen.tsx b/frontend/src/components/auth/LockScreen.tsx index 961574a..1885512 100644 --- a/frontend/src/components/auth/LockScreen.tsx +++ b/frontend/src/components/auth/LockScreen.tsx @@ -1,15 +1,16 @@ import { useState, FormEvent } from 'react'; import { Navigate } from 'react-router-dom'; import { toast } from 'sonner'; -import { Lock, Loader2 } from 'lucide-react'; +import { AlertTriangle, Copy, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; -import { getErrorMessage } from '@/lib/api'; +import api, { getErrorMessage } from '@/lib/api'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; import AmbientBackground from './AmbientBackground'; +import type { TotpSetupResponse } from '@/types'; /** Validates password against backend rules: 12-128 chars, at least one letter + one non-letter. */ function validatePassword(password: string): string | null { @@ -20,63 +21,124 @@ function validatePassword(password: string): string | null { return null; } -export default function LockScreen() { - const { authStatus, isLoading, login, setup, verifyTotp, mfaRequired, isLoginPending, isSetupPending, isTotpPending } = useAuth(); +type ScreenMode = + | 'login' + | 'setup' // first-run admin account creation + | 'register' // open registration + | 'totp' // TOTP challenge after login + | 'mfa_enforce' // forced MFA setup after login/register + | 'force_pw'; // admin-forced password change - // Credentials state (shared across login/setup states) +type MfaEnforceStep = 'qr' | 'verify' | 'backup_codes'; + +export default function LockScreen() { + const { + authStatus, + isLoading, + login, + register, + setup, + verifyTotp, + mfaRequired, + mfaSetupRequired, + mfaToken, + isLoginPending, + isRegisterPending, + isSetupPending, + isTotpPending, + } = useAuth(); + + // ── Shared credential fields ── const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); - // TOTP challenge state + // ── TOTP challenge ── const [totpCode, setTotpCode] = useState(''); const [useBackupCode, setUseBackupCode] = useState(false); - // Lockout handling (HTTP 423) + // ── Registration mode ── + const [mode, setMode] = useState('login'); + + // ── Lockout (HTTP 423) ── const [lockoutMessage, setLockoutMessage] = useState(null); - // Redirect authenticated users immediately - if (!isLoading && authStatus?.authenticated) { + // ── MFA enforcement setup flow ── + const [mfaEnforceStep, setMfaEnforceStep] = useState('qr'); + const [mfaEnforceQr, setMfaEnforceQr] = useState(''); + const [mfaEnforceSecret, setMfaEnforceSecret] = useState(''); + const [mfaEnforceBackupCodes, setMfaEnforceBackupCodes] = useState([]); + const [mfaEnforceCode, setMfaEnforceCode] = useState(''); + const [isMfaEnforceSetupPending, setIsMfaEnforceSetupPending] = useState(false); + const [isMfaEnforceConfirmPending, setIsMfaEnforceConfirmPending] = useState(false); + + // ── Forced password change ── + const [forcedNewPassword, setForcedNewPassword] = useState(''); + const [forcedConfirmPassword, setForcedConfirmPassword] = useState(''); + const [isForcePwPending, setIsForcePwPending] = useState(false); + + // Redirect authenticated users (no pending MFA flows) + if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') { return ; } const isSetup = authStatus?.setup_required === true; + const registrationOpen = authStatus?.registration_open === true; + + // Derive active screen — hook-driven states override local mode + const activeMode: ScreenMode = mfaRequired + ? 'totp' + : mfaSetupRequired + ? 'mfa_enforce' + : isSetup + ? 'setup' + : mode; + + // ── Handlers ── const handleCredentialSubmit = async (e: FormEvent) => { e.preventDefault(); setLockoutMessage(null); if (isSetup) { - // Setup mode: validate password then create account - const validationError = validatePassword(password); - if (validationError) { - toast.error(validationError); - return; - } - if (password !== confirmPassword) { - toast.error('Passwords do not match'); - return; - } + const err = validatePassword(password); + if (err) { toast.error(err); return; } + if (password !== confirmPassword) { toast.error('Passwords do not match'); return; } try { await setup({ username, password }); - // useAuth invalidates auth query → Navigate above handles redirect } catch (error) { toast.error(getErrorMessage(error, 'Failed to create account')); } - } else { - // Login mode - try { - await login({ username, password }); - // If mfaRequired becomes true, the TOTP state renders automatically - // If not required, useAuth invalidates auth query → Navigate above handles redirect - } catch (error: any) { - if (error?.response?.status === 423) { - const msg = error.response.data?.detail || 'Account locked. Try again later.'; - setLockoutMessage(msg); - } else { - toast.error(getErrorMessage(error, 'Invalid username or password')); - } + return; + } + + try { + const result = await login({ username, password }); + // must_change_password: backend issued session but UI must gate the app + if ('must_change_password' in result && result.must_change_password) { + setMode('force_pw'); } + // mfaSetupRequired / mfaRequired handled by hook state → activeMode switches automatically + } catch (error: any) { + if (error?.response?.status === 423) { + setLockoutMessage(error.response.data?.detail || 'Account locked. Try again later.'); + } else { + toast.error(getErrorMessage(error, 'Invalid username or password')); + } + } + }; + + const handleRegisterSubmit = async (e: FormEvent) => { + e.preventDefault(); + const err = validatePassword(password); + if (err) { toast.error(err); return; } + if (password !== confirmPassword) { toast.error('Passwords do not match'); return; } + try { + await register({ username, password }); + // On success useAuth invalidates query → Navigate handles redirect + // If mfa_setup_required the hook sets mfaSetupRequired → activeMode switches + } catch (error) { + toast.error(getErrorMessage(error, 'Registration failed')); } }; @@ -84,190 +146,580 @@ export default function LockScreen() { e.preventDefault(); try { await verifyTotp(totpCode); - // useAuth invalidates auth query → Navigate above handles redirect } catch (error) { toast.error(getErrorMessage(error, 'Invalid verification code')); setTotpCode(''); } }; + const handleMfaEnforceStart = async () => { + if (!mfaToken) return; + setIsMfaEnforceSetupPending(true); + try { + const { data } = await api.post('/auth/totp/enforce-setup', { + mfa_token: mfaToken, + }); + setMfaEnforceQr(data.qr_code_base64); + setMfaEnforceSecret(data.secret); + setMfaEnforceBackupCodes(data.backup_codes); + setMfaEnforceStep('qr'); + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to begin MFA setup')); + } finally { + setIsMfaEnforceSetupPending(false); + } + }; + + const handleMfaEnforceConfirm = async () => { + if (!mfaToken || !mfaEnforceCode || mfaEnforceCode.length !== 6) { + toast.error('Enter a 6-digit code from your authenticator app'); + return; + } + setIsMfaEnforceConfirmPending(true); + try { + await api.post('/auth/totp/enforce-confirm', { + mfa_token: mfaToken, + code: mfaEnforceCode, + }); + // Backend issued session — show backup codes then redirect + setMfaEnforceStep('backup_codes'); + } catch (error) { + toast.error(getErrorMessage(error, 'Invalid code — try again')); + setMfaEnforceCode(''); + } finally { + setIsMfaEnforceConfirmPending(false); + } + }; + + const handleCopyBackupCodes = async () => { + try { + await navigator.clipboard.writeText(mfaEnforceBackupCodes.join('\n')); + toast.success('Backup codes copied'); + } catch { + toast.error('Failed to copy — please select and copy manually'); + } + }; + + const handleForcePwSubmit = async (e: FormEvent) => { + e.preventDefault(); + const err = validatePassword(forcedNewPassword); + if (err) { toast.error(err); return; } + if (forcedNewPassword !== forcedConfirmPassword) { + toast.error('Passwords do not match'); + return; + } + setIsForcePwPending(true); + try { + await api.post('/auth/change-password', { + old_password: password, // retained from original login submission + new_password: forcedNewPassword, + }); + toast.success('Password updated — welcome to UMBRA'); + // Auth query still has authenticated:true → Navigate will fire after re-render + setMode('login'); + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to change password')); + } finally { + setIsForcePwPending(false); + } + }; + + // ── Render helpers ── + + const renderTotpChallenge = () => ( + <> + +
+
+
+
+ Two-Factor Authentication + + {useBackupCode ? 'Enter one of your backup codes' : 'Enter the code from your authenticator app'} + +
+
+
+ +
+
+ + + setTotpCode( + useBackupCode + ? e.target.value.replace(/[^0-9-]/g, '') + : e.target.value.replace(/\D/g, '') + ) + } + placeholder={useBackupCode ? 'XXXX-XXXX' : '000000'} + autoFocus + autoComplete="one-time-code" + className="text-center text-lg tracking-widest" + /> +
+ + +
+
+ + ); + + const renderMfaEnforce = () => { + // Show a loading/start state if QR hasn't been fetched yet + if (!mfaEnforceQr && mfaEnforceStep !== 'backup_codes') { + return ( + <> + +
+
+
+
+ Set Up Two-Factor Authentication + Your account requires MFA before you can continue +
+
+
+ +

+ An administrator has required that your account be protected with an authenticator app. + You'll need an app like Google Authenticator, Authy, or 1Password to continue. +

+ +
+ + ); + } + + if (mfaEnforceStep === 'backup_codes') { + return ( + <> + +
+
+
+
+ Save Your Backup Codes + Store these somewhere safe — they won't be shown again +
+
+
+ +

+ These {mfaEnforceBackupCodes.length} codes can each be used once if you lose access to + your authenticator app. MFA is now active on your account. +

+
+ {mfaEnforceBackupCodes.map((code, i) => ( + + {code} + + ))} +
+ + +
+ + ); + } + + if (mfaEnforceStep === 'qr') { + return ( + <> + +
+
+
+
+ Scan QR Code + Add UMBRA to your authenticator app +
+
+
+ +
+ TOTP QR code — scan with your authenticator app +
+

+ Can't scan? Enter this code manually in your app: +

+ + {mfaEnforceSecret} + + +
+ + ); + } + + // verify step + return ( + <> + +
+
+
+
+ Verify Your Authenticator + Enter the 6-digit code shown in your app +
+
+
+ +
+ + setMfaEnforceCode(e.target.value.replace(/\D/g, ''))} + className="text-center tracking-widest text-lg" + autoFocus + autoComplete="one-time-code" + onKeyDown={(e) => { if (e.key === 'Enter') handleMfaEnforceConfirm(); }} + /> +
+ + +
+ + ); + }; + + const renderLoginOrSetup = () => ( + <> + +
+
+
+
+ {isSetup ? 'Welcome to UMBRA' : 'Sign in'} + + {isSetup ? 'Create your account to get started' : 'Enter your credentials to continue'} + +
+
+
+ + {lockoutMessage && ( +
+
+ )} +
+
+ + { setUsername(e.target.value); setLockoutMessage(null); }} + placeholder="Enter username" + required + autoFocus + autoComplete="username" + /> +
+
+ + { setPassword(e.target.value); setLockoutMessage(null); }} + placeholder={isSetup ? 'Create a password' : 'Enter password'} + required + autoComplete={isSetup ? 'new-password' : 'current-password'} + /> +
+ {isSetup && ( +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm your password" + required + autoComplete="new-password" + /> +

+ Must be 12-128 characters with at least one letter and one non-letter. +

+
+ )} + +
+ + {/* Open registration link — only shown on login screen when enabled */} + {!isSetup && registrationOpen && ( +
+ +
+ )} +
+ + ); + + const renderRegister = () => ( + <> + +
+
+
+
+ Create Account + Register for access to UMBRA +
+
+
+ +
+
+ + setUsername(e.target.value)} + placeholder="Choose a username" + required + autoFocus + autoComplete="username" + /> +
+
+ + setPassword(e.target.value)} + placeholder="Create a password" + required + autoComplete="new-password" + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm your password" + required + autoComplete="new-password" + /> +

+ Must be 12-128 characters with at least one letter and one non-letter. +

+
+ +
+
+ +
+
+ + ); + + const renderForcedPasswordChange = () => ( + <> + +
+
+
+
+ Password Change Required + An administrator has reset your password. Please set a new one. +
+
+
+ +
+
+ + setForcedNewPassword(e.target.value)} + placeholder="Create a new password" + required + autoFocus + autoComplete="new-password" + /> +
+
+ + setForcedConfirmPassword(e.target.value)} + placeholder="Confirm your new password" + required + autoComplete="new-password" + /> +

+ Must be 12-128 characters with at least one letter and one non-letter. +

+
+ +
+
+ + ); + return (
- {/* Wordmark — in flex flow above card */} + {/* Wordmark */} UMBRA {/* Auth card */} - {mfaRequired ? ( - // State C: TOTP challenge - <> - -
-
-
-
- Two-Factor Authentication - - {useBackupCode - ? 'Enter one of your backup codes' - : 'Enter the code from your authenticator app'} - -
-
-
- -
-
- - - setTotpCode( - useBackupCode - ? e.target.value.replace(/[^0-9-]/g, '') - : e.target.value.replace(/\D/g, '') - ) - } - placeholder={useBackupCode ? 'XXXX-XXXX' : '000000'} - autoFocus - autoComplete="one-time-code" - className="text-center text-lg tracking-widest" - /> -
- - -
-
- - ) : ( - // State A (setup) or State B (login) - <> - -
-
-
-
- {isSetup ? 'Welcome to UMBRA' : 'Sign in'} - - {isSetup - ? 'Create your account to get started' - : 'Enter your credentials to continue'} - -
-
-
- - {/* Lockout warning banner */} - {lockoutMessage && ( -
-
- )} - -
-
- - { setUsername(e.target.value); setLockoutMessage(null); }} - placeholder="Enter username" - required - autoFocus - autoComplete="username" - /> -
- -
- - { setPassword(e.target.value); setLockoutMessage(null); }} - placeholder={isSetup ? 'Create a password' : 'Enter password'} - required - autoComplete={isSetup ? 'new-password' : 'current-password'} - /> -
- - {isSetup && ( -
- - setConfirmPassword(e.target.value)} - placeholder="Confirm your password" - required - autoComplete="new-password" - /> -

- Must be 12-128 characters with at least one letter and one non-letter. -

-
- )} - - -
-
- - )} + {activeMode === 'totp' && renderTotpChallenge()} + {activeMode === 'mfa_enforce' && renderMfaEnforce()} + {activeMode === 'force_pw' && renderForcedPasswordChange()} + {activeMode === 'register' && renderRegister()} + {(activeMode === 'login' || activeMode === 'setup') && renderLoginOrSetup()}
); From 2ec70d93443377a6df01b87cb49ef103ff41e8c0 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 26 Feb 2026 18:40:16 +0800 Subject: [PATCH 02/31] Add Phase 7 admin portal frontend (IAM, Config, Dashboard) Creates 7 files: useAdmin hook with TanStack Query v5, AdminPortal layout with horizontal tab nav, IAMPage with user table + stat cards + system settings, UserActionsMenu with two-click confirms, CreateUserDialog, ConfigPage with paginated audit log + action filter, AdminDashboardPage with stats + recent logins/actions tables. Co-Authored-By: Claude Opus 4.6 --- .../components/admin/AdminDashboardPage.tsx | 241 ++++++++++++++ frontend/src/components/admin/AdminPortal.tsx | 64 ++++ frontend/src/components/admin/ConfigPage.tsx | 231 ++++++++++++++ .../src/components/admin/CreateUserDialog.tsx | 121 +++++++ frontend/src/components/admin/IAMPage.tsx | 298 ++++++++++++++++++ .../src/components/admin/UserActionsMenu.tsx | 296 +++++++++++++++++ frontend/src/hooks/useAdmin.ts | 165 ++++++++++ 7 files changed, 1416 insertions(+) create mode 100644 frontend/src/components/admin/AdminDashboardPage.tsx create mode 100644 frontend/src/components/admin/AdminPortal.tsx create mode 100644 frontend/src/components/admin/ConfigPage.tsx create mode 100644 frontend/src/components/admin/CreateUserDialog.tsx create mode 100644 frontend/src/components/admin/IAMPage.tsx create mode 100644 frontend/src/components/admin/UserActionsMenu.tsx create mode 100644 frontend/src/hooks/useAdmin.ts diff --git a/frontend/src/components/admin/AdminDashboardPage.tsx b/frontend/src/components/admin/AdminDashboardPage.tsx new file mode 100644 index 0000000..33d95c4 --- /dev/null +++ b/frontend/src/components/admin/AdminDashboardPage.tsx @@ -0,0 +1,241 @@ +import { + Users, + UserCheck, + UserX, + Activity, + Smartphone, + LogIn, + ShieldAlert, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAdminDashboard, useAuditLog } from '@/hooks/useAdmin'; +import { getRelativeTime } from '@/lib/date-utils'; +import { cn } from '@/lib/utils'; + +interface StatCardProps { + icon: React.ReactNode; + label: string; + value: string | number; + iconBg?: string; +} + +function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) { + return ( + + +
+
{icon}
+
+

{label}

+

{value}

+
+
+
+
+ ); +} + +function actionColor(action: string): string { + if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) { + return 'bg-red-500/15 text-red-400'; + } + if (action.includes('login') || action.includes('create') || action.includes('enabled')) { + return 'bg-green-500/15 text-green-400'; + } + if (action.includes('config') || action.includes('role') || action.includes('password')) { + return 'bg-orange-500/15 text-orange-400'; + } + return 'bg-blue-500/15 text-blue-400'; +} + +export default function AdminDashboardPage() { + const { data: dashboard, isLoading } = useAdminDashboard(); + const { data: auditData } = useAuditLog(1, 10); + + const mfaPct = dashboard ? Math.round(dashboard.mfa_adoption_rate * 100) : null; + const disabledUsers = + dashboard ? dashboard.total_users - dashboard.active_users : null; + + return ( +
+ {/* Stats grid */} +
+ {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + + )) + ) : ( + <> + } + label="Total Users" + value={dashboard?.total_users ?? '—'} + /> + } + label="Active Users" + value={dashboard?.active_users ?? '—'} + iconBg="bg-green-500/10" + /> + } + label="Disabled Users" + value={disabledUsers ?? '—'} + iconBg="bg-red-500/10" + /> + } + label="Active Sessions" + value={dashboard?.active_sessions ?? '—'} + iconBg="bg-blue-500/10" + /> + } + label="MFA Adoption" + value={mfaPct !== null ? `${mfaPct}%` : '—'} + iconBg="bg-purple-500/10" + /> + + )} +
+ +
+ {/* Recent logins */} + + +
+
+ +
+ Recent Logins +
+
+ + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : !dashboard?.recent_logins?.length ? ( +

No recent logins.

+ ) : ( +
+ + + + + + + + + + {dashboard.recent_logins.map((entry, idx) => ( + + + + + + ))} + +
+ Username + + When + + IP +
{entry.username} + {getRelativeTime(entry.last_login_at)} + + {entry.ip_address ?? '—'} +
+
+ )} +
+
+ + {/* Recent admin actions */} + + +
+
+ +
+ Recent Admin Actions +
+
+ + {!auditData?.entries?.length ? ( +

No recent actions.

+ ) : ( +
+ + + + + + + + + + + {auditData.entries.slice(0, 10).map((entry, idx) => ( + + + + + + + ))} + +
+ Action + + Actor + + Target + + When +
+ + {entry.action} + + + {entry.actor_username ?? ( + system + )} + + {entry.target_username ?? '—'} + + {getRelativeTime(entry.created_at)} +
+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/admin/AdminPortal.tsx b/frontend/src/components/admin/AdminPortal.tsx new file mode 100644 index 0000000..b519f6f --- /dev/null +++ b/frontend/src/components/admin/AdminPortal.tsx @@ -0,0 +1,64 @@ +import { NavLink, Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { Users, Settings2, LayoutDashboard, ShieldCheck } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import IAMPage from './IAMPage'; +import ConfigPage from './ConfigPage'; +import AdminDashboardPage from './AdminDashboardPage'; + +const tabs = [ + { label: 'IAM Management', path: '/admin/iam', icon: Users }, + { label: 'Configuration', path: '/admin/config', icon: Settings2 }, + { label: 'Management Dashboard', path: '/admin/dashboard', icon: LayoutDashboard }, +]; + +export default function AdminPortal() { + const location = useLocation(); + + return ( +
+ {/* Portal header with tab navigation */} +
+
+
+
+ +
+

Admin Portal

+
+ + {/* Horizontal tab navigation */} + +
+
+ + {/* Page content */} +
+ + } /> + } /> + } /> + } /> + +
+
+ ); +} diff --git a/frontend/src/components/admin/ConfigPage.tsx b/frontend/src/components/admin/ConfigPage.tsx new file mode 100644 index 0000000..8a9ce15 --- /dev/null +++ b/frontend/src/components/admin/ConfigPage.tsx @@ -0,0 +1,231 @@ +import { useState } from 'react'; +import { + FileText, + ChevronLeft, + ChevronRight, + Filter, + X, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Select } from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAuditLog } from '@/hooks/useAdmin'; +import { getRelativeTime } from '@/lib/date-utils'; +import { cn } from '@/lib/utils'; + +const ACTION_TYPES = [ + 'user.create', + 'user.login', + 'user.logout', + 'user.login_failed', + 'user.locked', + 'user.unlocked', + 'user.role_changed', + 'user.disabled', + 'user.enabled', + 'user.password_reset', + 'user.totp_disabled', + 'user.mfa_enforced', + 'user.mfa_enforcement_removed', + 'user.sessions_revoked', + 'config.updated', +]; + +function actionLabel(action: string): string { + return action + .split('.') + .map((p) => p.replace(/_/g, ' ')) + .join(' — '); +} + +function actionColor(action: string): string { + if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) { + return 'bg-red-500/15 text-red-400'; + } + if (action.includes('login') || action.includes('create') || action.includes('enabled')) { + return 'bg-green-500/15 text-green-400'; + } + if (action.includes('config') || action.includes('role') || action.includes('password')) { + return 'bg-orange-500/15 text-orange-400'; + } + return 'bg-blue-500/15 text-blue-400'; +} + +export default function ConfigPage() { + const [page, setPage] = useState(1); + const [filterAction, setFilterAction] = useState(''); + const PER_PAGE = 25; + + const { data, isLoading } = useAuditLog(page, PER_PAGE, filterAction || undefined); + + const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1; + + return ( +
+ + +
+
+ +
+ Audit Log + {data && ( + + {data.total} entries + + )} +
+ + {/* Filter controls */} +
+
+ + Filter: +
+
+ +
+ {filterAction && ( + + )} +
+
+ + + {isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : !data?.entries?.length ? ( +

No audit entries found.

+ ) : ( + <> +
+ + + + + + + + + + + + + {data.entries.map((entry, idx) => ( + + + + + + + + + ))} + +
+ Time + + Actor + + Action + + Target + + IP + + Detail +
+ {getRelativeTime(entry.created_at)} + + {entry.actor_username ?? ( + system + )} + + + {entry.action} + + + {entry.target_username ?? '—'} + + {entry.ip_address ?? '—'} + + {entry.detail ?? '—'} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ + +
+
+ )} + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/admin/CreateUserDialog.tsx b/frontend/src/components/admin/CreateUserDialog.tsx new file mode 100644 index 0000000..a44164e --- /dev/null +++ b/frontend/src/components/admin/CreateUserDialog.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react'; +import { toast } from 'sonner'; +import { UserPlus, Loader2 } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select } from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; +import { useCreateUser, getErrorMessage } from '@/hooks/useAdmin'; +import type { UserRole } from '@/types'; + +interface CreateUserDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialogProps) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState('standard'); + + const createUser = useCreateUser(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!username.trim() || !password.trim()) return; + + try { + await createUser.mutateAsync({ username: username.trim(), password, role }); + toast.success(`User "${username.trim()}" created successfully`); + setUsername(''); + setPassword(''); + setRole('standard'); + onOpenChange(false); + } catch (err) { + toast.error(getErrorMessage(err, 'Failed to create user')); + } + }; + + return ( + + + + + + Create User + + + onOpenChange(false)} /> + +
+
+ + setUsername(e.target.value)} + placeholder="Enter username" + autoFocus + required + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Min. 8 characters" + required + /> +

+ Must be at least 8 characters. The user will be prompted to change it on first login. +

+
+ +
+ + +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/admin/IAMPage.tsx b/frontend/src/components/admin/IAMPage.tsx new file mode 100644 index 0000000..64329b9 --- /dev/null +++ b/frontend/src/components/admin/IAMPage.tsx @@ -0,0 +1,298 @@ +import { useState } from 'react'; +import { toast } from 'sonner'; +import { + Users, + UserCheck, + ShieldCheck, + Smartphone, + Plus, + Loader2, + Activity, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + useAdminUsers, + useAdminDashboard, + useAdminConfig, + useUpdateConfig, + getErrorMessage, +} from '@/hooks/useAdmin'; +import { getRelativeTime } from '@/lib/date-utils'; +import type { AdminUserDetail, UserRole } from '@/types'; +import { cn } from '@/lib/utils'; +import UserActionsMenu from './UserActionsMenu'; +import CreateUserDialog from './CreateUserDialog'; + +// ── Role badge ──────────────────────────────────────────────────────────────── + +function RoleBadge({ role }: { role: UserRole }) { + const styles: Record = { + admin: 'bg-red-500/15 text-red-400', + standard: 'bg-blue-500/15 text-blue-400', + public_event_manager: 'bg-purple-500/15 text-purple-400', + }; + const labels: Record = { + admin: 'Admin', + standard: 'Standard', + public_event_manager: 'Pub. Events', + }; + return ( + + {labels[role]} + + ); +} + +// ── Stat card ───────────────────────────────────────────────────────────────── + +interface StatCardProps { + icon: React.ReactNode; + label: string; + value: string | number; + iconBg?: string; +} + +function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) { + return ( + + +
+
{icon}
+
+

{label}

+

{value}

+
+
+
+
+ ); +} + +// ── Main page ───────────────────────────────────────────────────────────────── + +export default function IAMPage() { + const [createOpen, setCreateOpen] = useState(false); + + const { data: users, isLoading: usersLoading } = useAdminUsers(); + const { data: dashboard } = useAdminDashboard(); + const { data: config, isLoading: configLoading } = useAdminConfig(); + const updateConfig = useUpdateConfig(); + + const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users', value: boolean) => { + try { + await updateConfig.mutateAsync({ [key]: value }); + toast.success('System settings updated'); + } catch (err) { + toast.error(getErrorMessage(err, 'Failed to update settings')); + } + }; + + const mfaPct = dashboard + ? Math.round(dashboard.mfa_adoption_rate * 100) + : null; + + return ( +
+ {/* Stats row */} +
+ } + label="Total Users" + value={dashboard?.total_users ?? '—'} + /> + } + label="Active Sessions" + value={dashboard?.active_sessions ?? '—'} + iconBg="bg-green-500/10" + /> + } + label="Admins" + value={dashboard?.admin_count ?? '—'} + iconBg="bg-red-500/10" + /> + } + label="MFA Adoption" + value={mfaPct !== null ? `${mfaPct}%` : '—'} + iconBg="bg-purple-500/10" + /> +
+ + {/* User table */} + + +
+
+ +
+ Users +
+ +
+ + {usersLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : !users?.length ? ( +

No users found.

+ ) : ( +
+ + + + + + + + + + + + + + + {users.map((user: AdminUserDetail, idx) => ( + + + + + + + + + + + ))} + +
+ Username + + Role + + Status + + Last Login + + MFA + + Sessions + + Created + + Actions +
{user.username} + + + + {user.is_active ? 'Active' : 'Disabled'} + + + {user.last_login_at ? getRelativeTime(user.last_login_at) : '—'} + + {user.totp_enabled ? ( + + On + + ) : user.mfa_enforce_pending ? ( + + Pending + + ) : ( + + )} + + {user.active_sessions} + + {getRelativeTime(user.created_at)} + + +
+
+ )} +
+
+ + {/* System settings */} + + +
+
+ +
+ System Settings +
+
+ + {configLoading ? ( +
+ + +
+ ) : ( + <> +
+
+ +

+ When enabled, the /register page accepts new sign-ups. +

+
+ handleConfigToggle('allow_registration', v)} + disabled={updateConfig.isPending} + /> +
+ +
+
+ +

+ Newly registered users will be required to set up TOTP before accessing the app. +

+
+ handleConfigToggle('enforce_mfa_new_users', v)} + disabled={updateConfig.isPending} + /> +
+ + )} +
+
+ + +
+ ); +} diff --git a/frontend/src/components/admin/UserActionsMenu.tsx b/frontend/src/components/admin/UserActionsMenu.tsx new file mode 100644 index 0000000..3163b2f --- /dev/null +++ b/frontend/src/components/admin/UserActionsMenu.tsx @@ -0,0 +1,296 @@ +import { useState, useRef, useEffect } from 'react'; +import { toast } from 'sonner'; +import { + MoreHorizontal, + ShieldCheck, + ShieldOff, + KeyRound, + UserX, + UserCheck, + LogOut, + Smartphone, + SmartphoneOff, + ChevronRight, + Loader2, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useConfirmAction } from '@/hooks/useConfirmAction'; +import { + useUpdateRole, + useResetPassword, + useDisableMfa, + useEnforceMfa, + useRemoveMfaEnforcement, + useToggleUserActive, + useRevokeSessions, + getErrorMessage, +} from '@/hooks/useAdmin'; +import type { AdminUserDetail, UserRole } from '@/types'; +import { cn } from '@/lib/utils'; + +interface UserActionsMenuProps { + user: AdminUserDetail; +} + +const ROLES: { value: UserRole; label: string }[] = [ + { value: 'admin', label: 'Admin' }, + { value: 'standard', label: 'Standard' }, + { value: 'public_event_manager', label: 'Public Event Manager' }, +]; + +export default function UserActionsMenu({ user }: UserActionsMenuProps) { + const [open, setOpen] = useState(false); + const [roleSubmenuOpen, setRoleSubmenuOpen] = useState(false); + const [showResetPassword, setShowResetPassword] = useState(false); + const [newPassword, setNewPassword] = useState(''); + const menuRef = useRef(null); + + const updateRole = useUpdateRole(); + const resetPassword = useResetPassword(); + const disableMfa = useDisableMfa(); + const enforceMfa = useEnforceMfa(); + const removeMfaEnforcement = useRemoveMfaEnforcement(); + const toggleActive = useToggleUserActive(); + const revokeSessions = useRevokeSessions(); + + // Close on outside click + useEffect(() => { + const handleOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + setRoleSubmenuOpen(false); + } + }; + if (open) document.addEventListener('mousedown', handleOutside); + return () => document.removeEventListener('mousedown', handleOutside); + }, [open]); + + const handleAction = async (fn: () => Promise, successMsg: string) => { + try { + await fn(); + toast.success(successMsg); + setOpen(false); + } catch (err) { + toast.error(getErrorMessage(err, 'Action failed')); + } + }; + + // Two-click confirms + const disableMfaConfirm = useConfirmAction(() => { + handleAction(() => disableMfa.mutateAsync(user.id), 'MFA disabled'); + }); + + const toggleActiveConfirm = useConfirmAction(() => { + handleAction( + () => toggleActive.mutateAsync({ userId: user.id, active: !user.is_active }), + user.is_active ? 'Account disabled' : 'Account enabled' + ); + }); + + const revokeSessionsConfirm = useConfirmAction(() => { + handleAction(() => revokeSessions.mutateAsync(user.id), 'Sessions revoked'); + }); + + const isLoading = + updateRole.isPending || + resetPassword.isPending || + disableMfa.isPending || + enforceMfa.isPending || + removeMfaEnforcement.isPending || + toggleActive.isPending || + revokeSessions.isPending; + + return ( +
+ + + {open && ( +
+ {/* Edit Role */} +
+ + + {roleSubmenuOpen && ( +
setRoleSubmenuOpen(true)} + onMouseLeave={() => setRoleSubmenuOpen(false)} + > + {ROLES.map(({ value, label }) => ( + + ))} +
+ )} +
+ + {/* Reset Password */} + {!showResetPassword ? ( + + ) : ( +
+ setNewPassword(e.target.value)} + autoFocus + /> +
+ + +
+
+ )} + +
+ + {/* MFA actions */} + {user.mfa_enforce_pending ? ( + + ) : ( + + )} + + {user.totp_enabled && ( + + )} + +
+ + {/* Disable / Enable Account */} + + + {/* Revoke Sessions */} + +
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts new file mode 100644 index 0000000..3d9d484 --- /dev/null +++ b/frontend/src/hooks/useAdmin.ts @@ -0,0 +1,165 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api, { getErrorMessage } from '@/lib/api'; +import type { + AdminUser, + AdminUserDetail, + AdminDashboardData, + SystemConfig, + AuditLogEntry, + UserRole, +} from '@/types'; + +interface AuditLogResponse { + entries: AuditLogEntry[]; + total: number; + page: number; + per_page: number; +} + +interface CreateUserPayload { + username: string; + password: string; + role: UserRole; +} + +interface UpdateRolePayload { + userId: number; + role: UserRole; +} + +interface ResetPasswordPayload { + userId: number; + new_password: string; +} + +// ── Queries ────────────────────────────────────────────────────────────────── + +export function useAdminUsers() { + return useQuery({ + queryKey: ['admin', 'users'], + queryFn: async () => { + const { data } = await api.get('/admin/users'); + return data; + }, + }); +} + +export function useAdminDashboard() { + return useQuery({ + queryKey: ['admin', 'dashboard'], + queryFn: async () => { + const { data } = await api.get('/admin/dashboard'); + return data; + }, + }); +} + +export function useAdminConfig() { + return useQuery({ + queryKey: ['admin', 'config'], + queryFn: async () => { + const { data } = await api.get('/admin/config'); + return data; + }, + }); +} + +export function useAuditLog( + page: number, + perPage: number, + action?: string, + targetUserId?: number +) { + return useQuery({ + queryKey: ['admin', 'audit-log', page, perPage, action, targetUserId], + queryFn: async () => { + const params: Record = { page, per_page: perPage }; + if (action) params.action = action; + if (targetUserId) params.target_user_id = targetUserId; + const { data } = await api.get('/admin/audit-log', { params }); + return data; + }, + }); +} + +// ── Mutations ───────────────────────────────────────────────────────────────── + +function useAdminMutation( + mutationFn: (vars: TVariables) => Promise, + onSuccess?: () => void +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin'] }); + onSuccess?.(); + }, + }); +} + +export function useCreateUser() { + return useAdminMutation(async (payload: CreateUserPayload) => { + const { data } = await api.post('/admin/users', payload); + return data; + }); +} + +export function useUpdateRole() { + return useAdminMutation(async ({ userId, role }: UpdateRolePayload) => { + const { data } = await api.patch(`/admin/users/${userId}/role`, { role }); + return data; + }); +} + +export function useResetPassword() { + return useAdminMutation(async ({ userId, new_password }: ResetPasswordPayload) => { + const { data } = await api.post(`/admin/users/${userId}/reset-password`, { new_password }); + return data; + }); +} + +export function useDisableMfa() { + return useAdminMutation(async (userId: number) => { + const { data } = await api.delete(`/admin/users/${userId}/totp`); + return data; + }); +} + +export function useEnforceMfa() { + return useAdminMutation(async (userId: number) => { + const { data } = await api.post(`/admin/users/${userId}/enforce-mfa`); + return data; + }); +} + +export function useRemoveMfaEnforcement() { + return useAdminMutation(async (userId: number) => { + const { data } = await api.delete(`/admin/users/${userId}/enforce-mfa`); + return data; + }); +} + +export function useToggleUserActive() { + return useAdminMutation(async ({ userId, active }: { userId: number; active: boolean }) => { + const { data } = await api.patch(`/admin/users/${userId}/active`, { is_active: active }); + return data; + }); +} + +export function useRevokeSessions() { + return useAdminMutation(async (userId: number) => { + const { data } = await api.delete(`/admin/users/${userId}/sessions`); + return data; + }); +} + +export function useUpdateConfig() { + return useAdminMutation(async (config: Partial) => { + const { data } = await api.patch('/admin/config', config); + return data; + }); +} + +// Re-export getErrorMessage for convenience in admin components +export { getErrorMessage }; From d8bdae8ec39116b8138d375a38f9de113f799018 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 26 Feb 2026 19:06:25 +0800 Subject: [PATCH 03/31] Implement multi-user RBAC: database, auth, routing, admin API (Phases 1-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Add role, mfa_enforce_pending, must_change_password to users table. Create system_config (singleton) and audit_log tables. Migration 026. Phase 2: Add user_id FK to all 8 data tables (todos, reminders, projects, calendars, people, locations, event_templates, ntfy_sent) with 4-step nullable→backfill→FK→NOT NULL pattern. Migrations 027-034. Phase 3: Harden auth schemas (extra="forbid" on RegisterRequest), add MFA enforcement token serializer with distinct salt, rewrite auth router with require_role() factory and registration endpoint. Phase 4: Scope all 12 routers by user_id, fix dependency type bugs, bound weather cache (SEC-15), multi-user ntfy dispatch. Phase 5: Create admin router (14 endpoints), admin schemas, audit service, rate limiting in nginx. SEC-08 CSRF via X-Requested-With. Phase 6: Update frontend types, useAuth hook (role/isAdmin/register), App.tsx (AdminRoute guard), Sidebar (admin link), api.ts (XHR header). Security findings addressed: SEC-01, SEC-02, SEC-03, SEC-04, SEC-05, SEC-06, SEC-07, SEC-08, SEC-12, SEC-13, SEC-15. Co-Authored-By: Claude Opus 4.6 --- .../026_add_user_role_and_system_config.py | 101 +++ .../versions/027_add_user_id_to_todos.py | 38 + .../versions/028_add_user_id_to_reminders.py | 36 + .../versions/029_add_user_id_to_projects.py | 36 + .../versions/030_add_user_id_to_calendars.py | 36 + .../versions/031_add_user_id_to_people.py | 36 + .../versions/032_add_user_id_to_locations.py | 34 + .../033_add_user_id_to_event_templates.py | 34 + .../versions/034_add_user_id_to_ntfy_sent.py | 46 ++ backend/app/jobs/notifications.py | 69 +- backend/app/main.py | 5 +- backend/app/models/__init__.py | 4 + backend/app/models/audit_log.py | 27 + backend/app/models/calendar.py | 5 +- backend/app/models/event_template.py | 3 + backend/app/models/location.py | 5 +- backend/app/models/ntfy_sent.py | 12 +- backend/app/models/person.py | 5 +- backend/app/models/project.py | 5 +- backend/app/models/reminder.py | 5 +- backend/app/models/system_config.py | 27 + backend/app/models/todo.py | 3 + backend/app/models/user.py | 16 + backend/app/routers/admin.py | 687 ++++++++++++++++++ backend/app/routers/auth.py | 242 +++++- backend/app/routers/calendars.py | 24 +- backend/app/routers/dashboard.py | 46 +- backend/app/routers/event_templates.py | 18 +- backend/app/routers/events.py | 74 +- backend/app/routers/locations.py | 21 +- backend/app/routers/people.py | 16 +- backend/app/routers/projects.py | 67 +- backend/app/routers/reminders.py | 40 +- backend/app/routers/todos.py | 45 +- backend/app/routers/weather.py | 56 +- backend/app/schemas/admin.py | 133 ++++ backend/app/schemas/auth.py | 41 +- backend/app/services/audit.py | 22 + backend/app/services/auth.py | 29 + frontend/nginx.conf | 18 + frontend/src/App.tsx | 31 + frontend/src/components/layout/Sidebar.tsx | 13 +- frontend/src/hooks/useAuth.ts | 39 +- frontend/src/lib/api.ts | 1 + frontend/src/types/index.ts | 64 +- 45 files changed, 2148 insertions(+), 167 deletions(-) create mode 100644 backend/alembic/versions/026_add_user_role_and_system_config.py create mode 100644 backend/alembic/versions/027_add_user_id_to_todos.py create mode 100644 backend/alembic/versions/028_add_user_id_to_reminders.py create mode 100644 backend/alembic/versions/029_add_user_id_to_projects.py create mode 100644 backend/alembic/versions/030_add_user_id_to_calendars.py create mode 100644 backend/alembic/versions/031_add_user_id_to_people.py create mode 100644 backend/alembic/versions/032_add_user_id_to_locations.py create mode 100644 backend/alembic/versions/033_add_user_id_to_event_templates.py create mode 100644 backend/alembic/versions/034_add_user_id_to_ntfy_sent.py create mode 100644 backend/app/models/audit_log.py create mode 100644 backend/app/models/system_config.py create mode 100644 backend/app/routers/admin.py create mode 100644 backend/app/schemas/admin.py create mode 100644 backend/app/services/audit.py diff --git a/backend/alembic/versions/026_add_user_role_and_system_config.py b/backend/alembic/versions/026_add_user_role_and_system_config.py new file mode 100644 index 0000000..9bb863a --- /dev/null +++ b/backend/alembic/versions/026_add_user_role_and_system_config.py @@ -0,0 +1,101 @@ +"""Add role, mfa_enforce_pending, must_change_password to users; create system_config table. + +Revision ID: 026 +Revises: 025 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "026" +down_revision = "025" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Add role column with server_default for existing rows + op.add_column("users", sa.Column( + "role", sa.String(30), nullable=False, server_default="standard" + )) + + # 2. Add MFA enforcement pending flag + op.add_column("users", sa.Column( + "mfa_enforce_pending", sa.Boolean(), nullable=False, server_default="false" + )) + + # 3. Add forced password change flag (SEC-12) + op.add_column("users", sa.Column( + "must_change_password", sa.Boolean(), nullable=False, server_default="false" + )) + + # 4. Add last_password_change_at audit column + op.add_column("users", sa.Column( + "last_password_change_at", sa.DateTime(), nullable=True + )) + + # 5. Add CHECK constraint on role values (SEC-16) + op.create_check_constraint( + "ck_users_role", + "users", + "role IN ('admin', 'standard', 'public_event_manager')" + ) + + # 6. Promote the first (existing) user to admin + op.execute( + "UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)" + ) + + # 7. Create system_config table (singleton pattern -- always id=1) + op.create_table( + "system_config", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("allow_registration", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("enforce_mfa_new_users", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")), + sa.PrimaryKeyConstraint("id"), + # SEC-09: Enforce singleton row + sa.CheckConstraint("id = 1", name="ck_system_config_singleton"), + ) + + # 8. Seed the singleton row + op.execute( + "INSERT INTO system_config (id, allow_registration, enforce_mfa_new_users) " + "VALUES (1, false, false)" + ) + + # 9. Create audit_log table + op.create_table( + "audit_log", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("actor_user_id", sa.Integer(), nullable=True), + sa.Column("target_user_id", sa.Integer(), nullable=True), + sa.Column("action", sa.String(100), nullable=False), + sa.Column("detail", sa.Text(), nullable=True), + sa.Column("ip_address", sa.String(45), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"]), + sa.ForeignKeyConstraint( + ["target_user_id"], ["users.id"], ondelete="SET NULL" + ), + ) + op.create_index("ix_audit_log_actor_user_id", "audit_log", ["actor_user_id"]) + op.create_index("ix_audit_log_target_user_id", "audit_log", ["target_user_id"]) + op.create_index("ix_audit_log_action", "audit_log", ["action"]) + op.create_index("ix_audit_log_created_at", "audit_log", ["created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_audit_log_created_at", table_name="audit_log") + op.drop_index("ix_audit_log_action", table_name="audit_log") + op.drop_index("ix_audit_log_target_user_id", table_name="audit_log") + op.drop_index("ix_audit_log_actor_user_id", table_name="audit_log") + op.drop_table("audit_log") + op.drop_table("system_config") + op.drop_constraint("ck_users_role", "users", type_="check") + op.drop_column("users", "last_password_change_at") + op.drop_column("users", "must_change_password") + op.drop_column("users", "mfa_enforce_pending") + op.drop_column("users", "role") diff --git a/backend/alembic/versions/027_add_user_id_to_todos.py b/backend/alembic/versions/027_add_user_id_to_todos.py new file mode 100644 index 0000000..4a60855 --- /dev/null +++ b/backend/alembic/versions/027_add_user_id_to_todos.py @@ -0,0 +1,38 @@ +"""Add user_id FK to todos table. + +Revision ID: 027 +Revises: 026 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "027" +down_revision = "026" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("todos", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE todos SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_todos_user_id", "todos", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("todos", "user_id", nullable=False) + op.create_index("ix_todos_user_id", "todos", ["user_id"]) + op.create_index("ix_todos_user_completed", "todos", ["user_id", "completed"]) + op.create_index("ix_todos_user_due_date", "todos", ["user_id", "due_date"]) + + +def downgrade() -> None: + op.drop_index("ix_todos_user_due_date", table_name="todos") + op.drop_index("ix_todos_user_completed", table_name="todos") + op.drop_index("ix_todos_user_id", table_name="todos") + op.drop_constraint("fk_todos_user_id", "todos", type_="foreignkey") + op.drop_column("todos", "user_id") diff --git a/backend/alembic/versions/028_add_user_id_to_reminders.py b/backend/alembic/versions/028_add_user_id_to_reminders.py new file mode 100644 index 0000000..75a2dbb --- /dev/null +++ b/backend/alembic/versions/028_add_user_id_to_reminders.py @@ -0,0 +1,36 @@ +"""Add user_id FK to reminders table. + +Revision ID: 028 +Revises: 027 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "028" +down_revision = "027" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("reminders", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE reminders SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_reminders_user_id", "reminders", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("reminders", "user_id", nullable=False) + op.create_index("ix_reminders_user_id", "reminders", ["user_id"]) + op.create_index("ix_reminders_user_remind_at", "reminders", ["user_id", "remind_at"]) + + +def downgrade() -> None: + op.drop_index("ix_reminders_user_remind_at", table_name="reminders") + op.drop_index("ix_reminders_user_id", table_name="reminders") + op.drop_constraint("fk_reminders_user_id", "reminders", type_="foreignkey") + op.drop_column("reminders", "user_id") diff --git a/backend/alembic/versions/029_add_user_id_to_projects.py b/backend/alembic/versions/029_add_user_id_to_projects.py new file mode 100644 index 0000000..f5f9031 --- /dev/null +++ b/backend/alembic/versions/029_add_user_id_to_projects.py @@ -0,0 +1,36 @@ +"""Add user_id FK to projects table. + +Revision ID: 029 +Revises: 028 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "029" +down_revision = "028" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("projects", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE projects SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_projects_user_id", "projects", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("projects", "user_id", nullable=False) + op.create_index("ix_projects_user_id", "projects", ["user_id"]) + op.create_index("ix_projects_user_status", "projects", ["user_id", "status"]) + + +def downgrade() -> None: + op.drop_index("ix_projects_user_status", table_name="projects") + op.drop_index("ix_projects_user_id", table_name="projects") + op.drop_constraint("fk_projects_user_id", "projects", type_="foreignkey") + op.drop_column("projects", "user_id") diff --git a/backend/alembic/versions/030_add_user_id_to_calendars.py b/backend/alembic/versions/030_add_user_id_to_calendars.py new file mode 100644 index 0000000..ef1b621 --- /dev/null +++ b/backend/alembic/versions/030_add_user_id_to_calendars.py @@ -0,0 +1,36 @@ +"""Add user_id FK to calendars table. + +Revision ID: 030 +Revises: 029 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "030" +down_revision = "029" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("calendars", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE calendars SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_calendars_user_id", "calendars", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("calendars", "user_id", nullable=False) + op.create_index("ix_calendars_user_id", "calendars", ["user_id"]) + op.create_index("ix_calendars_user_default", "calendars", ["user_id", "is_default"]) + + +def downgrade() -> None: + op.drop_index("ix_calendars_user_default", table_name="calendars") + op.drop_index("ix_calendars_user_id", table_name="calendars") + op.drop_constraint("fk_calendars_user_id", "calendars", type_="foreignkey") + op.drop_column("calendars", "user_id") diff --git a/backend/alembic/versions/031_add_user_id_to_people.py b/backend/alembic/versions/031_add_user_id_to_people.py new file mode 100644 index 0000000..60ee2bc --- /dev/null +++ b/backend/alembic/versions/031_add_user_id_to_people.py @@ -0,0 +1,36 @@ +"""Add user_id FK to people table. + +Revision ID: 031 +Revises: 030 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "031" +down_revision = "030" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("people", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE people SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_people_user_id", "people", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("people", "user_id", nullable=False) + op.create_index("ix_people_user_id", "people", ["user_id"]) + op.create_index("ix_people_user_name", "people", ["user_id", "name"]) + + +def downgrade() -> None: + op.drop_index("ix_people_user_name", table_name="people") + op.drop_index("ix_people_user_id", table_name="people") + op.drop_constraint("fk_people_user_id", "people", type_="foreignkey") + op.drop_column("people", "user_id") diff --git a/backend/alembic/versions/032_add_user_id_to_locations.py b/backend/alembic/versions/032_add_user_id_to_locations.py new file mode 100644 index 0000000..996a207 --- /dev/null +++ b/backend/alembic/versions/032_add_user_id_to_locations.py @@ -0,0 +1,34 @@ +"""Add user_id FK to locations table. + +Revision ID: 032 +Revises: 031 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "032" +down_revision = "031" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("locations", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE locations SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_locations_user_id", "locations", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("locations", "user_id", nullable=False) + op.create_index("ix_locations_user_id", "locations", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_locations_user_id", table_name="locations") + op.drop_constraint("fk_locations_user_id", "locations", type_="foreignkey") + op.drop_column("locations", "user_id") diff --git a/backend/alembic/versions/033_add_user_id_to_event_templates.py b/backend/alembic/versions/033_add_user_id_to_event_templates.py new file mode 100644 index 0000000..5ca90a4 --- /dev/null +++ b/backend/alembic/versions/033_add_user_id_to_event_templates.py @@ -0,0 +1,34 @@ +"""Add user_id FK to event_templates table. + +Revision ID: 033 +Revises: 032 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "033" +down_revision = "032" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("event_templates", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE event_templates SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_event_templates_user_id", "event_templates", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("event_templates", "user_id", nullable=False) + op.create_index("ix_event_templates_user_id", "event_templates", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_event_templates_user_id", table_name="event_templates") + op.drop_constraint("fk_event_templates_user_id", "event_templates", type_="foreignkey") + op.drop_column("event_templates", "user_id") diff --git a/backend/alembic/versions/034_add_user_id_to_ntfy_sent.py b/backend/alembic/versions/034_add_user_id_to_ntfy_sent.py new file mode 100644 index 0000000..81b3a3d --- /dev/null +++ b/backend/alembic/versions/034_add_user_id_to_ntfy_sent.py @@ -0,0 +1,46 @@ +"""Add user_id FK to ntfy_sent table, rebuild unique constraint as composite. + +Revision ID: 034 +Revises: 033 +Create Date: 2026-02-26 +""" +from alembic import op +import sqlalchemy as sa + +revision = "034" +down_revision = "033" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("ntfy_sent", sa.Column("user_id", sa.Integer(), nullable=True)) + op.execute( + "UPDATE ntfy_sent SET user_id = (" + " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" + ")" + ) + op.create_foreign_key( + "fk_ntfy_sent_user_id", "ntfy_sent", "users", + ["user_id"], ["id"], ondelete="CASCADE" + ) + op.alter_column("ntfy_sent", "user_id", nullable=False) + + # Drop old unique constraint on notification_key alone + op.drop_constraint("ntfy_sent_notification_key_key", "ntfy_sent", type_="unique") + + # Create composite unique constraint (per-user dedup) + op.create_unique_constraint( + "uq_ntfy_sent_user_key", "ntfy_sent", ["user_id", "notification_key"] + ) + op.create_index("ix_ntfy_sent_user_id", "ntfy_sent", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_ntfy_sent_user_id", table_name="ntfy_sent") + op.drop_constraint("uq_ntfy_sent_user_key", "ntfy_sent", type_="unique") + op.create_unique_constraint( + "ntfy_sent_notification_key_key", "ntfy_sent", ["notification_key"] + ) + op.drop_constraint("fk_ntfy_sent_user_id", "ntfy_sent", type_="foreignkey") + op.drop_column("ntfy_sent", "user_id") diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index cd3099e..bcd0961 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -19,6 +19,7 @@ from app.database import AsyncSessionLocal from app.models.settings import Settings from app.models.reminder import Reminder from app.models.calendar_event import CalendarEvent +from app.models.calendar import Calendar from app.models.todo import Todo from app.models.project import Project from app.models.ntfy_sent import NtfySent @@ -55,10 +56,11 @@ async def _mark_sent(db: AsyncSession, key: str) -> None: async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetime) -> None: """Send notifications for reminders that are currently due and not dismissed/snoozed.""" - # Mirror the filter from /api/reminders/due + # Mirror the filter from /api/reminders/due, scoped to this user result = await db.execute( select(Reminder).where( and_( + Reminder.user_id == settings.user_id, Reminder.remind_at <= now, Reminder.is_dismissed == False, # noqa: E712 Reminder.is_active == True, # noqa: E712 @@ -72,8 +74,8 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim if reminder.snoozed_until and reminder.snoozed_until > now: continue # respect snooze - # Key ties notification to the specific day to handle re-fires after midnight - key = f"reminder:{reminder.id}:{reminder.remind_at.date()}" + # Key includes user_id to prevent cross-user dedup collisions + key = f"reminder:{settings.user_id}:{reminder.id}:{reminder.remind_at.date()}" if await _already_sent(db, key): continue @@ -98,9 +100,13 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) # Window: events starting between now and (now + lead_minutes) window_end = now + timedelta(minutes=lead_minutes) + # Scope events through calendar ownership + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == settings.user_id) + result = await db.execute( select(CalendarEvent).where( and_( + CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.start_datetime >= now, CalendarEvent.start_datetime <= window_end, # Exclude recurring parent templates — they duplicate the child instance rows. @@ -116,8 +122,8 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) today = now.date() for event in events: - # Key includes the minute-precision start to avoid re-firing during the window - key = f"event:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}" + # Key includes user_id to prevent cross-user dedup collisions + key = f"event:{settings.user_id}:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}" if await _already_sent(db, key): continue @@ -141,13 +147,13 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: """Send notifications for incomplete todos due within the configured lead days.""" - from datetime import date as date_type lead_days = settings.ntfy_todo_lead_days cutoff = today + timedelta(days=lead_days) result = await db.execute( select(Todo).where( and_( + Todo.user_id == settings.user_id, Todo.completed == False, # noqa: E712 Todo.due_date != None, # noqa: E711 Todo.due_date <= cutoff, @@ -157,7 +163,8 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: todos = result.scalars().all() for todo in todos: - key = f"todo:{todo.id}:{today}" + # Key includes user_id to prevent cross-user dedup collisions + key = f"todo:{settings.user_id}:{todo.id}:{today}" if await _already_sent(db, key): continue @@ -185,6 +192,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non result = await db.execute( select(Project).where( and_( + Project.user_id == settings.user_id, Project.due_date != None, # noqa: E711 Project.due_date <= cutoff, Project.status != "completed", @@ -194,7 +202,8 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non projects = result.scalars().all() for project in projects: - key = f"project:{project.id}:{today}" + # Key includes user_id to prevent cross-user dedup collisions + key = f"project:{settings.user_id}:{project.id}:{today}" if await _already_sent(db, key): continue @@ -213,6 +222,18 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non await _mark_sent(db, key) +async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime) -> None: + """Run all notification dispatches for a single user's settings.""" + if settings.ntfy_reminders_enabled: + await _dispatch_reminders(db, settings, now) + if settings.ntfy_events_enabled: + await _dispatch_events(db, settings, now) + if settings.ntfy_todos_enabled: + await _dispatch_todos(db, settings, now.date()) + if settings.ntfy_projects_enabled: + await _dispatch_projects(db, settings, now.date()) + + async def _purge_old_sent_records(db: AsyncSession) -> None: """Remove ntfy_sent entries older than 7 days to keep the table lean.""" # See DATETIME NOTE at top of file re: naive datetime usage @@ -240,29 +261,35 @@ async def run_notification_dispatch() -> None: """ Main dispatch function called by APScheduler every 60 seconds. Uses AsyncSessionLocal directly — not the get_db() request-scoped dependency. + + Iterates over ALL users with ntfy enabled. Per-user errors are caught and + logged individually so one user's failure does not prevent others from + receiving notifications. """ try: async with AsyncSessionLocal() as db: - result = await db.execute(select(Settings)) - settings = result.scalar_one_or_none() + # Fetch all Settings rows that have ntfy enabled + result = await db.execute( + select(Settings).where(Settings.ntfy_enabled == True) # noqa: E712 + ) + all_settings = result.scalars().all() - if not settings or not settings.ntfy_enabled: + if not all_settings: return # See DATETIME NOTE at top of file re: naive datetime usage now = datetime.now() - today = now.date() - if settings.ntfy_reminders_enabled: - await _dispatch_reminders(db, settings, now) - if settings.ntfy_events_enabled: - await _dispatch_events(db, settings, now) - if settings.ntfy_todos_enabled: - await _dispatch_todos(db, settings, today) - if settings.ntfy_projects_enabled: - await _dispatch_projects(db, settings, today) + for user_settings in all_settings: + try: + await _dispatch_for_user(db, user_settings, now) + except Exception: + # Isolate per-user failures — log and continue to next user + logger.exception( + "ntfy dispatch failed for user_id=%s", user_settings.user_id + ) - # Daily housekeeping: purge stale dedup records + # Daily housekeeping: purge stale dedup records (shared across all users) await _purge_old_sent_records(db) # Security housekeeping runs every cycle regardless of ntfy_enabled diff --git a/backend/app/main.py b/backend/app/main.py index c3a7e9a..624a61f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.config import settings from app.database import engine from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates -from app.routers import totp +from app.routers import totp, admin from app.jobs.notifications import run_notification_dispatch # Import models so Alembic's autogenerate can discover them @@ -15,6 +15,8 @@ from app.models import user as _user_model # noqa: F401 from app.models import session as _session_model # noqa: F401 from app.models import totp_usage as _totp_usage_model # noqa: F401 from app.models import backup_code as _backup_code_model # noqa: F401 +from app.models import system_config as _system_config_model # noqa: F401 +from app.models import audit_log as _audit_log_model # noqa: F401 @asynccontextmanager @@ -68,6 +70,7 @@ app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"]) app.include_router(weather.router, prefix="/api/weather", tags=["Weather"]) app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"]) app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"]) +app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) @app.get("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index cca74b0..d9d7a34 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -13,6 +13,8 @@ from app.models.session import UserSession from app.models.ntfy_sent import NtfySent from app.models.totp_usage import TOTPUsage from app.models.backup_code import BackupCode +from app.models.system_config import SystemConfig +from app.models.audit_log import AuditLog __all__ = [ "Settings", @@ -30,4 +32,6 @@ __all__ = [ "NtfySent", "TOTPUsage", "BackupCode", + "SystemConfig", + "AuditLog", ] diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py new file mode 100644 index 0000000..a16f8a6 --- /dev/null +++ b/backend/app/models/audit_log.py @@ -0,0 +1,27 @@ +from sqlalchemy import String, Text, Integer, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from typing import Optional +from app.database import Base + + +class AuditLog(Base): + """ + Append-only audit trail for admin actions and auth events. + No DELETE endpoint — this table is immutable once written. + """ + __tablename__ = "audit_log" + + id: Mapped[int] = mapped_column(primary_key=True) + actor_user_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id"), nullable=True, index=True + ) + target_user_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + action: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) + created_at: Mapped[datetime] = mapped_column( + default=func.now(), server_default=func.now(), index=True + ) diff --git a/backend/app/models/calendar.py b/backend/app/models/calendar.py index 60de5f7..43ab782 100644 --- a/backend/app/models/calendar.py +++ b/backend/app/models/calendar.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Boolean, func +from sqlalchemy import String, Boolean, Integer, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime from typing import List @@ -9,6 +9,9 @@ class Calendar(Base): __tablename__ = "calendars" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(100), nullable=False) color: Mapped[str] = mapped_column(String(20), nullable=False, default="#3b82f6") is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") diff --git a/backend/app/models/event_template.py b/backend/app/models/event_template.py index 47004de..78f8a2d 100644 --- a/backend/app/models/event_template.py +++ b/backend/app/models/event_template.py @@ -9,6 +9,9 @@ class EventTemplate(Base): __tablename__ = "event_templates" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(255), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) diff --git a/backend/app/models/location.py b/backend/app/models/location.py index 7dbea4a..c78adf8 100644 --- a/backend/app/models/location.py +++ b/backend/app/models/location.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Text, Boolean, func, text +from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, func, text from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime from typing import Optional, List @@ -9,6 +9,9 @@ class Location(Base): __tablename__ = "locations" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(255), nullable=False) address: Mapped[str] = mapped_column(Text, nullable=False) category: Mapped[str] = mapped_column(String(100), default="other") diff --git a/backend/app/models/ntfy_sent.py b/backend/app/models/ntfy_sent.py index ad3868a..a819741 100644 --- a/backend/app/models/ntfy_sent.py +++ b/backend/app/models/ntfy_sent.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, func +from sqlalchemy import String, Integer, ForeignKey, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column from datetime import datetime from app.database import Base @@ -8,7 +8,7 @@ class NtfySent(Base): """ Deduplication table for ntfy notifications. Prevents the background job from re-sending the same notification - within a given time window. + within a given time window. Scoped per-user. Key format: "{type}:{entity_id}:{date_window}" Examples: @@ -18,7 +18,13 @@ class NtfySent(Base): "project:3:2026-02-25" """ __tablename__ = "ntfy_sent" + __table_args__ = ( + UniqueConstraint("user_id", "notification_key", name="uq_ntfy_sent_user_key"), + ) id: Mapped[int] = mapped_column(primary_key=True) - notification_key: Mapped[str] = mapped_column(String(255), unique=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + notification_key: Mapped[str] = mapped_column(String(255), index=True) sent_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) diff --git a/backend/app/models/person.py b/backend/app/models/person.py index fbf7984..66c3acd 100644 --- a/backend/app/models/person.py +++ b/backend/app/models/person.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Text, Date, Boolean, func, text +from sqlalchemy import String, Text, Date, Boolean, Integer, ForeignKey, func, text from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime, date from typing import Optional, List @@ -9,6 +9,9 @@ class Person(Base): __tablename__ = "people" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(255), nullable=False) email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 57b0969..faefbe5 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -1,5 +1,5 @@ import sqlalchemy as sa -from sqlalchemy import Boolean, String, Text, Date, func +from sqlalchemy import Boolean, String, Text, Date, Integer, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime, date from typing import Optional, List @@ -10,6 +10,9 @@ class Project(Base): __tablename__ = "projects" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) status: Mapped[str] = mapped_column(String(20), default="not_started") diff --git a/backend/app/models/reminder.py b/backend/app/models/reminder.py index 8259d21..261a89b 100644 --- a/backend/app/models/reminder.py +++ b/backend/app/models/reminder.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Text, Boolean, func +from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column from datetime import datetime from typing import Optional @@ -9,6 +9,9 @@ class Reminder(Base): __tablename__ = "reminders" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) remind_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) diff --git a/backend/app/models/system_config.py b/backend/app/models/system_config.py new file mode 100644 index 0000000..3e801b2 --- /dev/null +++ b/backend/app/models/system_config.py @@ -0,0 +1,27 @@ +from sqlalchemy import Boolean, CheckConstraint, func +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from app.database import Base + + +class SystemConfig(Base): + """ + Singleton system configuration table (always id=1). + Stores global toggles for registration, MFA enforcement, etc. + """ + __tablename__ = "system_config" + __table_args__ = ( + CheckConstraint("id = 1", name="ck_system_config_singleton"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + allow_registration: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + enforce_mfa_new_users: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + default=func.now(), onupdate=func.now(), server_default=func.now() + ) diff --git a/backend/app/models/todo.py b/backend/app/models/todo.py index cf38b3f..23850ed 100644 --- a/backend/app/models/todo.py +++ b/backend/app/models/todo.py @@ -9,6 +9,9 @@ class Todo(Base): __tablename__ = "todos" id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) priority: Mapped[str] = mapped_column(String(20), default="medium") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 8de4311..efe582f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -23,7 +23,23 @@ class User(Base): # Account state is_active: Mapped[bool] = mapped_column(Boolean, default=True) + # RBAC + role: Mapped[str] = mapped_column( + String(30), nullable=False, default="standard", server_default="standard" + ) + + # MFA enforcement (admin can toggle; checked at login) + mfa_enforce_pending: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + + # Forced password change (set after admin reset) + must_change_password: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + # Audit created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) last_login_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None) + last_password_change_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..7c80b1a --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,687 @@ +""" +Admin router — full user management, system config, and audit log. + +Security measures implemented: + SEC-02: Session revocation on role change + SEC-05: Block admin self-actions (own role/password/MFA/active status) + SEC-08: X-Requested-With header check (verify_xhr) on all state-mutating requests + SEC-13: Session revocation + ntfy alert on MFA disable + +All routes require the `require_admin` dependency (which chains through +get_current_user, so the session cookie is always validated). +""" +import secrets +from datetime import datetime +from typing import Optional + +import sqlalchemy as sa +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.audit_log import AuditLog +from app.models.backup_code import BackupCode +from app.models.session import UserSession +from app.models.system_config import SystemConfig +from app.models.user import User +from app.routers.auth import ( + _create_user_defaults, + get_current_user, + require_admin, +) +from app.schemas.admin import ( + AdminDashboardResponse, + AuditLogEntry, + AuditLogResponse, + CreateUserRequest, + ResetPasswordResponse, + SystemConfigResponse, + SystemConfigUpdate, + ToggleActiveRequest, + ToggleMfaEnforceRequest, + UpdateUserRoleRequest, + UserDetailResponse, + UserListItem, + UserListResponse, +) +from app.services.audit import log_audit_event +from app.services.auth import hash_password + +# --------------------------------------------------------------------------- +# CSRF guard — SEC-08 +# --------------------------------------------------------------------------- + +async def verify_xhr(request: Request) -> None: + """ + Lightweight CSRF mitigation: require X-Requested-With on state-mutating + requests. Browsers never send this header cross-origin without CORS + pre-flight, which our CORS policy blocks. + """ + if request.method not in ("GET", "HEAD", "OPTIONS"): + if request.headers.get("X-Requested-With") != "XMLHttpRequest": + raise HTTPException(status_code=403, detail="Invalid request origin") + + +# --------------------------------------------------------------------------- +# Router — all endpoints inherit require_admin + verify_xhr +# --------------------------------------------------------------------------- + +router = APIRouter( + dependencies=[Depends(require_admin), Depends(verify_xhr)], +) + + +# --------------------------------------------------------------------------- +# Session revocation helper (used in multiple endpoints) +# --------------------------------------------------------------------------- + +async def _revoke_all_sessions(db: AsyncSession, user_id: int) -> int: + """Mark every active session for user_id as revoked. Returns count revoked.""" + result = await db.execute( + sa.update(UserSession) + .where(UserSession.user_id == user_id, UserSession.revoked == False) + .values(revoked=True) + .returning(UserSession.id) + ) + return len(result.fetchall()) + + +# --------------------------------------------------------------------------- +# Self-action guard — SEC-05 +# --------------------------------------------------------------------------- + +def _guard_self_action(actor: User, target_id: int, action: str) -> None: + """Raise 403 if an admin attempts a privileged action against their own account.""" + if actor.id == target_id: + raise HTTPException( + status_code=403, + detail=f"Admins cannot {action} their own account", + ) + + +# --------------------------------------------------------------------------- +# GET /users +# --------------------------------------------------------------------------- + +@router.get("/users", response_model=UserListResponse) +async def list_users( + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """Return all users with basic stats.""" + result = await db.execute(sa.select(User).order_by(User.created_at)) + users = result.scalars().all() + return UserListResponse( + users=[UserListItem.model_validate(u) for u in users], + total=len(users), + ) + + +# --------------------------------------------------------------------------- +# GET /users/{user_id} +# --------------------------------------------------------------------------- + +@router.get("/users/{user_id}", response_model=UserDetailResponse) +async def get_user( + user_id: int, + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """Return a single user with their active session count.""" + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + session_result = await db.execute( + sa.select(sa.func.count()).select_from(UserSession).where( + UserSession.user_id == user_id, + UserSession.revoked == False, + UserSession.expires_at > datetime.now(), + ) + ) + active_sessions = session_result.scalar_one() + + return UserDetailResponse( + **UserListItem.model_validate(user).model_dump(), + active_sessions=active_sessions, + ) + + +# --------------------------------------------------------------------------- +# POST /users +# --------------------------------------------------------------------------- + +@router.post("/users", response_model=UserDetailResponse, status_code=201) +async def create_user( + data: CreateUserRequest, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """Admin-create a user with Settings and default calendars.""" + existing = await db.execute(sa.select(User).where(User.username == data.username)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Username already taken") + + new_user = User( + username=data.username, + password_hash=hash_password(data.password), + role=data.role, + last_password_change_at=datetime.now(), + # Force password change so the user sets their own credential + must_change_password=True, + ) + db.add(new_user) + await db.flush() # populate new_user.id + + await _create_user_defaults(db, new_user.id) + await db.commit() + + await log_audit_event( + db, + action="admin.user_created", + actor_id=actor.id, + target_id=new_user.id, + detail={"username": new_user.username, "role": new_user.role}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return UserDetailResponse( + **UserListItem.model_validate(new_user).model_dump(), + active_sessions=0, + ) + + +# --------------------------------------------------------------------------- +# PUT /users/{user_id}/role — SEC-02, SEC-05 +# --------------------------------------------------------------------------- + +@router.put("/users/{user_id}/role") +async def update_user_role( + user_id: int, + data: UpdateUserRoleRequest, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """ + Change a user's role. + Blocks demotion of the last admin (SEC-05 variant). + Revokes all sessions after role change (SEC-02). + """ + _guard_self_action(actor, user_id, "change role of") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Prevent demoting the last admin + if user.role == "admin" and data.role != "admin": + admin_count = await db.scalar( + sa.select(sa.func.count()).select_from(User).where(User.role == "admin") + ) + if admin_count <= 1: + raise HTTPException( + status_code=409, + detail="Cannot demote the last admin account", + ) + + old_role = user.role + user.role = data.role + + # SEC-02: revoke sessions so the new role takes effect immediately + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.role_changed", + actor_id=actor.id, + target_id=user_id, + detail={"old_role": old_role, "new_role": data.role, "sessions_revoked": revoked}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return {"message": f"Role updated to '{data.role}'. {revoked} session(s) revoked."} + + +# --------------------------------------------------------------------------- +# POST /users/{user_id}/reset-password — SEC-05 +# --------------------------------------------------------------------------- + +@router.post("/users/{user_id}/reset-password", response_model=ResetPasswordResponse) +async def reset_user_password( + user_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """ + Generate a temporary password, revoke all sessions, and mark must_change_password. + The admin is shown the plaintext temp password once — it is not stored. + """ + _guard_self_action(actor, user_id, "reset the password of") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + temp_password = secrets.token_urlsafe(16) + user.password_hash = hash_password(temp_password) + user.must_change_password = True + user.last_password_change_at = datetime.now() + + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.password_reset", + actor_id=actor.id, + target_id=user_id, + detail={"sessions_revoked": revoked}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return ResetPasswordResponse( + message=f"Password reset. {revoked} session(s) revoked. User must change password on next login.", + temporary_password=temp_password, + ) + + +# --------------------------------------------------------------------------- +# POST /users/{user_id}/disable-mfa — SEC-05, SEC-13 +# --------------------------------------------------------------------------- + +@router.post("/users/{user_id}/disable-mfa") +async def disable_user_mfa( + user_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """ + Clear TOTP secret + backup codes and revoke all sessions (SEC-13). + """ + _guard_self_action(actor, user_id, "disable MFA for") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if not user.totp_enabled: + raise HTTPException(status_code=409, detail="MFA is not enabled for this user") + + # Clear TOTP data + user.totp_secret = None + user.totp_enabled = False + user.mfa_enforce_pending = False + + # Remove all backup codes + await db.execute( + sa.delete(BackupCode).where(BackupCode.user_id == user_id) + ) + + # SEC-13: revoke sessions so the MFA downgrade takes effect immediately + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.mfa_disabled", + actor_id=actor.id, + target_id=user_id, + detail={"sessions_revoked": revoked}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return {"message": f"MFA disabled. {revoked} session(s) revoked."} + + +# --------------------------------------------------------------------------- +# PUT /users/{user_id}/enforce-mfa — SEC-05 +# --------------------------------------------------------------------------- + +@router.put("/users/{user_id}/enforce-mfa") +async def toggle_mfa_enforce( + user_id: int, + data: ToggleMfaEnforceRequest, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """Toggle the mfa_enforce_pending flag. Next login will prompt MFA setup.""" + _guard_self_action(actor, user_id, "toggle MFA enforcement for") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.mfa_enforce_pending = data.enforce + + await log_audit_event( + db, + action="admin.mfa_enforce_toggled", + actor_id=actor.id, + target_id=user_id, + detail={"enforce": data.enforce}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return {"message": f"MFA enforcement {'enabled' if data.enforce else 'disabled'} for user."} + + +# --------------------------------------------------------------------------- +# PUT /users/{user_id}/active — SEC-05 +# --------------------------------------------------------------------------- + +@router.put("/users/{user_id}/active") +async def toggle_user_active( + user_id: int, + data: ToggleActiveRequest, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """ + Enable or disable a user account. + Revoking an account also revokes all active sessions immediately. + """ + _guard_self_action(actor, user_id, "change active status of") + + result = await db.execute(sa.select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.is_active = data.is_active + revoked = 0 + + if not data.is_active: + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.user_deactivated" if not data.is_active else "admin.user_activated", + actor_id=actor.id, + target_id=user_id, + detail={"sessions_revoked": revoked}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + state = "activated" if data.is_active else f"deactivated ({revoked} session(s) revoked)" + return {"message": f"User {state}."} + + +# --------------------------------------------------------------------------- +# DELETE /users/{user_id}/sessions +# --------------------------------------------------------------------------- + +@router.delete("/users/{user_id}/sessions") +async def revoke_user_sessions( + user_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """Forcibly revoke all active sessions for a user.""" + result = await db.execute(sa.select(User).where(User.id == user_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="User not found") + + revoked = await _revoke_all_sessions(db, user_id) + + await log_audit_event( + db, + action="admin.sessions_revoked", + actor_id=actor.id, + target_id=user_id, + detail={"sessions_revoked": revoked}, + ip=request.client.host if request.client else None, + ) + await db.commit() + + return {"message": f"{revoked} session(s) revoked."} + + +# --------------------------------------------------------------------------- +# GET /users/{user_id}/sessions +# --------------------------------------------------------------------------- + +@router.get("/users/{user_id}/sessions") +async def list_user_sessions( + user_id: int, + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """List all active (non-revoked, non-expired) sessions for a user.""" + result = await db.execute(sa.select(User).where(User.id == user_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="User not found") + + sessions_result = await db.execute( + sa.select(UserSession).where( + UserSession.user_id == user_id, + UserSession.revoked == False, + UserSession.expires_at > datetime.now(), + ).order_by(UserSession.created_at.desc()) + ) + sessions = sessions_result.scalars().all() + + return { + "sessions": [ + { + "id": s.id, + "created_at": s.created_at, + "expires_at": s.expires_at, + "ip_address": s.ip_address, + "user_agent": s.user_agent, + } + for s in sessions + ], + "total": len(sessions), + } + + +# --------------------------------------------------------------------------- +# GET /config +# --------------------------------------------------------------------------- + +@router.get("/config", response_model=SystemConfigResponse) +async def get_system_config( + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """Fetch the singleton system configuration row.""" + result = await db.execute(sa.select(SystemConfig).where(SystemConfig.id == 1)) + config = result.scalar_one_or_none() + if not config: + # Bootstrap the singleton if it doesn't exist yet + config = SystemConfig(id=1) + db.add(config) + await db.commit() + return config + + +# --------------------------------------------------------------------------- +# PUT /config +# --------------------------------------------------------------------------- + +@router.put("/config", response_model=SystemConfigResponse) +async def update_system_config( + data: SystemConfigUpdate, + request: Request, + db: AsyncSession = Depends(get_db), + actor: User = Depends(get_current_user), +): + """Update one or more system config fields (partial update).""" + result = await db.execute(sa.select(SystemConfig).where(SystemConfig.id == 1)) + config = result.scalar_one_or_none() + if not config: + config = SystemConfig(id=1) + db.add(config) + await db.flush() + + changes: dict = {} + if data.allow_registration is not None: + changes["allow_registration"] = data.allow_registration + config.allow_registration = data.allow_registration + if data.enforce_mfa_new_users is not None: + changes["enforce_mfa_new_users"] = data.enforce_mfa_new_users + config.enforce_mfa_new_users = data.enforce_mfa_new_users + + if changes: + await log_audit_event( + db, + action="admin.config_updated", + actor_id=actor.id, + detail=changes, + ip=request.client.host if request.client else None, + ) + + await db.commit() + return config + + +# --------------------------------------------------------------------------- +# GET /dashboard +# --------------------------------------------------------------------------- + +@router.get("/dashboard", response_model=AdminDashboardResponse) +async def admin_dashboard( + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), +): + """Aggregate stats for the admin portal dashboard.""" + total_users = await db.scalar( + sa.select(sa.func.count()).select_from(User) + ) + active_users = await db.scalar( + sa.select(sa.func.count()).select_from(User).where(User.is_active == True) + ) + admin_count = await db.scalar( + sa.select(sa.func.count()).select_from(User).where(User.role == "admin") + ) + totp_count = await db.scalar( + sa.select(sa.func.count()).select_from(User).where(User.totp_enabled == True) + ) + active_sessions = await db.scalar( + sa.select(sa.func.count()).select_from(UserSession).where( + UserSession.revoked == False, + UserSession.expires_at > datetime.now(), + ) + ) + + mfa_adoption = (totp_count / total_users) if total_users else 0.0 + + # 10 most recent logins — join to get username + actor_alias = sa.alias(User.__table__, name="actor") + recent_logins_result = await db.execute( + sa.select(User.username, User.last_login_at) + .where(User.last_login_at != None) + .order_by(User.last_login_at.desc()) + .limit(10) + ) + recent_logins = [ + {"username": row.username, "last_login_at": row.last_login_at} + for row in recent_logins_result + ] + + # 10 most recent audit entries + recent_audit_result = await db.execute( + sa.select(AuditLog).order_by(AuditLog.created_at.desc()).limit(10) + ) + recent_audit_entries = [ + { + "id": e.id, + "action": e.action, + "actor_user_id": e.actor_user_id, + "target_user_id": e.target_user_id, + "detail": e.detail, + "created_at": e.created_at, + } + for e in recent_audit_result.scalars() + ] + + return AdminDashboardResponse( + total_users=total_users or 0, + active_users=active_users or 0, + admin_count=admin_count or 0, + active_sessions=active_sessions or 0, + mfa_adoption_rate=round(mfa_adoption, 4), + recent_logins=recent_logins, + recent_audit_entries=recent_audit_entries, + ) + + +# --------------------------------------------------------------------------- +# GET /audit-log +# --------------------------------------------------------------------------- + +@router.get("/audit-log", response_model=AuditLogResponse) +async def get_audit_log( + db: AsyncSession = Depends(get_db), + _actor: User = Depends(get_current_user), + action: Optional[str] = Query(None, description="Filter by action string (prefix match)"), + target_user_id: Optional[int] = Query(None, description="Filter by target user ID"), + page: int = Query(1, ge=1, description="Page number (1-indexed)"), + per_page: int = Query(50, ge=1, le=200, description="Results per page"), +): + """ + Paginated audit log with optional filters. + Resolves actor and target user IDs to usernames via a JOIN. + """ + # Aliases for the two user joins + actor_user = sa.orm.aliased(User, name="actor_user") + target_user = sa.orm.aliased(User, name="target_user") + + # Base query — left outer join so entries with NULL actor/target still appear + base_q = ( + sa.select( + AuditLog, + actor_user.username.label("actor_username"), + target_user.username.label("target_username"), + ) + .outerjoin(actor_user, AuditLog.actor_user_id == actor_user.id) + .outerjoin(target_user, AuditLog.target_user_id == target_user.id) + ) + + if action: + base_q = base_q.where(AuditLog.action.like(f"{action}%")) + if target_user_id is not None: + base_q = base_q.where(AuditLog.target_user_id == target_user_id) + + # Count before pagination + count_q = sa.select(sa.func.count()).select_from( + base_q.subquery() + ) + total = await db.scalar(count_q) or 0 + + # Paginate + offset = (page - 1) * per_page + rows_result = await db.execute( + base_q.order_by(AuditLog.created_at.desc()).offset(offset).limit(per_page) + ) + + entries = [ + AuditLogEntry( + id=row.AuditLog.id, + actor_username=row.actor_username, + target_username=row.target_username, + action=row.AuditLog.action, + detail=row.AuditLog.detail, + ip_address=row.AuditLog.ip_address, + created_at=row.AuditLog.created_at, + ) + for row in rows_result + ] + + return AuditLogResponse(entries=entries, total=total) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index e0fb04b..5dbeac1 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,18 +1,20 @@ """ -Authentication router — username/password with DB-backed sessions and account lockout. +Authentication router — username/password with DB-backed sessions, account lockout, +role-based access control, and multi-user registration. Session flow: - POST /setup → create User + Settings row → issue session cookie - POST /login → verify credentials → check lockout → insert UserSession → issue cookie - → if TOTP enabled: return mfa_token instead of full session + POST /setup → create admin User + Settings + calendars → issue session cookie + POST /login → verify credentials → check lockout → MFA/enforce checks → issue session + POST /register → create standard user (when registration enabled) POST /logout → mark session revoked in DB → delete cookie - GET /status → verify user exists + session valid + GET /status → verify user exists + session valid + role + registration_open Security layers: - 1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) — outer guard on all auth endpoints - 2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423) — per-user guard + 1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) — outer guard on auth endpoints + 2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423) 3. Session revocation stored in DB (survives container restarts) - 4. bcrypt→Argon2id transparent upgrade on first login with migrated hash + 4. bcrypt→Argon2id transparent upgrade on first login + 5. Role-based authorization via require_role() dependency factory """ import uuid from datetime import datetime, timedelta @@ -26,14 +28,21 @@ from app.database import get_db from app.models.user import User from app.models.session import UserSession from app.models.settings import Settings -from app.schemas.auth import SetupRequest, LoginRequest, ChangePasswordRequest, VerifyPasswordRequest +from app.models.system_config import SystemConfig +from app.models.calendar import Calendar +from app.schemas.auth import ( + SetupRequest, LoginRequest, RegisterRequest, + ChangePasswordRequest, VerifyPasswordRequest, +) from app.services.auth import ( hash_password, verify_password_with_upgrade, create_session_token, verify_session_token, create_mfa_token, + create_mfa_enforce_token, ) +from app.services.audit import log_audit_event from app.config import settings as app_settings router = APIRouter() @@ -64,8 +73,6 @@ async def get_current_user( ) -> User: """ Dependency that verifies the session cookie and returns the authenticated User. - Replaces the old get_current_session (which returned Settings). - Any router that hasn't been updated will get a compile-time type error. """ if not session_cookie: raise HTTPException(status_code=401, detail="Not authenticated") @@ -119,6 +126,24 @@ async def get_current_settings( return settings_obj +# --------------------------------------------------------------------------- +# Role-based authorization dependencies +# --------------------------------------------------------------------------- + +def require_role(*allowed_roles: str): + """Factory: returns a dependency that enforces role membership.""" + async def _check( + current_user: User = Depends(get_current_user), + ) -> User: + if current_user.role not in allowed_roles: + raise HTTPException(status_code=403, detail="Insufficient permissions") + return current_user + return _check + +# Convenience aliases +require_admin = require_role("admin") + + # --------------------------------------------------------------------------- # Account lockout helpers # --------------------------------------------------------------------------- @@ -166,7 +191,7 @@ async def _create_db_session( id=session_id, user_id=user.id, expires_at=expires_at, - ip_address=ip[:45] if ip else None, # clamp to column width + ip_address=ip[:45] if ip else None, user_agent=(user_agent or "")[:255] if user_agent else None, ) db.add(db_session) @@ -175,6 +200,25 @@ async def _create_db_session( return session_id, token +# --------------------------------------------------------------------------- +# User bootstrapping helper (Settings + default calendars) +# --------------------------------------------------------------------------- + +async def _create_user_defaults(db: AsyncSession, user_id: int) -> None: + """Create Settings row and default calendars for a new user.""" + db.add(Settings(user_id=user_id)) + db.add(Calendar( + name="Personal", color="#3b82f6", + is_default=True, is_system=False, is_visible=True, + user_id=user_id, + )) + db.add(Calendar( + name="Birthdays", color="#f59e0b", + is_default=False, is_system=True, is_visible=True, + user_id=user_id, + )) + + # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @@ -187,7 +231,7 @@ async def setup( db: AsyncSession = Depends(get_db), ): """ - First-time setup: create the User record and a linked Settings row. + First-time setup: create the admin User + Settings + default calendars. Only works when no users exist (i.e., fresh install). """ existing = await db.execute(select(User)) @@ -195,13 +239,16 @@ async def setup( raise HTTPException(status_code=400, detail="Setup already completed") password_hash = hash_password(data.password) - new_user = User(username=data.username, password_hash=password_hash) + new_user = User( + username=data.username, + password_hash=password_hash, + role="admin", + last_password_change_at=datetime.now(), + ) db.add(new_user) - await db.flush() # assign new_user.id before creating Settings + await db.flush() - # Create Settings row linked to this user with all defaults - new_settings = Settings(user_id=new_user.id) - db.add(new_settings) + await _create_user_defaults(db, new_user.id) await db.commit() ip = request.client.host if request.client else "unknown" @@ -209,6 +256,11 @@ async def setup( _, token = await _create_db_session(db, new_user, ip, user_agent) _set_session_cookie(response, token) + await log_audit_event( + db, action="auth.setup_complete", actor_id=new_user.id, ip=ip, + ) + await db.commit() + return {"message": "Setup completed successfully", "authenticated": True} @@ -223,15 +275,15 @@ async def login( Authenticate with username + password. Returns: - { authenticated: true } — on success (no TOTP) + { authenticated: true } — on success (no TOTP, no enforcement) { authenticated: false, totp_required: true, mfa_token: "..." } — TOTP pending - HTTP 401 — wrong credentials (generic; never reveals which field is wrong) + { authenticated: false, mfa_setup_required: true, mfa_token: "..." } — MFA enforcement + { authenticated: false, must_change_password: true } — forced password change after admin reset + HTTP 401 — wrong credentials HTTP 423 — account locked - HTTP 429 — IP rate limited """ client_ip = request.client.host if request.client else "unknown" - # Lookup user — do NOT differentiate "user not found" from "wrong password" result = await db.execute(select(User).where(User.username == data.username)) user = result.scalar_one_or_none() @@ -240,20 +292,36 @@ async def login( await _check_account_lockout(user) - # Transparent bcrypt→Argon2id upgrade valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash) if not valid: await _record_failed_login(db, user) + await log_audit_event( + db, action="auth.login_failed", actor_id=user.id, + detail={"reason": "invalid_password"}, ip=client_ip, + ) + await db.commit() raise HTTPException(status_code=401, detail="Invalid username or password") - # Persist upgraded hash if migration happened if new_hash: user.password_hash = new_hash await _record_successful_login(db, user) - # If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session + # SEC-03: MFA enforcement — block login entirely until MFA setup completes + if user.mfa_enforce_pending and not user.totp_enabled: + enforce_token = create_mfa_enforce_token(user.id) + await log_audit_event( + db, action="auth.mfa_enforce_prompted", actor_id=user.id, ip=client_ip, + ) + await db.commit() + return { + "authenticated": False, + "mfa_setup_required": True, + "mfa_token": enforce_token, + } + + # If TOTP is enabled, issue a short-lived MFA challenge token if user.totp_enabled: mfa_token = create_mfa_token(user.id) return { @@ -262,13 +330,97 @@ async def login( "mfa_token": mfa_token, } + # SEC-12: Forced password change after admin reset + if user.must_change_password: + # Issue a session but flag the frontend to show password change + user_agent = request.headers.get("user-agent") + _, token = await _create_db_session(db, user, client_ip, user_agent) + _set_session_cookie(response, token) + return { + "authenticated": True, + "must_change_password": True, + } + user_agent = request.headers.get("user-agent") _, token = await _create_db_session(db, user, client_ip, user_agent) _set_session_cookie(response, token) + await log_audit_event( + db, action="auth.login_success", actor_id=user.id, ip=client_ip, + ) + await db.commit() + return {"authenticated": True} +@router.post("/register") +async def register( + data: RegisterRequest, + response: Response, + request: Request, + db: AsyncSession = Depends(get_db), +): + """ + Create a new standard user account. + Only available when system_config.allow_registration is True. + """ + config_result = await db.execute( + select(SystemConfig).where(SystemConfig.id == 1) + ) + config = config_result.scalar_one_or_none() + if not config or not config.allow_registration: + raise HTTPException(status_code=403, detail="Registration is not available") + + # Check username availability (generic error to prevent enumeration) + existing = await db.execute( + select(User).where(User.username == data.username) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Registration failed") + + password_hash = hash_password(data.password) + # SEC-01: Explicit field assignment — never **data.model_dump() + new_user = User( + username=data.username, + password_hash=password_hash, + role="standard", + last_password_change_at=datetime.now(), + ) + + # Check if MFA enforcement is enabled for new users + if config.enforce_mfa_new_users: + new_user.mfa_enforce_pending = True + + db.add(new_user) + await db.flush() + + await _create_user_defaults(db, new_user.id) + await db.commit() + + ip = request.client.host if request.client else "unknown" + user_agent = request.headers.get("user-agent") + + await log_audit_event( + db, action="auth.registration", actor_id=new_user.id, ip=ip, + ) + await db.commit() + + # If MFA enforcement is pending, don't issue a session — require MFA setup first + if new_user.mfa_enforce_pending: + enforce_token = create_mfa_enforce_token(new_user.id) + return { + "message": "Registration successful", + "authenticated": False, + "mfa_setup_required": True, + "mfa_token": enforce_token, + } + + _, token = await _create_db_session(db, new_user, ip, user_agent) + _set_session_cookie(response, token) + + return {"message": "Registration successful", "authenticated": True} + + @router.post("/logout") async def logout( response: Response, @@ -304,13 +456,13 @@ async def auth_status( db: AsyncSession = Depends(get_db), ): """ - Check authentication status and whether initial setup has been performed. - Used by the frontend to decide whether to show login vs setup screen. + Check authentication status, role, and whether initial setup/registration is available. """ user_result = await db.execute(select(User)) existing_user = user_result.scalar_one_or_none() setup_required = existing_user is None authenticated = False + role = None if not setup_required and session_cookie: payload = verify_session_token(session_cookie) @@ -326,9 +478,32 @@ async def auth_status( UserSession.expires_at > datetime.now(), ) ) - authenticated = session_result.scalar_one_or_none() is not None + if session_result.scalar_one_or_none() is not None: + authenticated = True + user_obj_result = await db.execute( + select(User).where(User.id == user_id, User.is_active == True) + ) + u = user_obj_result.scalar_one_or_none() + if u: + role = u.role + else: + authenticated = False - return {"authenticated": authenticated, "setup_required": setup_required} + # Check registration availability + registration_open = False + if not setup_required: + config_result = await db.execute( + select(SystemConfig).where(SystemConfig.id == 1) + ) + config = config_result.scalar_one_or_none() + registration_open = config.allow_registration if config else False + + return { + "authenticated": authenticated, + "setup_required": setup_required, + "role": role, + "registration_open": registration_open, + } @router.post("/verify-password") @@ -340,8 +515,6 @@ async def verify_password( """ Verify the current user's password without changing anything. Used by the frontend lock screen to re-authenticate without a full login. - Also handles transparent bcrypt→Argon2id upgrade. - Shares the same lockout guards as /login. Nginx limit_req_zone handles IP rate limiting. """ await _check_account_lockout(current_user) @@ -350,7 +523,6 @@ async def verify_password( await _record_failed_login(db, current_user) raise HTTPException(status_code=401, detail="Invalid password") - # Persist upgraded hash if migration happened if new_hash: current_user.password_hash = new_hash await db.commit() @@ -373,6 +545,12 @@ async def change_password( raise HTTPException(status_code=401, detail="Invalid current password") current_user.password_hash = hash_password(data.new_password) + current_user.last_password_change_at = datetime.now() + + # Clear forced password change flag if set (SEC-12) + if current_user.must_change_password: + current_user.must_change_password = False + await db.commit() return {"message": "Password changed successfully"} diff --git a/backend/app/routers/calendars.py b/backend/app/routers/calendars.py index 70322ed..4f35984 100644 --- a/backend/app/routers/calendars.py +++ b/backend/app/routers/calendars.py @@ -18,7 +18,11 @@ async def get_calendars( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - result = await db.execute(select(Calendar).order_by(Calendar.is_default.desc(), Calendar.name.asc())) + result = await db.execute( + select(Calendar) + .where(Calendar.user_id == current_user.id) + .order_by(Calendar.is_default.desc(), Calendar.name.asc()) + ) return result.scalars().all() @@ -34,6 +38,7 @@ async def create_calendar( is_default=False, is_system=False, is_visible=True, + user_id=current_user.id, ) db.add(new_calendar) await db.commit() @@ -48,7 +53,9 @@ async def update_calendar( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - result = await db.execute(select(Calendar).where(Calendar.id == calendar_id)) + result = await db.execute( + select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == current_user.id) + ) calendar = result.scalar_one_or_none() if not calendar: @@ -74,7 +81,9 @@ async def delete_calendar( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - result = await db.execute(select(Calendar).where(Calendar.id == calendar_id)) + result = await db.execute( + select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == current_user.id) + ) calendar = result.scalar_one_or_none() if not calendar: @@ -86,8 +95,13 @@ async def delete_calendar( if calendar.is_default: raise HTTPException(status_code=400, detail="Cannot delete the default calendar") - # Reassign all events on this calendar to the default calendar - default_result = await db.execute(select(Calendar).where(Calendar.is_default == True)) + # Reassign all events on this calendar to the user's default calendar + default_result = await db.execute( + select(Calendar).where( + Calendar.user_id == current_user.id, + Calendar.is_default == True, + ) + ) default_calendar = default_result.scalar_one_or_none() if default_calendar: diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index cd0b8b0..c3b3bbc 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -8,9 +8,11 @@ from app.database import get_db from app.models.settings import Settings from app.models.todo import Todo from app.models.calendar_event import CalendarEvent +from app.models.calendar import Calendar from app.models.reminder import Reminder from app.models.project import Project -from app.routers.auth import get_current_settings +from app.models.user import User +from app.routers.auth import get_current_user, get_current_settings router = APIRouter() @@ -26,16 +28,21 @@ _not_parent_template = or_( async def get_dashboard( client_date: Optional[date] = Query(None), db: AsyncSession = Depends(get_db), - current_settings: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): """Get aggregated dashboard data.""" today = client_date or date.today() upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days) + # Subquery: calendar IDs belonging to this user (for event scoping) + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + # Today's events (exclude parent templates — they are hidden, children are shown) today_start = datetime.combine(today, datetime.min.time()) today_end = datetime.combine(today, datetime.max.time()) events_query = select(CalendarEvent).where( + CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime <= today_end, _not_parent_template, @@ -45,6 +52,7 @@ async def get_dashboard( # Upcoming todos (not completed, with due date from today through upcoming_days) todos_query = select(Todo).where( + Todo.user_id == current_user.id, Todo.completed == False, Todo.due_date.isnot(None), Todo.due_date >= today, @@ -55,6 +63,7 @@ async def get_dashboard( # Active reminders (not dismissed, is_active = true, from today onward) reminders_query = select(Reminder).where( + Reminder.user_id == current_user.id, Reminder.is_active == True, Reminder.is_dismissed == False, Reminder.remind_at >= today_start @@ -62,26 +71,32 @@ async def get_dashboard( reminders_result = await db.execute(reminders_query) active_reminders = reminders_result.scalars().all() - # Project stats - total_projects_result = await db.execute(select(func.count(Project.id))) + # Project stats (scoped to user) + total_projects_result = await db.execute( + select(func.count(Project.id)).where(Project.user_id == current_user.id) + ) total_projects = total_projects_result.scalar() projects_by_status_query = select( Project.status, func.count(Project.id).label("count") - ).group_by(Project.status) + ).where(Project.user_id == current_user.id).group_by(Project.status) projects_by_status_result = await db.execute(projects_by_status_query) projects_by_status = {row[0]: row[1] for row in projects_by_status_result} - # Total incomplete todos count + # Total incomplete todos count (scoped to user) total_incomplete_result = await db.execute( - select(func.count(Todo.id)).where(Todo.completed == False) + select(func.count(Todo.id)).where( + Todo.user_id == current_user.id, + Todo.completed == False, + ) ) total_incomplete_todos = total_incomplete_result.scalar() - # Starred events (upcoming, ordered by date) + # Starred events (upcoming, ordered by date, scoped to user's calendars) now = datetime.now() starred_query = select(CalendarEvent).where( + CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.is_starred == True, CalendarEvent.start_datetime > now, _not_parent_template, @@ -143,7 +158,8 @@ async def get_upcoming( days: int = Query(default=7, ge=1, le=90), client_date: Optional[date] = Query(None), db: AsyncSession = Depends(get_db), - current_settings: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): """Get unified list of upcoming items (todos, events, reminders) sorted by date.""" today = client_date or date.today() @@ -151,8 +167,12 @@ async def get_upcoming( cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time()) today_start = datetime.combine(today, datetime.min.time()) - # Get upcoming todos with due dates (today onward only) + # Subquery: calendar IDs belonging to this user + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + + # Get upcoming todos with due dates (today onward only, scoped to user) todos_query = select(Todo).where( + Todo.user_id == current_user.id, Todo.completed == False, Todo.due_date.isnot(None), Todo.due_date >= today, @@ -161,8 +181,9 @@ async def get_upcoming( todos_result = await db.execute(todos_query) todos = todos_result.scalars().all() - # Get upcoming events (from today onward, exclude parent templates) + # Get upcoming events (from today onward, exclude parent templates, scoped to user's calendars) events_query = select(CalendarEvent).where( + CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime <= cutoff_datetime, _not_parent_template, @@ -170,8 +191,9 @@ async def get_upcoming( events_result = await db.execute(events_query) events = events_result.scalars().all() - # Get upcoming reminders (today onward only) + # Get upcoming reminders (today onward only, scoped to user) reminders_query = select(Reminder).where( + Reminder.user_id == current_user.id, Reminder.is_active == True, Reminder.is_dismissed == False, Reminder.remind_at >= today_start, diff --git a/backend/app/routers/event_templates.py b/backend/app/routers/event_templates.py index 95efe42..7d7a90b 100644 --- a/backend/app/routers/event_templates.py +++ b/backend/app/routers/event_templates.py @@ -20,7 +20,11 @@ async def list_templates( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - result = await db.execute(select(EventTemplate).order_by(EventTemplate.name)) + result = await db.execute( + select(EventTemplate) + .where(EventTemplate.user_id == current_user.id) + .order_by(EventTemplate.name) + ) return result.scalars().all() @@ -30,7 +34,7 @@ async def create_template( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - template = EventTemplate(**payload.model_dump()) + template = EventTemplate(**payload.model_dump(), user_id=current_user.id) db.add(template) await db.commit() await db.refresh(template) @@ -45,7 +49,10 @@ async def update_template( current_user: User = Depends(get_current_user), ): result = await db.execute( - select(EventTemplate).where(EventTemplate.id == template_id) + select(EventTemplate).where( + EventTemplate.id == template_id, + EventTemplate.user_id == current_user.id, + ) ) template = result.scalar_one_or_none() if template is None: @@ -66,7 +73,10 @@ async def delete_template( current_user: User = Depends(get_current_user), ): result = await db.execute( - select(EventTemplate).where(EventTemplate.id == template_id) + select(EventTemplate).where( + EventTemplate.id == template_id, + EventTemplate.user_id == current_user.id, + ) ) template = result.scalar_one_or_none() if template is None: diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index d91776f..3dc6e1a 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -105,15 +105,29 @@ def _birthday_events_for_range( return virtual_events -async def _get_default_calendar_id(db: AsyncSession) -> int: - """Return the id of the default calendar, raising 500 if not found.""" - result = await db.execute(select(Calendar).where(Calendar.is_default == True)) +async def _get_default_calendar_id(db: AsyncSession, user_id: int) -> int: + """Return the id of the user's default calendar, raising 500 if not found.""" + result = await db.execute( + select(Calendar).where( + Calendar.user_id == user_id, + Calendar.is_default == True, + ) + ) default = result.scalar_one_or_none() if not default: raise HTTPException(status_code=500, detail="No default calendar configured") return default.id +async def _verify_calendar_ownership(db: AsyncSession, calendar_id: int, user_id: int) -> None: + """Raise 404 if calendar_id does not belong to user_id (SEC-04).""" + result = await db.execute( + select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == user_id) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Calendar not found") + + @router.get("/", response_model=None) async def get_events( start: Optional[date] = Query(None), @@ -128,9 +142,13 @@ async def get_events( recurrence_rule IS NOT NULL) are excluded — their materialised children are what get displayed on the calendar. """ + # Scope events through calendar ownership + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + query = ( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) + .where(CalendarEvent.calendar_id.in_(user_calendar_ids)) ) # Exclude parent template rows — they are not directly rendered @@ -154,14 +172,24 @@ async def get_events( response: List[dict] = [_event_to_dict(e) for e in events] - # Fetch Birthdays calendar; only generate virtual events if visible + # Fetch the user's Birthdays system calendar; only generate virtual events if visible bday_result = await db.execute( - select(Calendar).where(Calendar.name == "Birthdays", Calendar.is_system == True) + select(Calendar).where( + Calendar.user_id == current_user.id, + Calendar.name == "Birthdays", + Calendar.is_system == True, + ) ) bday_calendar = bday_result.scalar_one_or_none() if bday_calendar and bday_calendar.is_visible: - people_result = await db.execute(select(Person).where(Person.birthday.isnot(None))) + # Scope birthday people to this user + people_result = await db.execute( + select(Person).where( + Person.user_id == current_user.id, + Person.birthday.isnot(None), + ) + ) people = people_result.scalars().all() virtual = _birthday_events_for_range( @@ -187,9 +215,12 @@ async def create_event( data = event.model_dump() - # Resolve calendar_id to default if not provided + # Resolve calendar_id to user's default if not provided if not data.get("calendar_id"): - data["calendar_id"] = await _get_default_calendar_id(db) + data["calendar_id"] = await _get_default_calendar_id(db, current_user.id) + else: + # SEC-04: verify the target calendar belongs to the requesting user + await _verify_calendar_ownership(db, data["calendar_id"], current_user.id) # Serialize RecurrenceRule object to JSON string for DB storage # Exclude None values so defaults in recurrence service work correctly @@ -245,10 +276,15 @@ async def get_event( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + result = await db.execute( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) - .where(CalendarEvent.id == event_id) + .where( + CalendarEvent.id == event_id, + CalendarEvent.calendar_id.in_(user_calendar_ids), + ) ) event = result.scalar_one_or_none() @@ -265,10 +301,15 @@ async def update_event( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + result = await db.execute( select(CalendarEvent) .options(selectinload(CalendarEvent.calendar)) - .where(CalendarEvent.id == event_id) + .where( + CalendarEvent.id == event_id, + CalendarEvent.calendar_id.in_(user_calendar_ids), + ) ) event = result.scalar_one_or_none() @@ -285,6 +326,10 @@ async def update_event( if rule_obj is not None: update_data["recurrence_rule"] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None + # SEC-04: if calendar_id is being changed, verify the target belongs to the user + if "calendar_id" in update_data and update_data["calendar_id"] is not None: + await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id) + start = update_data.get("start_datetime", event.start_datetime) end_dt = update_data.get("end_datetime", event.end_datetime) if end_dt is not None and end_dt < start: @@ -381,7 +426,14 @@ async def delete_event( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) + user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id) + + result = await db.execute( + select(CalendarEvent).where( + CalendarEvent.id == event_id, + CalendarEvent.calendar_id.in_(user_calendar_ids), + ) + ) event = result.scalar_one_or_none() if not event: diff --git a/backend/app/routers/locations.py b/backend/app/routers/locations.py index ed64541..e209d8f 100644 --- a/backend/app/routers/locations.py +++ b/backend/app/routers/locations.py @@ -29,14 +29,15 @@ async def search_locations( """Search locations from local DB and Nominatim OSM.""" results: List[LocationSearchResult] = [] - # Local DB search + # Local DB search — scoped to user's locations local_query = ( select(Location) .where( + Location.user_id == current_user.id, or_( Location.name.ilike(f"%{q}%"), Location.address.ilike(f"%{q}%"), - ) + ), ) .limit(5) ) @@ -89,7 +90,7 @@ async def get_locations( current_user: User = Depends(get_current_user) ): """Get all locations with optional category filter.""" - query = select(Location) + query = select(Location).where(Location.user_id == current_user.id) if category: query = query.where(Location.category == category) @@ -109,7 +110,7 @@ async def create_location( current_user: User = Depends(get_current_user) ): """Create a new location.""" - new_location = Location(**location.model_dump()) + new_location = Location(**location.model_dump(), user_id=current_user.id) db.add(new_location) await db.commit() await db.refresh(new_location) @@ -124,7 +125,9 @@ async def get_location( current_user: User = Depends(get_current_user) ): """Get a specific location by ID.""" - result = await db.execute(select(Location).where(Location.id == location_id)) + result = await db.execute( + select(Location).where(Location.id == location_id, Location.user_id == current_user.id) + ) location = result.scalar_one_or_none() if not location: @@ -141,7 +144,9 @@ async def update_location( current_user: User = Depends(get_current_user) ): """Update a location.""" - result = await db.execute(select(Location).where(Location.id == location_id)) + result = await db.execute( + select(Location).where(Location.id == location_id, Location.user_id == current_user.id) + ) location = result.scalar_one_or_none() if not location: @@ -168,7 +173,9 @@ async def delete_location( current_user: User = Depends(get_current_user) ): """Delete a location.""" - result = await db.execute(select(Location).where(Location.id == location_id)) + result = await db.execute( + select(Location).where(Location.id == location_id, Location.user_id == current_user.id) + ) location = result.scalar_one_or_none() if not location: diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 150f268..2bee2b1 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -37,7 +37,7 @@ async def get_people( current_user: User = Depends(get_current_user) ): """Get all people with optional search and category filter.""" - query = select(Person) + query = select(Person).where(Person.user_id == current_user.id) if search: term = f"%{search}%" @@ -75,7 +75,7 @@ async def create_person( parts = data['name'].split(' ', 1) data['first_name'] = parts[0] data['last_name'] = parts[1] if len(parts) > 1 else None - new_person = Person(**data) + new_person = Person(**data, user_id=current_user.id) new_person.name = _compute_display_name( new_person.first_name, new_person.last_name, @@ -96,7 +96,9 @@ async def get_person( current_user: User = Depends(get_current_user) ): """Get a specific person by ID.""" - result = await db.execute(select(Person).where(Person.id == person_id)) + result = await db.execute( + select(Person).where(Person.id == person_id, Person.user_id == current_user.id) + ) person = result.scalar_one_or_none() if not person: @@ -113,7 +115,9 @@ async def update_person( current_user: User = Depends(get_current_user) ): """Update a person and refresh the denormalised display name.""" - result = await db.execute(select(Person).where(Person.id == person_id)) + result = await db.execute( + select(Person).where(Person.id == person_id, Person.user_id == current_user.id) + ) person = result.scalar_one_or_none() if not person: @@ -147,7 +151,9 @@ async def delete_person( current_user: User = Depends(get_current_user) ): """Delete a person.""" - result = await db.execute(select(Person).where(Person.id == person_id)) + result = await db.execute( + select(Person).where(Person.id == person_id, Person.user_id == current_user.id) + ) person = result.scalar_one_or_none() if not person: diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index dd86618..c49fecf 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -49,7 +49,12 @@ async def get_projects( current_user: User = Depends(get_current_user) ): """Get all projects with their tasks. Optionally filter by tracked status.""" - query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc()) + query = ( + select(Project) + .options(*_project_load_options()) + .where(Project.user_id == current_user.id) + .order_by(Project.created_at.desc()) + ) if tracked is not None: query = query.where(Project.is_tracked == tracked) result = await db.execute(query) @@ -77,6 +82,7 @@ async def get_tracked_tasks( selectinload(ProjectTask.parent_task), ) .where( + Project.user_id == current_user.id, Project.is_tracked == True, ProjectTask.due_date.isnot(None), ProjectTask.due_date >= today, @@ -110,7 +116,7 @@ async def create_project( current_user: User = Depends(get_current_user) ): """Create a new project.""" - new_project = Project(**project.model_dump()) + new_project = Project(**project.model_dump(), user_id=current_user.id) db.add(new_project) await db.commit() @@ -127,7 +133,11 @@ async def get_project( current_user: User = Depends(get_current_user) ): """Get a specific project by ID with its tasks.""" - query = select(Project).options(*_project_load_options()).where(Project.id == project_id) + query = ( + select(Project) + .options(*_project_load_options()) + .where(Project.id == project_id, Project.user_id == current_user.id) + ) result = await db.execute(query) project = result.scalar_one_or_none() @@ -145,7 +155,9 @@ async def update_project( current_user: User = Depends(get_current_user) ): """Update a project.""" - result = await db.execute(select(Project).where(Project.id == project_id)) + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) project = result.scalar_one_or_none() if not project: @@ -171,7 +183,9 @@ async def delete_project( current_user: User = Depends(get_current_user) ): """Delete a project and all its tasks.""" - result = await db.execute(select(Project).where(Project.id == project_id)) + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) project = result.scalar_one_or_none() if not project: @@ -190,7 +204,10 @@ async def get_project_tasks( current_user: User = Depends(get_current_user) ): """Get top-level tasks for a specific project (subtasks are nested).""" - result = await db.execute(select(Project).where(Project.id == project_id)) + # Verify project ownership first + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) project = result.scalar_one_or_none() if not project: @@ -219,7 +236,10 @@ async def create_project_task( current_user: User = Depends(get_current_user) ): """Create a new task or subtask for a project.""" - result = await db.execute(select(Project).where(Project.id == project_id)) + # Verify project ownership first + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) project = result.scalar_one_or_none() if not project: @@ -265,7 +285,10 @@ async def reorder_tasks( current_user: User = Depends(get_current_user) ): """Bulk update sort_order for tasks.""" - result = await db.execute(select(Project).where(Project.id == project_id)) + # Verify project ownership first + result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) project = result.scalar_one_or_none() if not project: @@ -296,6 +319,13 @@ async def update_project_task( current_user: User = Depends(get_current_user) ): """Update a project task.""" + # Verify project ownership first, then fetch task scoped to that project + project_result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + result = await db.execute( select(ProjectTask).where( ProjectTask.id == task_id, @@ -332,6 +362,13 @@ async def delete_project_task( current_user: User = Depends(get_current_user) ): """Delete a project task (cascades to subtasks).""" + # Verify project ownership first, then fetch task scoped to that project + project_result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + result = await db.execute( select(ProjectTask).where( ProjectTask.id == task_id, @@ -358,6 +395,13 @@ async def create_task_comment( current_user: User = Depends(get_current_user) ): """Add a comment to a task.""" + # Verify project ownership first, then fetch task scoped to that project + project_result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + result = await db.execute( select(ProjectTask).where( ProjectTask.id == task_id, @@ -386,6 +430,13 @@ async def delete_task_comment( current_user: User = Depends(get_current_user) ): """Delete a task comment.""" + # Verify project ownership first, then fetch comment scoped through task + project_result = await db.execute( + select(Project).where(Project.id == project_id, Project.user_id == current_user.id) + ) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + result = await db.execute( select(TaskComment).where( TaskComment.id == comment_id, diff --git a/backend/app/routers/reminders.py b/backend/app/routers/reminders.py index b33872a..788ea79 100644 --- a/backend/app/routers/reminders.py +++ b/backend/app/routers/reminders.py @@ -22,7 +22,7 @@ async def get_reminders( current_user: User = Depends(get_current_user) ): """Get all reminders with optional filters.""" - query = select(Reminder) + query = select(Reminder).where(Reminder.user_id == current_user.id) if active is not None: query = query.where(Reminder.is_active == active) @@ -48,6 +48,7 @@ async def get_due_reminders( now = client_now or datetime.now() query = select(Reminder).where( and_( + Reminder.user_id == current_user.id, Reminder.remind_at <= now, Reminder.is_dismissed == False, Reminder.is_active == True, @@ -74,7 +75,12 @@ async def snooze_reminder( current_user: User = Depends(get_current_user) ): """Snooze a reminder for N minutes from now.""" - result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + result = await db.execute( + select(Reminder).where( + Reminder.id == reminder_id, + Reminder.user_id == current_user.id, + ) + ) reminder = result.scalar_one_or_none() if not reminder: @@ -99,7 +105,7 @@ async def create_reminder( current_user: User = Depends(get_current_user) ): """Create a new reminder.""" - new_reminder = Reminder(**reminder.model_dump()) + new_reminder = Reminder(**reminder.model_dump(), user_id=current_user.id) db.add(new_reminder) await db.commit() await db.refresh(new_reminder) @@ -114,7 +120,12 @@ async def get_reminder( current_user: User = Depends(get_current_user) ): """Get a specific reminder by ID.""" - result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + result = await db.execute( + select(Reminder).where( + Reminder.id == reminder_id, + Reminder.user_id == current_user.id, + ) + ) reminder = result.scalar_one_or_none() if not reminder: @@ -131,7 +142,12 @@ async def update_reminder( current_user: User = Depends(get_current_user) ): """Update a reminder.""" - result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + result = await db.execute( + select(Reminder).where( + Reminder.id == reminder_id, + Reminder.user_id == current_user.id, + ) + ) reminder = result.scalar_one_or_none() if not reminder: @@ -164,7 +180,12 @@ async def delete_reminder( current_user: User = Depends(get_current_user) ): """Delete a reminder.""" - result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + result = await db.execute( + select(Reminder).where( + Reminder.id == reminder_id, + Reminder.user_id == current_user.id, + ) + ) reminder = result.scalar_one_or_none() if not reminder: @@ -183,7 +204,12 @@ async def dismiss_reminder( current_user: User = Depends(get_current_user) ): """Dismiss a reminder.""" - result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + result = await db.execute( + select(Reminder).where( + Reminder.id == reminder_id, + Reminder.user_id == current_user.id, + ) + ) reminder = result.scalar_one_or_none() if not reminder: diff --git a/backend/app/routers/todos.py b/backend/app/routers/todos.py index 57841a7..86cdf65 100644 --- a/backend/app/routers/todos.py +++ b/backend/app/routers/todos.py @@ -73,15 +73,17 @@ def _calculate_recurrence( return reset_at, next_due -async def _reactivate_recurring_todos(db: AsyncSession) -> None: +async def _reactivate_recurring_todos(db: AsyncSession, user_id: int) -> None: """Auto-reactivate recurring todos whose reset_at has passed. Uses flush (not commit) so changes are visible to the subsequent query within the same transaction. The caller's commit handles persistence. + Scoped to a single user to avoid cross-user reactivation. """ now = datetime.now() query = select(Todo).where( and_( + Todo.user_id == user_id, Todo.completed == True, Todo.recurrence_rule.isnot(None), Todo.reset_at.isnot(None), @@ -110,13 +112,14 @@ async def get_todos( category: Optional[str] = Query(None), search: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): """Get all todos with optional filters.""" # Reactivate any recurring todos whose reset time has passed - await _reactivate_recurring_todos(db) + await _reactivate_recurring_todos(db, current_user.id) - query = select(Todo) + query = select(Todo).where(Todo.user_id == current_user.id) if completed is not None: query = query.where(Todo.completed == completed) @@ -144,10 +147,10 @@ async def get_todos( async def create_todo( todo: TodoCreate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), ): """Create a new todo.""" - new_todo = Todo(**todo.model_dump()) + new_todo = Todo(**todo.model_dump(), user_id=current_user.id) db.add(new_todo) await db.commit() await db.refresh(new_todo) @@ -159,10 +162,12 @@ async def create_todo( async def get_todo( todo_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), ): """Get a specific todo by ID.""" - result = await db.execute(select(Todo).where(Todo.id == todo_id)) + result = await db.execute( + select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id) + ) todo = result.scalar_one_or_none() if not todo: @@ -176,10 +181,13 @@ async def update_todo( todo_id: int, todo_update: TodoUpdate, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): """Update a todo.""" - result = await db.execute(select(Todo).where(Todo.id == todo_id)) + result = await db.execute( + select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id) + ) todo = result.scalar_one_or_none() if not todo: @@ -210,7 +218,7 @@ async def update_todo( reset_at, next_due = _calculate_recurrence( todo.recurrence_rule, todo.due_date, - current_user.first_day_of_week, + current_settings.first_day_of_week, ) todo.reset_at = reset_at todo.next_due_date = next_due @@ -229,10 +237,12 @@ async def update_todo( async def delete_todo( todo_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), ): """Delete a todo.""" - result = await db.execute(select(Todo).where(Todo.id == todo_id)) + result = await db.execute( + select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id) + ) todo = result.scalar_one_or_none() if not todo: @@ -248,10 +258,13 @@ async def delete_todo( async def toggle_todo( todo_id: int, db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): """Toggle todo completion status. For recurring todos, calculates reset schedule.""" - result = await db.execute(select(Todo).where(Todo.id == todo_id)) + result = await db.execute( + select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id) + ) todo = result.scalar_one_or_none() if not todo: @@ -267,7 +280,7 @@ async def toggle_todo( reset_at, next_due = _calculate_recurrence( todo.recurrence_rule, todo.due_date, - current_user.first_day_of_week, + current_settings.first_day_of_week, ) todo.reset_at = reset_at todo.next_due_date = next_due diff --git a/backend/app/routers/weather.py b/backend/app/routers/weather.py index 25f8b94..bed8724 100644 --- a/backend/app/routers/weather.py +++ b/backend/app/routers/weather.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from datetime import datetime, timedelta +from collections import OrderedDict import asyncio import urllib.request import urllib.parse @@ -11,13 +12,37 @@ import json from app.database import get_db from app.models.settings import Settings +from app.models.user import User from app.config import settings as app_settings from app.routers.auth import get_current_user, get_current_settings -from app.models.user import User router = APIRouter() -_cache: dict = {} +# SEC-15: Bounded LRU cache keyed by (user_id, location) — max 100 entries. +# OrderedDict preserves insertion order; move_to_end on hit, popitem(last=False) +# to evict the oldest when capacity is exceeded. +_CACHE_MAX = 100 +_cache: OrderedDict = OrderedDict() + + +def _cache_get(key: tuple) -> dict | None: + """Return cached entry if it exists and hasn't expired.""" + entry = _cache.get(key) + if entry and datetime.now() < entry["expires_at"]: + _cache.move_to_end(key) # LRU: promote to most-recently-used + return entry["data"] + if entry: + del _cache[key] # expired — evict immediately + return None + + +def _cache_set(key: tuple, data: dict) -> None: + """Store an entry; evict the oldest if over capacity.""" + if key in _cache: + _cache.move_to_end(key) + _cache[key] = {"data": data, "expires_at": datetime.now() + timedelta(hours=1)} + while len(_cache) > _CACHE_MAX: + _cache.popitem(last=False) # evict LRU (oldest) class GeoSearchResult(BaseModel): @@ -66,23 +91,24 @@ async def search_locations( @router.get("/") async def get_weather( db: AsyncSession = Depends(get_db), - current_user: Settings = Depends(get_current_settings) + current_user: User = Depends(get_current_user), + current_settings: Settings = Depends(get_current_settings), ): - city = current_user.weather_city - lat = current_user.weather_lat - lon = current_user.weather_lon + city = current_settings.weather_city + lat = current_settings.weather_lat + lon = current_settings.weather_lon if not city and (lat is None or lon is None): raise HTTPException(status_code=400, detail="No weather location configured") - # Build cache key from coordinates or city + # Cache key includes user_id so each user gets isolated cache entries use_coords = lat is not None and lon is not None - cache_key = f"{lat},{lon}" if use_coords else city + location_key = f"{lat},{lon}" if use_coords else city + cache_key = (current_user.id, location_key) - # Check cache - now = datetime.now() - if _cache.get("expires_at") and now < _cache["expires_at"] and _cache.get("cache_key") == cache_key: - return _cache["data"] + cached = _cache_get(cache_key) + if cached is not None: + return cached api_key = app_settings.OPENWEATHERMAP_API_KEY if not api_key: @@ -122,11 +148,7 @@ async def get_weather( "city": current_data["name"], } - # Cache for 1 hour - _cache["data"] = weather_result - _cache["expires_at"] = now + timedelta(hours=1) - _cache["cache_key"] = cache_key - + _cache_set(cache_key, weather_result) return weather_result except urllib.error.URLError: diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..075a916 --- /dev/null +++ b/backend/app/schemas/admin.py @@ -0,0 +1,133 @@ +""" +Admin API schemas — Pydantic v2. + +All admin-facing request/response shapes live here to keep the admin router +clean and testable in isolation. +""" +import re +from datetime import datetime +from typing import Optional, Literal + +from pydantic import BaseModel, ConfigDict, field_validator + +from app.schemas.auth import _validate_username, _validate_password_strength + + +# --------------------------------------------------------------------------- +# User list / detail +# --------------------------------------------------------------------------- + +class UserListItem(BaseModel): + id: int + username: str + role: str + is_active: bool + last_login_at: Optional[datetime] = None + last_password_change_at: Optional[datetime] = None + totp_enabled: bool + mfa_enforce_pending: bool + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class UserListResponse(BaseModel): + users: list[UserListItem] + total: int + + +class UserDetailResponse(UserListItem): + active_sessions: int + + +# --------------------------------------------------------------------------- +# Mutating user requests +# --------------------------------------------------------------------------- + +class CreateUserRequest(BaseModel): + """Admin-created user — allows role selection (unlike public RegisterRequest).""" + model_config = ConfigDict(extra="forbid") + + username: str + password: str + role: Literal["admin", "standard", "public_event_manager"] = "standard" + + @field_validator("username") + @classmethod + def validate_username(cls, v: str) -> str: + return _validate_username(v) + + @field_validator("password") + @classmethod + def validate_password(cls, v: str) -> str: + return _validate_password_strength(v) + + +class UpdateUserRoleRequest(BaseModel): + role: Literal["admin", "standard", "public_event_manager"] + + +class ToggleActiveRequest(BaseModel): + is_active: bool + + +class ToggleMfaEnforceRequest(BaseModel): + enforce: bool + + +# --------------------------------------------------------------------------- +# System config +# --------------------------------------------------------------------------- + +class SystemConfigResponse(BaseModel): + allow_registration: bool + enforce_mfa_new_users: bool + + model_config = ConfigDict(from_attributes=True) + + +class SystemConfigUpdate(BaseModel): + allow_registration: Optional[bool] = None + enforce_mfa_new_users: Optional[bool] = None + + +# --------------------------------------------------------------------------- +# Admin dashboard +# --------------------------------------------------------------------------- + +class AdminDashboardResponse(BaseModel): + total_users: int + active_users: int + admin_count: int + active_sessions: int + mfa_adoption_rate: float + recent_logins: list[dict] + recent_audit_entries: list[dict] + + +# --------------------------------------------------------------------------- +# Password reset +# --------------------------------------------------------------------------- + +class ResetPasswordResponse(BaseModel): + message: str + temporary_password: str + + +# --------------------------------------------------------------------------- +# Audit log +# --------------------------------------------------------------------------- + +class AuditLogEntry(BaseModel): + id: int + actor_username: Optional[str] = None + target_username: Optional[str] = None + action: str + detail: Optional[str] = None + ip_address: Optional[str] = None + created_at: datetime + + +class AuditLogResponse(BaseModel): + entries: list[AuditLogEntry] + total: int diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 806835d..a45dba3 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,5 +1,5 @@ import re -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, ConfigDict, field_validator def _validate_password_strength(v: str) -> str: @@ -21,6 +21,16 @@ def _validate_password_strength(v: str) -> str: return v +def _validate_username(v: str) -> str: + """Shared username validation.""" + v = v.strip().lower() + if not 3 <= len(v) <= 50: + raise ValueError("Username must be 3–50 characters") + if not re.fullmatch(r"[a-z0-9_\-]+", v): + raise ValueError("Username may only contain letters, numbers, _ and -") + return v + + class SetupRequest(BaseModel): username: str password: str @@ -28,12 +38,29 @@ class SetupRequest(BaseModel): @field_validator("username") @classmethod def validate_username(cls, v: str) -> str: - v = v.strip().lower() - if not 3 <= len(v) <= 50: - raise ValueError("Username must be 3–50 characters") - if not re.fullmatch(r"[a-z0-9_\-]+", v): - raise ValueError("Username may only contain letters, numbers, _ and -") - return v + return _validate_username(v) + + @field_validator("password") + @classmethod + def validate_password(cls, v: str) -> str: + return _validate_password_strength(v) + + +class RegisterRequest(BaseModel): + """ + Public registration schema — SEC-01: extra="forbid" prevents role injection. + An attacker sending {"username": "...", "password": "...", "role": "admin"} + will get a 422 Validation Error instead of silent acceptance. + """ + model_config = ConfigDict(extra="forbid") + + username: str + password: str + + @field_validator("username") + @classmethod + def validate_username(cls, v: str) -> str: + return _validate_username(v) @field_validator("password") @classmethod diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 0000000..a38548c --- /dev/null +++ b/backend/app/services/audit.py @@ -0,0 +1,22 @@ +import json +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.audit_log import AuditLog + + +async def log_audit_event( + db: AsyncSession, + action: str, + actor_id: int | None = None, + target_id: int | None = None, + detail: dict | None = None, + ip: str | None = None, +) -> None: + """Record an action in the audit log. Does NOT commit — caller handles transaction.""" + entry = AuditLog( + actor_user_id=actor_id, + target_user_id=target_id, + action=action, + detail=json.dumps(detail) if detail else None, + ip_address=ip[:45] if ip else None, + ) + db.add(entry) diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 186524b..adf2323 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -126,3 +126,32 @@ def verify_mfa_token(token: str) -> int | None: return data["uid"] except Exception: return None + + +# --------------------------------------------------------------------------- +# MFA enforcement tokens (SEC-03: distinct salt from challenge tokens) +# --------------------------------------------------------------------------- + +_mfa_enforce_serializer = URLSafeTimedSerializer( + secret_key=app_settings.SECRET_KEY, + salt="mfa-enforce-setup-v1", +) + + +def create_mfa_enforce_token(user_id: int) -> str: + """Create a short-lived token for MFA enforcement setup (not a session).""" + return _mfa_enforce_serializer.dumps({"uid": user_id}) + + +def verify_mfa_enforce_token(token: str) -> int | None: + """ + Verify an MFA enforcement setup token. + Returns user_id on success, None if invalid or expired (5-minute TTL). + """ + try: + data = _mfa_enforce_serializer.loads( + token, max_age=app_settings.MFA_TOKEN_MAX_AGE_SECONDS + ) + return data["uid"] + except Exception: + return None diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 3519178..1e8bbd8 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,5 +1,9 @@ # Rate limiting zones (before server block) limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m; +# SEC-14: Registration endpoint — slightly more permissive than strict auth endpoints +limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m; +# Admin API — generous for legitimate use but still guards against scraping/brute-force +limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m; # Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access map $http_x_forwarded_proto $forwarded_proto { @@ -60,6 +64,20 @@ server { include /etc/nginx/proxy-params.conf; } + # SEC-14: Rate-limit public registration endpoint + location /api/auth/register { + limit_req zone=register_limit burst=3 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + + # Admin API — rate-limited separately from general /api traffic + location /api/admin/ { + limit_req zone=admin_limit burst=10 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + # API proxy location /api { proxy_pass http://backend:8000; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6e1694d..a969411 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuth } from '@/hooks/useAuth'; import LockScreen from '@/components/auth/LockScreen'; @@ -12,6 +13,8 @@ import PeoplePage from '@/components/people/PeoplePage'; import LocationsPage from '@/components/locations/LocationsPage'; import SettingsPage from '@/components/settings/SettingsPage'; +const AdminPortal = lazy(() => import('@/components/admin/AdminPortal')); + function ProtectedRoute({ children }: { children: React.ReactNode }) { const { authStatus, isLoading } = useAuth(); @@ -30,6 +33,24 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { return <>{children}; } +function AdminRoute({ children }: { children: React.ReactNode }) { + const { authStatus, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!authStatus?.authenticated || authStatus?.role !== 'admin') { + return ; + } + + return <>{children}; +} + function App() { return ( @@ -52,6 +73,16 @@ function App() { } /> } /> } /> + + Loading...
}> + + + + } + /> ); diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index e269f9e..78ea3ed 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -16,6 +16,7 @@ import { X, LogOut, Lock, + Shield, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuth } from '@/hooks/useAuth'; @@ -44,7 +45,7 @@ interface SidebarProps { export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) { const navigate = useNavigate(); const location = useLocation(); - const { logout } = useAuth(); + const { logout, isAdmin } = useAuth(); const { lock } = useLock(); const [projectsExpanded, setProjectsExpanded] = useState(false); @@ -193,6 +194,16 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose {showExpanded && Lock} + {isAdmin && ( + + + {showExpanded && Admin} + + )} (null); + const [mfaSetupRequired, setMfaSetupRequired] = useState(false); const authQuery = useQuery({ queryKey: ['auth'], @@ -23,11 +23,34 @@ export function useAuth() { return data; }, onSuccess: (data) => { - if ('mfa_token' in data && data.totp_required) { - // MFA required — store token locally, do NOT mark as authenticated yet + if ('mfa_setup_required' in data && data.mfa_setup_required) { + // MFA enforcement — user must set up TOTP before accessing app + setMfaSetupRequired(true); + setMfaToken(data.mfa_token); + } else if ('mfa_token' in data && 'totp_required' in data && data.totp_required) { + // Regular TOTP challenge + setMfaToken(data.mfa_token); + setMfaSetupRequired(false); + } else { + setMfaToken(null); + setMfaSetupRequired(false); + queryClient.invalidateQueries({ queryKey: ['auth'] }); + } + }, + }); + + const registerMutation = useMutation({ + mutationFn: async ({ username, password }: { username: string; password: string }) => { + const { data } = await api.post('/auth/register', { username, password }); + return data; + }, + onSuccess: (data) => { + if ('mfa_setup_required' in data && data.mfa_setup_required) { + setMfaSetupRequired(true); setMfaToken(data.mfa_token); } else { setMfaToken(null); + setMfaSetupRequired(false); queryClient.invalidateQueries({ queryKey: ['auth'] }); } }, @@ -43,6 +66,7 @@ export function useAuth() { }, onSuccess: () => { setMfaToken(null); + setMfaSetupRequired(false); queryClient.invalidateQueries({ queryKey: ['auth'] }); }, }); @@ -64,6 +88,7 @@ export function useAuth() { }, onSuccess: () => { setMfaToken(null); + setMfaSetupRequired(false); queryClient.invalidateQueries({ queryKey: ['auth'] }); }, }); @@ -71,12 +96,18 @@ export function useAuth() { return { authStatus: authQuery.data, isLoading: authQuery.isLoading, - mfaRequired: mfaToken !== null, + role: authQuery.data?.role ?? null, + isAdmin: authQuery.data?.role === 'admin', + mfaRequired: mfaToken !== null && !mfaSetupRequired, + mfaSetupRequired, + mfaToken, login: loginMutation.mutateAsync, + register: registerMutation.mutateAsync, verifyTotp: totpVerifyMutation.mutateAsync, setup: setupMutation.mutateAsync, logout: logoutMutation.mutateAsync, isLoginPending: loginMutation.isPending, + isRegisterPending: registerMutation.isPending, isTotpPending: totpVerifyMutation.isPending, isSetupPending: setupMutation.isPending, }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7913a72..c169e2d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,6 +4,7 @@ const api = axios.create({ baseURL: '/api', headers: { 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', }, withCredentials: true, }); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 510aacd..0cb34c4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -188,14 +188,19 @@ export interface Location { updated_at: string; } +export type UserRole = 'admin' | 'standard' | 'public_event_manager'; + export interface AuthStatus { authenticated: boolean; setup_required: boolean; + role: UserRole | null; + registration_open: boolean; } // Login response discriminated union export interface LoginSuccessResponse { authenticated: true; + must_change_password?: boolean; } export interface LoginMfaRequiredResponse { @@ -204,7 +209,64 @@ export interface LoginMfaRequiredResponse { mfa_token: string; } -export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse; +export interface LoginMfaSetupRequiredResponse { + authenticated: false; + mfa_setup_required: true; + mfa_token: string; +} + +export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse | LoginMfaSetupRequiredResponse; + +// Admin types +export interface AdminUser { + id: number; + username: string; + role: UserRole; + is_active: boolean; + last_login_at: string | null; + last_password_change_at: string | null; + totp_enabled: boolean; + mfa_enforce_pending: boolean; + created_at: string; +} + +export interface AdminUserDetail extends AdminUser { + active_sessions: number; +} + +export interface SystemConfig { + allow_registration: boolean; + enforce_mfa_new_users: boolean; +} + +export interface AuditLogEntry { + id: number; + actor_username: string | null; + target_username: string | null; + action: string; + detail: string | null; + ip_address: string | null; + created_at: string; +} + +export interface AdminDashboardData { + total_users: number; + active_users: number; + admin_count: number; + active_sessions: number; + mfa_adoption_rate: number; + recent_logins: Array<{ + username: string; + last_login_at: string; + ip_address: string; + }>; + recent_audit_entries: Array<{ + action: string; + actor_username: string | null; + target_username: string | null; + created_at: string; + }>; +} // TOTP setup response (from POST /api/auth/totp/setup) export interface TotpSetupResponse { From e57a5b00c9480e1cec5612dd678e242a2b999629 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 26 Feb 2026 19:19:04 +0800 Subject: [PATCH 04/31] Fix QA review findings: C-01 through C-04, W-01 through W-07, S-01/S-04/S-05/S-06 Critical fixes: - C-01: Pass user_id to _mark_sent/_already_sent (ntfy crash) - C-02: Align frontend HTTP methods with backend routes (PATCH->PUT, DELETE->POST, fix reset-password/enforce-mfa/disable-mfa paths) - C-03: Add X-Requested-With to CORS allow_headers - C-04: Replace scalar_one_or_none with func.count for auth/status Warning fixes: - W-01: Batch audit log into same transaction in create_user, setup, register - W-02: Extract users array from UserListResponse wrapper in useAdminUsers - W-03: Update password hint from "8 chars" to "12 chars" in CreateUserDialog - W-04: Remove password input from reset flow, show returned temp password - W-06: Remove unused actor_alias variable in admin_dashboard - W-07: Resolve usernames in dashboard audit entries via JOIN, remove ip_address column from recent_logins (not tracked on User model) Suggestions applied: - S-01/S-06: Add extra="forbid" to all admin mutation schemas - S-04: Add ondelete="SET NULL" to audit_log.actor_user_id FK - S-05: Improve registration error message for better UX Co-Authored-By: Claude Opus 4.6 --- .../026_add_user_role_and_system_config.py | 2 +- backend/app/jobs/notifications.py | 27 ++++---- backend/app/main.py | 2 +- backend/app/models/audit_log.py | 2 +- backend/app/routers/admin.py | 30 +++++---- backend/app/routers/auth.py | 13 ++-- backend/app/schemas/admin.py | 4 ++ .../components/admin/AdminDashboardPage.tsx | 6 -- .../src/components/admin/CreateUserDialog.tsx | 4 +- .../src/components/admin/UserActionsMenu.tsx | 67 ++++++++----------- frontend/src/hooks/useAdmin.ts | 40 +++++------ frontend/src/types/index.ts | 1 - 12 files changed, 96 insertions(+), 102 deletions(-) diff --git a/backend/alembic/versions/026_add_user_role_and_system_config.py b/backend/alembic/versions/026_add_user_role_and_system_config.py index 9bb863a..63b11f4 100644 --- a/backend/alembic/versions/026_add_user_role_and_system_config.py +++ b/backend/alembic/versions/026_add_user_role_and_system_config.py @@ -76,7 +76,7 @@ def upgrade() -> None: sa.Column("ip_address", sa.String(45), nullable=True), sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")), sa.PrimaryKeyConstraint("id"), - sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"), sa.ForeignKeyConstraint( ["target_user_id"], ["users.id"], ondelete="SET NULL" ), diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index bcd0961..595191a 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -40,15 +40,18 @@ UMBRA_URL = "http://10.0.69.35" # ── Dedup helpers ───────────────────────────────────────────────────────────── -async def _already_sent(db: AsyncSession, key: str) -> bool: +async def _already_sent(db: AsyncSession, key: str, user_id: int) -> bool: result = await db.execute( - select(NtfySent).where(NtfySent.notification_key == key) + select(NtfySent).where( + NtfySent.user_id == user_id, + NtfySent.notification_key == key, + ) ) return result.scalar_one_or_none() is not None -async def _mark_sent(db: AsyncSession, key: str) -> None: - db.add(NtfySent(notification_key=key)) +async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None: + db.add(NtfySent(notification_key=key, user_id=user_id)) await db.commit() @@ -76,7 +79,7 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim # Key includes user_id to prevent cross-user dedup collisions key = f"reminder:{settings.user_id}:{reminder.id}:{reminder.remind_at.date()}" - if await _already_sent(db, key): + if await _already_sent(db, key, settings.user_id): continue payload = build_reminder_notification( @@ -91,7 +94,7 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim **payload, ) if sent: - await _mark_sent(db, key) + await _mark_sent(db, key, settings.user_id) async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) -> None: @@ -124,7 +127,7 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) for event in events: # Key includes user_id to prevent cross-user dedup collisions key = f"event:{settings.user_id}:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}" - if await _already_sent(db, key): + if await _already_sent(db, key, settings.user_id): continue payload = build_event_notification( @@ -142,7 +145,7 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) **payload, ) if sent: - await _mark_sent(db, key) + await _mark_sent(db, key, settings.user_id) async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: @@ -165,7 +168,7 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: for todo in todos: # Key includes user_id to prevent cross-user dedup collisions key = f"todo:{settings.user_id}:{todo.id}:{today}" - if await _already_sent(db, key): + if await _already_sent(db, key, settings.user_id): continue payload = build_todo_notification( @@ -181,7 +184,7 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: **payload, ) if sent: - await _mark_sent(db, key) + await _mark_sent(db, key, settings.user_id) async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> None: @@ -204,7 +207,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non for project in projects: # Key includes user_id to prevent cross-user dedup collisions key = f"project:{settings.user_id}:{project.id}:{today}" - if await _already_sent(db, key): + if await _already_sent(db, key, settings.user_id): continue payload = build_project_notification( @@ -219,7 +222,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non **payload, ) if sent: - await _mark_sent(db, key) + await _mark_sent(db, key, settings.user_id) async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime) -> None: diff --git a/backend/app/main.py b/backend/app/main.py index 624a61f..c546d5b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -53,7 +53,7 @@ app.add_middleware( allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()], allow_credentials=True, allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - allow_headers=["Content-Type", "Authorization", "Cookie"], + allow_headers=["Content-Type", "Authorization", "Cookie", "X-Requested-With"], ) # Include routers with /api prefix diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py index a16f8a6..1ebef1d 100644 --- a/backend/app/models/audit_log.py +++ b/backend/app/models/audit_log.py @@ -14,7 +14,7 @@ class AuditLog(Base): id: Mapped[int] = mapped_column(primary_key=True) actor_user_id: Mapped[Optional[int]] = mapped_column( - Integer, ForeignKey("users.id"), nullable=True, index=True + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True ) target_user_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 7c80b1a..1d1228d 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -176,7 +176,6 @@ async def create_user( await db.flush() # populate new_user.id await _create_user_defaults(db, new_user.id) - await db.commit() await log_audit_event( db, @@ -582,8 +581,7 @@ async def admin_dashboard( mfa_adoption = (totp_count / total_users) if total_users else 0.0 - # 10 most recent logins — join to get username - actor_alias = sa.alias(User.__table__, name="actor") + # 10 most recent logins recent_logins_result = await db.execute( sa.select(User.username, User.last_login_at) .where(User.last_login_at != None) @@ -595,20 +593,28 @@ async def admin_dashboard( for row in recent_logins_result ] - # 10 most recent audit entries + # 10 most recent audit entries — resolve usernames via JOINs + actor_user = sa.orm.aliased(User, name="actor_user") + target_user = sa.orm.aliased(User, name="target_user") recent_audit_result = await db.execute( - sa.select(AuditLog).order_by(AuditLog.created_at.desc()).limit(10) + sa.select( + AuditLog, + actor_user.username.label("actor_username"), + target_user.username.label("target_username"), + ) + .outerjoin(actor_user, AuditLog.actor_user_id == actor_user.id) + .outerjoin(target_user, AuditLog.target_user_id == target_user.id) + .order_by(AuditLog.created_at.desc()) + .limit(10) ) recent_audit_entries = [ { - "id": e.id, - "action": e.action, - "actor_user_id": e.actor_user_id, - "target_user_id": e.target_user_id, - "detail": e.detail, - "created_at": e.created_at, + "action": row.AuditLog.action, + "actor_username": row.actor_username, + "target_username": row.target_username, + "created_at": row.AuditLog.created_at, } - for e in recent_audit_result.scalars() + for row in recent_audit_result ] return AdminDashboardResponse( diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 5dbeac1..eda49c0 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -22,7 +22,7 @@ from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response, Cookie from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, func from app.database import get_db from app.models.user import User @@ -249,7 +249,6 @@ async def setup( await db.flush() await _create_user_defaults(db, new_user.id) - await db.commit() ip = request.client.host if request.client else "unknown" user_agent = request.headers.get("user-agent") @@ -376,7 +375,7 @@ async def register( select(User).where(User.username == data.username) ) if existing.scalar_one_or_none(): - raise HTTPException(status_code=400, detail="Registration failed") + raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.") password_hash = hash_password(data.password) # SEC-01: Explicit field assignment — never **data.model_dump() @@ -395,7 +394,6 @@ async def register( await db.flush() await _create_user_defaults(db, new_user.id) - await db.commit() ip = request.client.host if request.client else "unknown" user_agent = request.headers.get("user-agent") @@ -458,9 +456,10 @@ async def auth_status( """ Check authentication status, role, and whether initial setup/registration is available. """ - user_result = await db.execute(select(User)) - existing_user = user_result.scalar_one_or_none() - setup_required = existing_user is None + user_count_result = await db.execute( + select(func.count()).select_from(User) + ) + setup_required = user_count_result.scalar_one() == 0 authenticated = False role = None diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index 075a916..958a26c 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -64,14 +64,17 @@ class CreateUserRequest(BaseModel): class UpdateUserRoleRequest(BaseModel): + model_config = ConfigDict(extra="forbid") role: Literal["admin", "standard", "public_event_manager"] class ToggleActiveRequest(BaseModel): + model_config = ConfigDict(extra="forbid") is_active: bool class ToggleMfaEnforceRequest(BaseModel): + model_config = ConfigDict(extra="forbid") enforce: bool @@ -87,6 +90,7 @@ class SystemConfigResponse(BaseModel): class SystemConfigUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") allow_registration: Optional[bool] = None enforce_mfa_new_users: Optional[bool] = None diff --git a/frontend/src/components/admin/AdminDashboardPage.tsx b/frontend/src/components/admin/AdminDashboardPage.tsx index 33d95c4..7881759 100644 --- a/frontend/src/components/admin/AdminDashboardPage.tsx +++ b/frontend/src/components/admin/AdminDashboardPage.tsx @@ -135,9 +135,6 @@ export default function AdminDashboardPage() { When - - IP - @@ -153,9 +150,6 @@ export default function AdminDashboardPage() { {getRelativeTime(entry.last_login_at)} - - {entry.ip_address ?? '—'} - ))} diff --git a/frontend/src/components/admin/CreateUserDialog.tsx b/frontend/src/components/admin/CreateUserDialog.tsx index a44164e..e4e8023 100644 --- a/frontend/src/components/admin/CreateUserDialog.tsx +++ b/frontend/src/components/admin/CreateUserDialog.tsx @@ -75,11 +75,11 @@ export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialo type="password" value={password} onChange={(e) => setPassword(e.target.value)} - placeholder="Min. 8 characters" + placeholder="Min. 12 characters" required />

- Must be at least 8 characters. The user will be prompted to change it on first login. + Min. 12 characters with at least one letter and one non-letter. User must change on first login.

diff --git a/frontend/src/components/admin/UserActionsMenu.tsx b/frontend/src/components/admin/UserActionsMenu.tsx index 3163b2f..d6b4a7b 100644 --- a/frontend/src/components/admin/UserActionsMenu.tsx +++ b/frontend/src/components/admin/UserActionsMenu.tsx @@ -41,8 +41,7 @@ const ROLES: { value: UserRole; label: string }[] = [ export default function UserActionsMenu({ user }: UserActionsMenuProps) { const [open, setOpen] = useState(false); const [roleSubmenuOpen, setRoleSubmenuOpen] = useState(false); - const [showResetPassword, setShowResetPassword] = useState(false); - const [newPassword, setNewPassword] = useState(''); + const [tempPassword, setTempPassword] = useState(null); const menuRef = useRef(null); const updateRole = useUpdateRole(); @@ -162,50 +161,38 @@ export default function UserActionsMenu({ user }: UserActionsMenuProps) { {/* Reset Password */} - {!showResetPassword ? ( + {tempPassword ? ( +
+

Temporary password:

+ + {tempPassword} + + +
+ ) : ( - ) : ( -
- setNewPassword(e.target.value)} - autoFocus - /> -
- - -
-
)}
diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts index 3d9d484..5d73c33 100644 --- a/frontend/src/hooks/useAdmin.ts +++ b/frontend/src/hooks/useAdmin.ts @@ -1,7 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api, { getErrorMessage } from '@/lib/api'; import type { - AdminUser, AdminUserDetail, AdminDashboardData, SystemConfig, @@ -9,11 +8,14 @@ import type { UserRole, } from '@/types'; +interface UserListResponse { + users: AdminUserDetail[]; + total: number; +} + interface AuditLogResponse { entries: AuditLogEntry[]; total: number; - page: number; - per_page: number; } interface CreateUserPayload { @@ -27,9 +29,9 @@ interface UpdateRolePayload { role: UserRole; } -interface ResetPasswordPayload { - userId: number; - new_password: string; +interface ResetPasswordResult { + message: string; + temporary_password: string; } // ── Queries ────────────────────────────────────────────────────────────────── @@ -38,8 +40,8 @@ export function useAdminUsers() { return useQuery({ queryKey: ['admin', 'users'], queryFn: async () => { - const { data } = await api.get('/admin/users'); - return data; + const { data } = await api.get('/admin/users'); + return data.users; }, }); } @@ -84,12 +86,12 @@ export function useAuditLog( // ── Mutations ───────────────────────────────────────────────────────────────── -function useAdminMutation( - mutationFn: (vars: TVariables) => Promise, +function useAdminMutation( + mutationFn: (vars: TVariables) => Promise, onSuccess?: () => void ) { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin'] }); @@ -107,42 +109,42 @@ export function useCreateUser() { export function useUpdateRole() { return useAdminMutation(async ({ userId, role }: UpdateRolePayload) => { - const { data } = await api.patch(`/admin/users/${userId}/role`, { role }); + const { data } = await api.put(`/admin/users/${userId}/role`, { role }); return data; }); } export function useResetPassword() { - return useAdminMutation(async ({ userId, new_password }: ResetPasswordPayload) => { - const { data } = await api.post(`/admin/users/${userId}/reset-password`, { new_password }); + return useAdminMutation(async (userId: number) => { + const { data } = await api.post(`/admin/users/${userId}/reset-password`); return data; }); } export function useDisableMfa() { return useAdminMutation(async (userId: number) => { - const { data } = await api.delete(`/admin/users/${userId}/totp`); + const { data } = await api.post(`/admin/users/${userId}/disable-mfa`); return data; }); } export function useEnforceMfa() { return useAdminMutation(async (userId: number) => { - const { data } = await api.post(`/admin/users/${userId}/enforce-mfa`); + const { data } = await api.put(`/admin/users/${userId}/enforce-mfa`, { enforce: true }); return data; }); } export function useRemoveMfaEnforcement() { return useAdminMutation(async (userId: number) => { - const { data } = await api.delete(`/admin/users/${userId}/enforce-mfa`); + const { data } = await api.put(`/admin/users/${userId}/enforce-mfa`, { enforce: false }); return data; }); } export function useToggleUserActive() { return useAdminMutation(async ({ userId, active }: { userId: number; active: boolean }) => { - const { data } = await api.patch(`/admin/users/${userId}/active`, { is_active: active }); + const { data } = await api.put(`/admin/users/${userId}/active`, { is_active: active }); return data; }); } @@ -156,7 +158,7 @@ export function useRevokeSessions() { export function useUpdateConfig() { return useAdminMutation(async (config: Partial) => { - const { data } = await api.patch('/admin/config', config); + const { data } = await api.put('/admin/config', config); return data; }); } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0cb34c4..542e3d4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -258,7 +258,6 @@ export interface AdminDashboardData { recent_logins: Array<{ username: string; last_login_at: string; - ip_address: string; }>; recent_audit_entries: Array<{ action: string; From cbf4663e8dcb190253a5e261134924818def7ab7 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 27 Feb 2026 04:42:23 +0800 Subject: [PATCH 05/31] Fix TS build errors and apply remaining QA fixes Remove unused imports (UserCheck, Loader2, ShieldOff) and replace non-existent SmartphoneOff icon with Smartphone in admin components. Includes backend query fixes, performance indexes migration, and admin page shared utilities extraction. Co-Authored-By: Claude Opus 4.6 --- .../versions/035_add_performance_indexes.py | 64 +++++++++++++++++++ backend/app/jobs/notifications.py | 41 ++++++------ backend/app/routers/dashboard.py | 7 +- backend/app/routers/events.py | 4 +- backend/app/routers/todos.py | 16 ++++- .../components/admin/AdminDashboardPage.tsx | 37 +---------- frontend/src/components/admin/ConfigPage.tsx | 14 +--- frontend/src/components/admin/IAMPage.tsx | 28 +------- .../src/components/admin/UserActionsMenu.tsx | 6 +- frontend/src/components/admin/shared.tsx | 42 ++++++++++++ 10 files changed, 154 insertions(+), 105 deletions(-) create mode 100644 backend/alembic/versions/035_add_performance_indexes.py create mode 100644 frontend/src/components/admin/shared.tsx diff --git a/backend/alembic/versions/035_add_performance_indexes.py b/backend/alembic/versions/035_add_performance_indexes.py new file mode 100644 index 0000000..c7fb34b --- /dev/null +++ b/backend/alembic/versions/035_add_performance_indexes.py @@ -0,0 +1,64 @@ +"""Add performance indexes for hot query paths. + +Covers: +- calendar_events range queries scoped by calendar (dashboard, notifications) +- calendar_events starred query (dashboard widget) +- calendar_events parent_event_id (recurring series DELETE) +- user_sessions lookup (auth middleware, every request) +- ntfy_sent purge query (background job, every 60s) + +Revision ID: 035 +Revises: 034 +Create Date: 2026-02-27 +""" +from alembic import op + +revision = "035" +down_revision = "034" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Composite index for event range queries scoped by calendar + op.create_index( + "ix_calendar_events_calendar_start_end", + "calendar_events", + ["calendar_id", "start_datetime", "end_datetime"], + ) + + # Partial index for starred events dashboard query + op.create_index( + "ix_calendar_events_calendar_starred", + "calendar_events", + ["calendar_id", "is_starred"], + ) + + # FK lookup index for recurring children DELETE + op.create_index( + "ix_calendar_events_parent_id", + "calendar_events", + ["parent_event_id"], + ) + + # Composite index for session validation (runs on every authenticated request) + op.create_index( + "ix_user_sessions_lookup", + "user_sessions", + ["user_id", "revoked", "expires_at"], + ) + + # Index for ntfy_sent purge query (DELETE WHERE sent_at < cutoff) + op.create_index( + "ix_ntfy_sent_sent_at", + "ntfy_sent", + ["sent_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_ntfy_sent_sent_at", table_name="ntfy_sent") + op.drop_index("ix_user_sessions_lookup", table_name="user_sessions") + op.drop_index("ix_calendar_events_parent_id", table_name="calendar_events") + op.drop_index("ix_calendar_events_calendar_starred", table_name="calendar_events") + op.drop_index("ix_calendar_events_calendar_start_end", table_name="calendar_events") diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index 595191a..045a4ab 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -40,14 +40,12 @@ UMBRA_URL = "http://10.0.69.35" # ── Dedup helpers ───────────────────────────────────────────────────────────── -async def _already_sent(db: AsyncSession, key: str, user_id: int) -> bool: +async def _get_sent_keys(db: AsyncSession, user_id: int) -> set[str]: + """Batch-fetch all notification keys for a user in one query.""" result = await db.execute( - select(NtfySent).where( - NtfySent.user_id == user_id, - NtfySent.notification_key == key, - ) + select(NtfySent.notification_key).where(NtfySent.user_id == user_id) ) - return result.scalar_one_or_none() is not None + return set(result.scalars().all()) async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None: @@ -57,7 +55,7 @@ async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None: # ── Dispatch functions ──────────────────────────────────────────────────────── -async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetime) -> None: +async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetime, sent_keys: set[str]) -> None: """Send notifications for reminders that are currently due and not dismissed/snoozed.""" # Mirror the filter from /api/reminders/due, scoped to this user result = await db.execute( @@ -79,7 +77,7 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim # Key includes user_id to prevent cross-user dedup collisions key = f"reminder:{settings.user_id}:{reminder.id}:{reminder.remind_at.date()}" - if await _already_sent(db, key, settings.user_id): + if key in sent_keys: continue payload = build_reminder_notification( @@ -95,9 +93,10 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim ) if sent: await _mark_sent(db, key, settings.user_id) + sent_keys.add(key) -async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) -> None: +async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime, sent_keys: set[str]) -> None: """Send notifications for calendar events within the configured lead time window.""" lead_minutes = settings.ntfy_event_lead_minutes # Window: events starting between now and (now + lead_minutes) @@ -127,7 +126,7 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) for event in events: # Key includes user_id to prevent cross-user dedup collisions key = f"event:{settings.user_id}:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}" - if await _already_sent(db, key, settings.user_id): + if key in sent_keys: continue payload = build_event_notification( @@ -146,9 +145,10 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) ) if sent: await _mark_sent(db, key, settings.user_id) + sent_keys.add(key) -async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: +async def _dispatch_todos(db: AsyncSession, settings: Settings, today, sent_keys: set[str]) -> None: """Send notifications for incomplete todos due within the configured lead days.""" lead_days = settings.ntfy_todo_lead_days cutoff = today + timedelta(days=lead_days) @@ -168,7 +168,7 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: for todo in todos: # Key includes user_id to prevent cross-user dedup collisions key = f"todo:{settings.user_id}:{todo.id}:{today}" - if await _already_sent(db, key, settings.user_id): + if key in sent_keys: continue payload = build_todo_notification( @@ -185,9 +185,10 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: ) if sent: await _mark_sent(db, key, settings.user_id) + sent_keys.add(key) -async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> None: +async def _dispatch_projects(db: AsyncSession, settings: Settings, today, sent_keys: set[str]) -> None: """Send notifications for projects with deadlines within the configured lead days.""" lead_days = settings.ntfy_project_lead_days cutoff = today + timedelta(days=lead_days) @@ -207,7 +208,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non for project in projects: # Key includes user_id to prevent cross-user dedup collisions key = f"project:{settings.user_id}:{project.id}:{today}" - if await _already_sent(db, key, settings.user_id): + if key in sent_keys: continue payload = build_project_notification( @@ -223,18 +224,22 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non ) if sent: await _mark_sent(db, key, settings.user_id) + sent_keys.add(key) async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime) -> None: """Run all notification dispatches for a single user's settings.""" + # Batch-fetch all sent keys once per user instead of one query per entity + sent_keys = await _get_sent_keys(db, settings.user_id) + if settings.ntfy_reminders_enabled: - await _dispatch_reminders(db, settings, now) + await _dispatch_reminders(db, settings, now, sent_keys) if settings.ntfy_events_enabled: - await _dispatch_events(db, settings, now) + await _dispatch_events(db, settings, now, sent_keys) if settings.ntfy_todos_enabled: - await _dispatch_todos(db, settings, now.date()) + await _dispatch_todos(db, settings, now.date(), sent_keys) if settings.ntfy_projects_enabled: - await _dispatch_projects(db, settings, now.date()) + await _dispatch_projects(db, settings, now.date(), sent_keys) async def _purge_old_sent_records(db: AsyncSession) -> None: diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index c3b3bbc..18190db 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -26,7 +26,7 @@ _not_parent_template = or_( @router.get("/dashboard") async def get_dashboard( - client_date: Optional[date] = Query(None), + client_date: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), current_settings: Settings = Depends(get_current_settings), @@ -94,11 +94,10 @@ async def get_dashboard( total_incomplete_todos = total_incomplete_result.scalar() # Starred events (upcoming, ordered by date, scoped to user's calendars) - now = datetime.now() starred_query = select(CalendarEvent).where( CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.is_starred == True, - CalendarEvent.start_datetime > now, + CalendarEvent.start_datetime > today_start, _not_parent_template, ).order_by(CalendarEvent.start_datetime.asc()).limit(5) starred_result = await db.execute(starred_query) @@ -156,7 +155,7 @@ async def get_dashboard( @router.get("/upcoming") async def get_upcoming( days: int = Query(default=7, ge=1, le=90), - client_date: Optional[date] = Query(None), + client_date: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), current_settings: Settings = Depends(get_current_settings), diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index 3dc6e1a..e36c198 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -130,8 +130,8 @@ async def _verify_calendar_ownership(db: AsyncSession, calendar_id: int, user_id @router.get("/", response_model=None) async def get_events( - start: Optional[date] = Query(None), - end: Optional[date] = Query(None), + start: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)), + end: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ) -> List[Any]: diff --git a/backend/app/routers/todos.py b/backend/app/routers/todos.py index 86cdf65..d5e1d57 100644 --- a/backend/app/routers/todos.py +++ b/backend/app/routers/todos.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_ +from sqlalchemy import select, and_, func from typing import Optional, List from datetime import datetime, date, timedelta import calendar @@ -81,6 +81,20 @@ async def _reactivate_recurring_todos(db: AsyncSession, user_id: int) -> None: Scoped to a single user to avoid cross-user reactivation. """ now = datetime.now() + + # Fast-path: skip the FOR UPDATE lock when nothing needs reactivation (common case) + count = await db.scalar( + select(func.count()).select_from(Todo).where( + Todo.user_id == user_id, + Todo.completed == True, # noqa: E712 + Todo.recurrence_rule.isnot(None), + Todo.reset_at.isnot(None), + Todo.reset_at <= now, + ) + ) + if count == 0: + return + query = select(Todo).where( and_( Todo.user_id == user_id, diff --git a/frontend/src/components/admin/AdminDashboardPage.tsx b/frontend/src/components/admin/AdminDashboardPage.tsx index 7881759..0b5cd04 100644 --- a/frontend/src/components/admin/AdminDashboardPage.tsx +++ b/frontend/src/components/admin/AdminDashboardPage.tsx @@ -12,42 +12,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { useAdminDashboard, useAuditLog } from '@/hooks/useAdmin'; import { getRelativeTime } from '@/lib/date-utils'; import { cn } from '@/lib/utils'; - -interface StatCardProps { - icon: React.ReactNode; - label: string; - value: string | number; - iconBg?: string; -} - -function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) { - return ( - - -
-
{icon}
-
-

{label}

-

{value}

-
-
-
-
- ); -} - -function actionColor(action: string): string { - if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) { - return 'bg-red-500/15 text-red-400'; - } - if (action.includes('login') || action.includes('create') || action.includes('enabled')) { - return 'bg-green-500/15 text-green-400'; - } - if (action.includes('config') || action.includes('role') || action.includes('password')) { - return 'bg-orange-500/15 text-orange-400'; - } - return 'bg-blue-500/15 text-blue-400'; -} +import { StatCard, actionColor } from './shared'; export default function AdminDashboardPage() { const { data: dashboard, isLoading } = useAdminDashboard(); diff --git a/frontend/src/components/admin/ConfigPage.tsx b/frontend/src/components/admin/ConfigPage.tsx index 8a9ce15..0c1460b 100644 --- a/frontend/src/components/admin/ConfigPage.tsx +++ b/frontend/src/components/admin/ConfigPage.tsx @@ -13,6 +13,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { useAuditLog } from '@/hooks/useAdmin'; import { getRelativeTime } from '@/lib/date-utils'; import { cn } from '@/lib/utils'; +import { actionColor } from './shared'; const ACTION_TYPES = [ 'user.create', @@ -39,19 +40,6 @@ function actionLabel(action: string): string { .join(' — '); } -function actionColor(action: string): string { - if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) { - return 'bg-red-500/15 text-red-400'; - } - if (action.includes('login') || action.includes('create') || action.includes('enabled')) { - return 'bg-green-500/15 text-green-400'; - } - if (action.includes('config') || action.includes('role') || action.includes('password')) { - return 'bg-orange-500/15 text-orange-400'; - } - return 'bg-blue-500/15 text-blue-400'; -} - export default function ConfigPage() { const [page, setPage] = useState(1); const [filterAction, setFilterAction] = useState(''); diff --git a/frontend/src/components/admin/IAMPage.tsx b/frontend/src/components/admin/IAMPage.tsx index 64329b9..f7fc357 100644 --- a/frontend/src/components/admin/IAMPage.tsx +++ b/frontend/src/components/admin/IAMPage.tsx @@ -2,11 +2,9 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { Users, - UserCheck, ShieldCheck, Smartphone, Plus, - Loader2, Activity, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -14,6 +12,7 @@ import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; +import { StatCard } from './shared'; import { useAdminUsers, useAdminDashboard, @@ -52,31 +51,6 @@ function RoleBadge({ role }: { role: UserRole }) { ); } -// ── Stat card ───────────────────────────────────────────────────────────────── - -interface StatCardProps { - icon: React.ReactNode; - label: string; - value: string | number; - iconBg?: string; -} - -function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) { - return ( - - -
-
{icon}
-
-

{label}

-

{value}

-
-
-
-
- ); -} - // ── Main page ───────────────────────────────────────────────────────────────── export default function IAMPage() { diff --git a/frontend/src/components/admin/UserActionsMenu.tsx b/frontend/src/components/admin/UserActionsMenu.tsx index d6b4a7b..8c664a6 100644 --- a/frontend/src/components/admin/UserActionsMenu.tsx +++ b/frontend/src/components/admin/UserActionsMenu.tsx @@ -3,13 +3,11 @@ import { toast } from 'sonner'; import { MoreHorizontal, ShieldCheck, - ShieldOff, KeyRound, UserX, UserCheck, LogOut, Smartphone, - SmartphoneOff, ChevronRight, Loader2, } from 'lucide-react'; @@ -208,7 +206,7 @@ export default function UserActionsMenu({ user }: UserActionsMenuProps) { ) } > - + Remove MFA Enforcement ) : ( @@ -233,7 +231,7 @@ export default function UserActionsMenu({ user }: UserActionsMenuProps) { )} onClick={disableMfaConfirm.handleClick} > - + {disableMfaConfirm.confirming ? 'Sure? Click to confirm' : 'Disable MFA'} )} diff --git a/frontend/src/components/admin/shared.tsx b/frontend/src/components/admin/shared.tsx new file mode 100644 index 0000000..0592f87 --- /dev/null +++ b/frontend/src/components/admin/shared.tsx @@ -0,0 +1,42 @@ +import { Card, CardContent } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; + +// ── StatCard ───────────────────────────────────────────────────────────────── + +interface StatCardProps { + icon: React.ReactNode; + label: string; + value: string | number; + iconBg?: string; +} + +export function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) { + return ( + + +
+
{icon}
+
+

{label}

+

{value}

+
+
+
+
+ ); +} + +// ── actionColor ────────────────────────────────────────────────────────────── + +export function actionColor(action: string): string { + if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) { + return 'bg-red-500/15 text-red-400'; + } + if (action.includes('login') || action.includes('create') || action.includes('enabled')) { + return 'bg-green-500/15 text-green-400'; + } + if (action.includes('config') || action.includes('role') || action.includes('password')) { + return 'bg-orange-500/15 text-orange-400'; + } + return 'bg-blue-500/15 text-blue-400'; +} From 72ac1d53fb0fb27533cd6323df4777e7a0d70cf5 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 27 Feb 2026 04:49:57 +0800 Subject: [PATCH 06/31] Fix migration 030 failing on fresh DB with no admin user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 006 seeds default calendar rows. On a fresh install, no users exist when migration 030 runs, so the backfill SELECT returns NULL and SET NOT NULL fails. Now deletes orphan calendars before enforcing the constraint — account setup will recreate defaults for new users. Co-Authored-By: Claude Opus 4.6 --- backend/alembic/versions/030_add_user_id_to_calendars.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/alembic/versions/030_add_user_id_to_calendars.py b/backend/alembic/versions/030_add_user_id_to_calendars.py index ef1b621..23aafe1 100644 --- a/backend/alembic/versions/030_add_user_id_to_calendars.py +++ b/backend/alembic/versions/030_add_user_id_to_calendars.py @@ -15,11 +15,15 @@ depends_on = None def upgrade() -> None: op.add_column("calendars", sa.Column("user_id", sa.Integer(), nullable=True)) + # Backfill existing calendars to first admin user op.execute( "UPDATE calendars SET user_id = (" " SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1" ")" ) + # On fresh installs no users exist yet, so seeded calendars still have + # NULL user_id. Remove them — account setup will recreate defaults. + op.execute("DELETE FROM calendars WHERE user_id IS NULL") op.create_foreign_key( "fk_calendars_user_id", "calendars", "users", ["user_id"], ["id"], ondelete="CASCADE" From 72e00f3a699e7979d9994f7dd6c5db4b6cd0120f Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 27 Feb 2026 04:59:29 +0800 Subject: [PATCH 07/31] Fix QA review #2: backup code flow, audit filters, schema hardening C-01: verifyTotp now sends backup_code field when in backup mode C-02: Backup code input filter allows alphanumeric chars (not digits only) W-01: Audit log ACTION_TYPES aligned with actual backend action strings W-02: Added extra="forbid" to SetupRequest and LoginRequest schemas Co-Authored-By: Claude Opus 4.6 --- backend/app/schemas/auth.py | 4 +++ frontend/src/components/admin/ConfigPage.tsx | 29 ++++++++++---------- frontend/src/components/auth/LockScreen.tsx | 4 +-- frontend/src/hooks/useAuth.ts | 13 +++++---- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index a45dba3..41de744 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -32,6 +32,8 @@ def _validate_username(v: str) -> str: class SetupRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + username: str password: str @@ -69,6 +71,8 @@ class RegisterRequest(BaseModel): class LoginRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + username: str password: str diff --git a/frontend/src/components/admin/ConfigPage.tsx b/frontend/src/components/admin/ConfigPage.tsx index 0c1460b..afd00be 100644 --- a/frontend/src/components/admin/ConfigPage.tsx +++ b/frontend/src/components/admin/ConfigPage.tsx @@ -16,21 +16,20 @@ import { cn } from '@/lib/utils'; import { actionColor } from './shared'; const ACTION_TYPES = [ - 'user.create', - 'user.login', - 'user.logout', - 'user.login_failed', - 'user.locked', - 'user.unlocked', - 'user.role_changed', - 'user.disabled', - 'user.enabled', - 'user.password_reset', - 'user.totp_disabled', - 'user.mfa_enforced', - 'user.mfa_enforcement_removed', - 'user.sessions_revoked', - 'config.updated', + 'admin.user_created', + 'admin.role_changed', + 'admin.password_reset', + 'admin.mfa_disabled', + 'admin.mfa_enforce_toggled', + 'admin.user_deactivated', + 'admin.user_activated', + 'admin.sessions_revoked', + 'admin.config_updated', + 'auth.login_success', + 'auth.login_failed', + 'auth.setup_complete', + 'auth.registration', + 'auth.mfa_enforce_prompted', ]; function actionLabel(action: string): string { diff --git a/frontend/src/components/auth/LockScreen.tsx b/frontend/src/components/auth/LockScreen.tsx index 1885512..12aaa46 100644 --- a/frontend/src/components/auth/LockScreen.tsx +++ b/frontend/src/components/auth/LockScreen.tsx @@ -145,7 +145,7 @@ export default function LockScreen() { const handleTotpSubmit = async (e: FormEvent) => { e.preventDefault(); try { - await verifyTotp(totpCode); + await verifyTotp({ code: totpCode, isBackup: useBackupCode }); } catch (error) { toast.error(getErrorMessage(error, 'Invalid verification code')); setTotpCode(''); @@ -257,7 +257,7 @@ export default function LockScreen() { onChange={(e) => setTotpCode( useBackupCode - ? e.target.value.replace(/[^0-9-]/g, '') + ? e.target.value.replace(/[^A-Za-z0-9-]/g, '').toUpperCase() : e.target.value.replace(/\D/g, '') ) } diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 239fe2f..098d268 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -57,11 +57,14 @@ export function useAuth() { }); const totpVerifyMutation = useMutation({ - mutationFn: async (code: string) => { - const { data } = await api.post('/auth/totp-verify', { - mfa_token: mfaToken, - code, - }); + mutationFn: async ({ code, isBackup }: { code: string; isBackup: boolean }) => { + const payload: Record = { mfa_token: mfaToken! }; + if (isBackup) { + payload.backup_code = code; + } else { + payload.code = code; + } + const { data } = await api.post('/auth/totp-verify', payload); return data; }, onSuccess: () => { From 619e2206220e55d6316c76f6323b8820f28c4873 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 27 Feb 2026 05:41:16 +0800 Subject: [PATCH 08/31] Fix QA review #2: W-03/W-04, S-01 through S-04 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W-03: Unify split transactions — _create_db_session() now uses flush() instead of commit(), callers own the final commit. W-04: Time-bound dedup key fetch to 7-day purge window. S-01: Type admin dashboard response with RecentLoginItem/RecentAuditItem. S-02: Convert starred events index to partial index WHERE is_starred = true. S-03: EventTemplate.created_at default changed to func.now() for consistency. S-04: Add single-worker scaling note to weather cache. Co-Authored-By: Claude Opus 4.6 --- .../versions/035_add_performance_indexes.py | 6 ++++-- backend/app/jobs/notifications.py | 8 ++++++-- backend/app/models/event_template.py | 2 +- backend/app/routers/auth.py | 4 +++- backend/app/routers/weather.py | 2 ++ backend/app/schemas/admin.py | 20 +++++++++++++++++-- 6 files changed, 34 insertions(+), 8 deletions(-) diff --git a/backend/alembic/versions/035_add_performance_indexes.py b/backend/alembic/versions/035_add_performance_indexes.py index c7fb34b..e36baae 100644 --- a/backend/alembic/versions/035_add_performance_indexes.py +++ b/backend/alembic/versions/035_add_performance_indexes.py @@ -27,11 +27,13 @@ def upgrade() -> None: ["calendar_id", "start_datetime", "end_datetime"], ) - # Partial index for starred events dashboard query + # Partial index for starred events dashboard query — only rows where + # is_starred = true are ever queried, so a partial index is smaller and faster. op.create_index( "ix_calendar_events_calendar_starred", "calendar_events", - ["calendar_id", "is_starred"], + ["calendar_id", "start_datetime"], + postgresql_where="is_starred = true", ) # FK lookup index for recurring children DELETE diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index 045a4ab..cc97c35 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -41,9 +41,13 @@ UMBRA_URL = "http://10.0.69.35" # ── Dedup helpers ───────────────────────────────────────────────────────────── async def _get_sent_keys(db: AsyncSession, user_id: int) -> set[str]: - """Batch-fetch all notification keys for a user in one query.""" + """Batch-fetch recent notification keys for a user (within the 7-day purge window).""" + cutoff = datetime.now() - timedelta(days=7) result = await db.execute( - select(NtfySent.notification_key).where(NtfySent.user_id == user_id) + select(NtfySent.notification_key).where( + NtfySent.user_id == user_id, + NtfySent.sent_at >= cutoff, + ) ) return set(result.scalars().all()) diff --git a/backend/app/models/event_template.py b/backend/app/models/event_template.py index 78f8a2d..d4cb662 100644 --- a/backend/app/models/event_template.py +++ b/backend/app/models/event_template.py @@ -24,4 +24,4 @@ class EventTemplate(Base): Integer, ForeignKey("locations.id", ondelete="SET NULL"), nullable=True ) is_starred: Mapped[bool] = mapped_column(Boolean, default=False) - created_at: Mapped[datetime] = mapped_column(default=datetime.now, server_default=func.now()) + created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index eda49c0..69038f2 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -195,7 +195,7 @@ async def _create_db_session( user_agent=(user_agent or "")[:255] if user_agent else None, ) db.add(db_session) - await db.commit() + await db.flush() token = create_session_token(user.id, session_id) return session_id, token @@ -335,6 +335,7 @@ async def login( user_agent = request.headers.get("user-agent") _, token = await _create_db_session(db, user, client_ip, user_agent) _set_session_cookie(response, token) + await db.commit() return { "authenticated": True, "must_change_password": True, @@ -415,6 +416,7 @@ async def register( _, token = await _create_db_session(db, new_user, ip, user_agent) _set_session_cookie(response, token) + await db.commit() return {"message": "Registration successful", "authenticated": True} diff --git a/backend/app/routers/weather.py b/backend/app/routers/weather.py index bed8724..8568b37 100644 --- a/backend/app/routers/weather.py +++ b/backend/app/routers/weather.py @@ -21,6 +21,8 @@ router = APIRouter() # SEC-15: Bounded LRU cache keyed by (user_id, location) — max 100 entries. # OrderedDict preserves insertion order; move_to_end on hit, popitem(last=False) # to evict the oldest when capacity is exceeded. +# NOTE: This cache is process-local. With multiple workers each process would +# maintain its own copy, wasting API quota. Currently safe — single Uvicorn worker. _CACHE_MAX = 100 _cache: OrderedDict = OrderedDict() diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index 958a26c..7688bc5 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -99,14 +99,30 @@ class SystemConfigUpdate(BaseModel): # Admin dashboard # --------------------------------------------------------------------------- +class RecentLoginItem(BaseModel): + username: str + last_login_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class RecentAuditItem(BaseModel): + action: str + actor_username: Optional[str] = None + target_username: Optional[str] = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + class AdminDashboardResponse(BaseModel): total_users: int active_users: int admin_count: int active_sessions: int mfa_adoption_rate: float - recent_logins: list[dict] - recent_audit_entries: list[dict] + recent_logins: list[RecentLoginItem] + recent_audit_entries: list[RecentAuditItem] # --------------------------------------------------------------------------- From 2438cdcf25d9c3bb83646080eb47c22cf18f2981 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 27 Feb 2026 06:06:13 +0800 Subject: [PATCH 09/31] Fix migration 034 failing on fresh DB: drop index not constraint Migration 022 created a unique INDEX (ix_ntfy_sent_notification_key), not a named unique CONSTRAINT. Migration 034 was trying to drop a constraint name that only existed on upgraded DBs. Fixed to drop the index instead, which works on both fresh and upgrade paths. Co-Authored-By: Claude Opus 4.6 --- .../alembic/versions/034_add_user_id_to_ntfy_sent.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/alembic/versions/034_add_user_id_to_ntfy_sent.py b/backend/alembic/versions/034_add_user_id_to_ntfy_sent.py index 81b3a3d..e40e66d 100644 --- a/backend/alembic/versions/034_add_user_id_to_ntfy_sent.py +++ b/backend/alembic/versions/034_add_user_id_to_ntfy_sent.py @@ -24,10 +24,14 @@ def upgrade() -> None: "fk_ntfy_sent_user_id", "ntfy_sent", "users", ["user_id"], ["id"], ondelete="CASCADE" ) + # On fresh DB ntfy_sent may be empty — clean up NULLs just in case + op.execute("DELETE FROM ntfy_sent WHERE user_id IS NULL") op.alter_column("ntfy_sent", "user_id", nullable=False) - # Drop old unique constraint on notification_key alone - op.drop_constraint("ntfy_sent_notification_key_key", "ntfy_sent", type_="unique") + # Migration 022 created a unique INDEX (ix_ntfy_sent_notification_key), not a + # named unique CONSTRAINT. Drop the index; the new composite unique constraint + # below replaces it. + op.drop_index("ix_ntfy_sent_notification_key", table_name="ntfy_sent") # Create composite unique constraint (per-user dedup) op.create_unique_constraint( @@ -39,8 +43,8 @@ def upgrade() -> None: def downgrade() -> None: op.drop_index("ix_ntfy_sent_user_id", table_name="ntfy_sent") op.drop_constraint("uq_ntfy_sent_user_key", "ntfy_sent", type_="unique") - op.create_unique_constraint( - "ntfy_sent_notification_key_key", "ntfy_sent", ["notification_key"] + op.create_index( + "ix_ntfy_sent_notification_key", "ntfy_sent", ["notification_key"], unique=True ) op.drop_constraint("fk_ntfy_sent_user_id", "ntfy_sent", type_="foreignkey") op.drop_column("ntfy_sent", "user_id") From 4fc85684eaae5d06619f3c73e1dfa807589c47b8 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 27 Feb 2026 06:23:08 +0800 Subject: [PATCH 10/31] Fix IAM user actions dropdown clipped by overflow-x-auto The table wrapper's overflow-x-auto forced overflow-y to also clip, hiding the 3-dot actions dropdown below the container boundary. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/admin/IAMPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/admin/IAMPage.tsx b/frontend/src/components/admin/IAMPage.tsx index f7fc357..aed1a85 100644 --- a/frontend/src/components/admin/IAMPage.tsx +++ b/frontend/src/components/admin/IAMPage.tsx @@ -127,7 +127,7 @@ export default function IAMPage() { ) : !users?.length ? (

No users found.

) : ( -
+
From e860723a2a223b09e4d10ed25e6f0324688af878 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 27 Feb 2026 06:35:03 +0800 Subject: [PATCH 11/31] Fix Edit Role submenu overflowing right edge of viewport Submenu was positioned left-full (opening rightward) but the parent dropdown is already at the right edge. Changed to right-full so it opens leftward into available space. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/admin/UserActionsMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/admin/UserActionsMenu.tsx b/frontend/src/components/admin/UserActionsMenu.tsx index 8c664a6..edc65f9 100644 --- a/frontend/src/components/admin/UserActionsMenu.tsx +++ b/frontend/src/components/admin/UserActionsMenu.tsx @@ -132,7 +132,7 @@ export default function UserActionsMenu({ user }: UserActionsMenuProps) { {roleSubmenuOpen && (
setRoleSubmenuOpen(true)} onMouseLeave={() => setRoleSubmenuOpen(false)} > From 0fc3f1a14b6a452de8c3f24f6412e17e02200411 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 27 Feb 2026 08:03:25 +0800 Subject: [PATCH 12/31] Allow dots in usernames (e.g. user.test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added . to the username character whitelist regex. No security reason to exclude it — dots are standard in usernames. Co-Authored-By: Claude Opus 4.6 --- backend/app/schemas/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 41de744..ccfcf74 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -26,8 +26,8 @@ def _validate_username(v: str) -> str: v = v.strip().lower() if not 3 <= len(v) <= 50: raise ValueError("Username must be 3–50 characters") - if not re.fullmatch(r"[a-z0-9_\-]+", v): - raise ValueError("Username may only contain letters, numbers, _ and -") + if not re.fullmatch(r"[a-z0-9_.\-]+", v): + raise ValueError("Username may only contain letters, numbers, _ . and -") return v From f07ce02576d9b14937d3d3eb188f74fb866dae22 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 27 Feb 2026 08:35:31 +0800 Subject: [PATCH 13/31] Fix crash when creating new todo/reminder/event (null.priority) All three DetailPanel components initialized isEditing=false even when isCreating=true. The useEffect that flips it to true runs AFTER the first render, so the view-mode branch executes with todo=null, crashing on null.priority. Initialize isEditing from isCreating. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/calendar/EventDetailPanel.tsx | 2 +- frontend/src/components/reminders/ReminderDetailPanel.tsx | 2 +- frontend/src/components/todos/TodoDetailPanel.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index 863cffb..de40dc1 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -232,7 +232,7 @@ export default function EventDetailPanel({ staleTime: 5 * 60 * 1000, }); - const [isEditing, setIsEditing] = useState(false); + const [isEditing, setIsEditing] = useState(isCreating); const [editState, setEditState] = useState(() => isCreating ? buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || '') diff --git a/frontend/src/components/reminders/ReminderDetailPanel.tsx b/frontend/src/components/reminders/ReminderDetailPanel.tsx index 0a101e8..79be3d5 100644 --- a/frontend/src/components/reminders/ReminderDetailPanel.tsx +++ b/frontend/src/components/reminders/ReminderDetailPanel.tsx @@ -70,7 +70,7 @@ export default function ReminderDetailPanel({ }: ReminderDetailPanelProps) { const queryClient = useQueryClient(); - const [isEditing, setIsEditing] = useState(false); + const [isEditing, setIsEditing] = useState(isCreating); const [editState, setEditState] = useState(() => isCreating ? buildCreateState() : reminder ? buildEditState(reminder) : buildCreateState() ); diff --git a/frontend/src/components/todos/TodoDetailPanel.tsx b/frontend/src/components/todos/TodoDetailPanel.tsx index fd9a03a..ee34ef6 100644 --- a/frontend/src/components/todos/TodoDetailPanel.tsx +++ b/frontend/src/components/todos/TodoDetailPanel.tsx @@ -95,7 +95,7 @@ export default function TodoDetailPanel({ }: TodoDetailPanelProps) { const queryClient = useQueryClient(); - const [isEditing, setIsEditing] = useState(false); + const [isEditing, setIsEditing] = useState(isCreating); const [editState, setEditState] = useState(() => isCreating ? buildCreateState(createDefaults) : todo ? buildEditState(todo) : buildCreateState() ); From a128005ae580272f7f9ef16620c3109bd6e1a28b Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 27 Feb 2026 09:57:19 +0800 Subject: [PATCH 14/31] Fix create-mode crash in detail panels (null entity access) The desktop detail panels are pre-mounted (always in DOM, hidden with w-0). useState(isCreating) only captures the initial value on mount (false), so when isCreating later becomes true via props, isEditing stays false. The view-mode branch then runs with a null entity, crashing on property access. Fix: use (isEditing || isCreating) for all conditionals that gate between edit/create form and view mode, ensuring the form always renders when isCreating is true regardless of isEditing state. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/calendar/EventDetailPanel.tsx | 4 ++-- frontend/src/components/reminders/ReminderDetailPanel.tsx | 4 ++-- frontend/src/components/todos/TodoDetailPanel.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index de40dc1..3c0c4e4 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -480,7 +480,7 @@ export default function EventDetailPanel({ > - ) : isEditing ? ( + ) : (isEditing || isCreating) ? ( <>
- ) : isEditing ? ( + ) : (isEditing || isCreating) ? ( /* Edit / Create mode */
{/* Title (only shown in body for create mode; edit mode has it in header) */} diff --git a/frontend/src/components/reminders/ReminderDetailPanel.tsx b/frontend/src/components/reminders/ReminderDetailPanel.tsx index 79be3d5..1d60e03 100644 --- a/frontend/src/components/reminders/ReminderDetailPanel.tsx +++ b/frontend/src/components/reminders/ReminderDetailPanel.tsx @@ -224,7 +224,7 @@ export default function ReminderDetailPanel({ )}
- {isEditing ? ( + {(isEditing || isCreating) ? ( <> + +
+ + {/* Delete User — destructive, red two-click confirm */} +
)}
diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts index 5d73c33..58d2521 100644 --- a/frontend/src/hooks/useAdmin.ts +++ b/frontend/src/hooks/useAdmin.ts @@ -156,6 +156,13 @@ export function useRevokeSessions() { }); } +export function useDeleteUser() { + return useAdminMutation(async (userId: number) => { + const { data } = await api.delete(`/admin/users/${userId}`); + return data; + }); +} + export function useUpdateConfig() { return useAdminMutation(async (config: Partial) => { const { data } = await api.put('/admin/config', config); From 48e15fa6774f5b5ee16286c0f35736a5432f75a1 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 27 Feb 2026 19:30:43 +0800 Subject: [PATCH 22/31] UX polish for delete-user: username toast, hide self-delete S-03: Delete toast now shows the deleted username from the API response S-04: Delete button hidden for the current admin's own row (backend still guards with 403, but no reason to show a dead button) Adds username to auth status response so the frontend can identify the current user. Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/auth.py | 1 + frontend/src/components/admin/IAMPage.tsx | 4 +++- .../src/components/admin/UserActionsMenu.tsx | 19 +++++++++++++++---- frontend/src/types/index.ts | 1 + 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 16e8ed5..d9cd550 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -531,6 +531,7 @@ async def auth_status( "authenticated": authenticated, "setup_required": setup_required, "role": role, + "username": u.username if authenticated and u else None, "registration_open": registration_open, } diff --git a/frontend/src/components/admin/IAMPage.tsx b/frontend/src/components/admin/IAMPage.tsx index aed1a85..3800b4c 100644 --- a/frontend/src/components/admin/IAMPage.tsx +++ b/frontend/src/components/admin/IAMPage.tsx @@ -20,6 +20,7 @@ import { useUpdateConfig, getErrorMessage, } from '@/hooks/useAdmin'; +import { useAuth } from '@/hooks/useAuth'; import { getRelativeTime } from '@/lib/date-utils'; import type { AdminUserDetail, UserRole } from '@/types'; import { cn } from '@/lib/utils'; @@ -55,6 +56,7 @@ function RoleBadge({ role }: { role: UserRole }) { export default function IAMPage() { const [createOpen, setCreateOpen] = useState(false); + const { authStatus } = useAuth(); const { data: users, isLoading: usersLoading } = useAdminUsers(); const { data: dashboard } = useAdminDashboard(); @@ -205,7 +207,7 @@ export default function IAMPage() { {getRelativeTime(user.created_at)}
))} diff --git a/frontend/src/components/admin/UserActionsMenu.tsx b/frontend/src/components/admin/UserActionsMenu.tsx index c2d8c43..a2318e0 100644 --- a/frontend/src/components/admin/UserActionsMenu.tsx +++ b/frontend/src/components/admin/UserActionsMenu.tsx @@ -30,6 +30,7 @@ import { cn } from '@/lib/utils'; interface UserActionsMenuProps { user: AdminUserDetail; + currentUsername: string | null; } const ROLES: { value: UserRole; label: string }[] = [ @@ -38,7 +39,7 @@ const ROLES: { value: UserRole; label: string }[] = [ { value: 'public_event_manager', label: 'Public Event Manager' }, ]; -export default function UserActionsMenu({ user }: UserActionsMenuProps) { +export default function UserActionsMenu({ user, currentUsername }: UserActionsMenuProps) { const [open, setOpen] = useState(false); const [roleSubmenuOpen, setRoleSubmenuOpen] = useState(false); const [tempPassword, setTempPassword] = useState(null); @@ -91,8 +92,14 @@ export default function UserActionsMenu({ user }: UserActionsMenuProps) { handleAction(() => revokeSessions.mutateAsync(user.id), 'Sessions revoked'); }); - const deleteUserConfirm = useConfirmAction(() => { - handleAction(() => deleteUser.mutateAsync(user.id), 'User permanently deleted'); + const deleteUserConfirm = useConfirmAction(async () => { + try { + const result = await deleteUser.mutateAsync(user.id); + toast.success(`User '${(result as { deleted_username: string }).deleted_username}' permanently deleted`); + setOpen(false); + } catch (err) { + toast.error(getErrorMessage(err, 'Delete failed')); + } }); const isLoading = @@ -283,9 +290,11 @@ export default function UserActionsMenu({ user }: UserActionsMenuProps) { {revokeSessionsConfirm.confirming ? 'Sure? Click to confirm' : 'Revoke All Sessions'} + {/* Delete User — hidden for own account */} + {currentUsername !== user.username && ( + <>
- {/* Delete User — destructive, red two-click confirm */} + + )}
)} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 542e3d4..ba85daf 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -194,6 +194,7 @@ export interface AuthStatus { authenticated: boolean; setup_required: boolean; role: UserRole | null; + username: string | null; registration_open: boolean; } From c3654dc0690bd22d8238710350b3402293da0901 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 27 Feb 2026 19:44:10 +0800 Subject: [PATCH 23/31] Fix audit log target for deleted users + create user 500 error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Audit log: COALESCE target_username with detail JSON fallback so deleted users still show their username in the target column (target_user_id is SET NULL by FK cascade, but detail JSON preserves the username). 2. Create/get user: add exclude={"active_sessions"} to model_dump() calls — UserListItem defaults active_sessions=0, so model_dump() includes it, then the explicit active_sessions=N keyword argument causes a duplicate keyword TypeError. DB commit already happened, so the user exists but the response was a 500. Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/admin.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 963fe4a..c4acdb6 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -16,6 +16,7 @@ from typing import Optional import sqlalchemy as sa from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db @@ -58,6 +59,22 @@ router = APIRouter( ) +# --------------------------------------------------------------------------- +# Audit log helper — resolve target username even for deleted users +# --------------------------------------------------------------------------- + +def _target_username_col(target_alias, audit_model): + """ + COALESCE: prefer the live username from the users table, + fall back to the username stored in the audit detail JSON + (survives user deletion since audit_log.target_user_id → SET NULL). + """ + return sa.func.coalesce( + target_alias.username, + sa.cast(audit_model.detail, JSONB)["username"].as_string(), + ).label("target_username") + + # --------------------------------------------------------------------------- # Session revocation helper (used in multiple endpoints) # --------------------------------------------------------------------------- @@ -152,7 +169,7 @@ async def get_user( active_sessions = session_result.scalar_one() return UserDetailResponse( - **UserListItem.model_validate(user).model_dump(), + **UserListItem.model_validate(user).model_dump(exclude={"active_sessions"}), active_sessions=active_sessions, ) @@ -197,7 +214,7 @@ async def create_user( await db.commit() return UserDetailResponse( - **UserListItem.model_validate(new_user).model_dump(), + **UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}), active_sessions=0, ) @@ -668,7 +685,7 @@ async def admin_dashboard( sa.select( AuditLog, actor_user.username.label("actor_username"), - target_user.username.label("target_username"), + _target_username_col(target_user, AuditLog), ) .outerjoin(actor_user, AuditLog.actor_user_id == actor_user.id) .outerjoin(target_user, AuditLog.target_user_id == target_user.id) @@ -722,7 +739,7 @@ async def get_audit_log( sa.select( AuditLog, actor_user.username.label("actor_username"), - target_user.username.label("target_username"), + _target_username_col(target_user, AuditLog), ) .outerjoin(actor_user, AuditLog.actor_user_id == actor_user.id) .outerjoin(target_user, AuditLog.target_user_id == target_user.id) From 8582b41b03994c7c0bd2b154c741909b8d2ba939 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 27 Feb 2026 22:40:20 +0800 Subject: [PATCH 24/31] Add user profile fields + IAM search, email column, detail panel Backend: - Migration 037: add email, first_name, last_name to users table - User model: add 3 profile columns - Admin schemas: extend UserListItem/UserDetailResponse/CreateUserRequest with profile fields, email validator, name field sanitization - _create_user_defaults: accept optional preferred_name kwarg - POST /users: set profile fields, email uniqueness check, IntegrityError guard - GET /users/{id}: join Settings for preferred_name, include must_change_password/locked_until Frontend: - AdminUser/AdminUserDetail types: add profile + detail fields - useAdmin: add CreateUserPayload profile fields + useAdminUserDetail query - CreateUserDialog: optional profile section (first/last name, email, preferred name) - IAMPage: search bar filtering on username/email/name, email column in table, row click to select user with accent highlight - UserDetailSection: two-column detail panel (User Info + Security & Permissions) with inline role editing Co-Authored-By: Claude Opus 4.6 --- .../versions/037_add_user_profile_fields.py | 29 +++ backend/app/models/user.py | 3 + backend/app/routers/admin.py | 30 ++- backend/app/routers/auth.py | 6 +- backend/app/schemas/admin.py | 39 +++- .../src/components/admin/CreateUserDialog.tsx | 65 +++++- frontend/src/components/admin/IAMPage.tsx | 75 +++++-- .../components/admin/UserDetailSection.tsx | 197 ++++++++++++++++++ frontend/src/hooks/useAdmin.ts | 15 ++ frontend/src/types/index.ts | 6 + 10 files changed, 445 insertions(+), 20 deletions(-) create mode 100644 backend/alembic/versions/037_add_user_profile_fields.py create mode 100644 frontend/src/components/admin/UserDetailSection.tsx diff --git a/backend/alembic/versions/037_add_user_profile_fields.py b/backend/alembic/versions/037_add_user_profile_fields.py new file mode 100644 index 0000000..1710a96 --- /dev/null +++ b/backend/alembic/versions/037_add_user_profile_fields.py @@ -0,0 +1,29 @@ +"""Add user profile fields (email, first_name, last_name). + +Revision ID: 037 +Revises: 036 +""" +from alembic import op +import sqlalchemy as sa + +revision = "037" +down_revision = "036" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("email", sa.String(255), nullable=True)) + op.add_column("users", sa.Column("first_name", sa.String(100), nullable=True)) + op.add_column("users", sa.Column("last_name", sa.String(100), nullable=True)) + + op.create_unique_constraint("uq_users_email", "users", ["email"]) + op.create_index("ix_users_email", "users", ["email"]) + + +def downgrade() -> None: + op.drop_index("ix_users_email", table_name="users") + op.drop_constraint("uq_users_email", "users", type_="unique") + op.drop_column("users", "last_name") + op.drop_column("users", "first_name") + op.drop_column("users", "email") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index efe582f..92623dd 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -9,6 +9,9 @@ class User(Base): id: Mapped[int] = mapped_column(primary_key=True, index=True) username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) + email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True, index=True) + first_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + last_name: Mapped[str | None] = mapped_column(String(100), nullable=True) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) # MFA — populated in Track B diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index c4acdb6..aac4d97 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -17,12 +17,14 @@ from typing import Optional import sqlalchemy as sa from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models.audit_log import AuditLog from app.models.backup_code import BackupCode from app.models.session import UserSession +from app.models.settings import Settings from app.models.system_config import SystemConfig from app.models.user import User from app.routers.auth import ( @@ -153,7 +155,7 @@ async def get_user( db: AsyncSession = Depends(get_db), _actor: User = Depends(get_current_user), ): - """Return a single user with their active session count.""" + """Return a single user with their active session count and preferred_name.""" result = await db.execute(sa.select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: @@ -168,9 +170,16 @@ async def get_user( ) active_sessions = session_result.scalar_one() + # Fetch preferred_name from Settings + settings_result = await db.execute( + sa.select(Settings.preferred_name).where(Settings.user_id == user_id) + ) + preferred_name = settings_result.scalar_one_or_none() + return UserDetailResponse( **UserListItem.model_validate(user).model_dump(exclude={"active_sessions"}), active_sessions=active_sessions, + preferred_name=preferred_name, ) @@ -190,10 +199,20 @@ async def create_user( if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail="Username already taken") + # Check email uniqueness if provided + email = data.email.strip().lower() if data.email else None + if email: + email_exists = await db.execute(sa.select(User).where(User.email == email)) + if email_exists.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Email already in use") + new_user = User( username=data.username, password_hash=hash_password(data.password), role=data.role, + email=email, + first_name=data.first_name, + last_name=data.last_name, last_password_change_at=datetime.now(), # Force password change so the user sets their own credential must_change_password=True, @@ -201,7 +220,7 @@ async def create_user( db.add(new_user) await db.flush() # populate new_user.id - await _create_user_defaults(db, new_user.id) + await _create_user_defaults(db, new_user.id, preferred_name=data.preferred_name) await log_audit_event( db, @@ -211,7 +230,12 @@ async def create_user( detail={"username": new_user.username, "role": new_user.role}, ip=request.client.host if request.client else None, ) - await db.commit() + + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise HTTPException(status_code=409, detail="Username or email already in use") return UserDetailResponse( **UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}), diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index d9cd550..0bf3fe0 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -227,9 +227,11 @@ async def _create_db_session( # User bootstrapping helper (Settings + default calendars) # --------------------------------------------------------------------------- -async def _create_user_defaults(db: AsyncSession, user_id: int) -> None: +async def _create_user_defaults( + db: AsyncSession, user_id: int, *, preferred_name: str | None = None, +) -> None: """Create Settings row and default calendars for a new user.""" - db.add(Settings(user_id=user_id)) + db.add(Settings(user_id=user_id, preferred_name=preferred_name)) db.add(Calendar( name="Personal", color="#3b82f6", is_default=True, is_system=False, is_visible=True, diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index ea03df2..c24160d 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -8,7 +8,7 @@ import re from datetime import datetime from typing import Optional, Literal -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from app.schemas.auth import _validate_username, _validate_password_strength @@ -20,6 +20,9 @@ from app.schemas.auth import _validate_username, _validate_password_strength class UserListItem(BaseModel): id: int username: str + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None role: str is_active: bool last_login_at: Optional[datetime] = None @@ -38,7 +41,9 @@ class UserListResponse(BaseModel): class UserDetailResponse(UserListItem): - pass + preferred_name: Optional[str] = None + must_change_password: bool = False + locked_until: Optional[datetime] = None # --------------------------------------------------------------------------- @@ -52,6 +57,10 @@ class CreateUserRequest(BaseModel): username: str password: str role: Literal["admin", "standard", "public_event_manager"] = "standard" + email: Optional[str] = Field(None, max_length=254) + first_name: Optional[str] = Field(None, max_length=100) + last_name: Optional[str] = Field(None, max_length=100) + preferred_name: Optional[str] = Field(None, max_length=100) @field_validator("username") @classmethod @@ -63,6 +72,32 @@ class CreateUserRequest(BaseModel): def validate_password(cls, v: str) -> str: return _validate_password_strength(v) + @field_validator("email") + @classmethod + def validate_email(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip().lower() + if not v: + return None + # Basic format check: must have exactly one @, with non-empty local and domain parts + if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", v): + raise ValueError("Invalid email format") + return v + + @field_validator("first_name", "last_name", "preferred_name") + @classmethod + def validate_name_fields(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip() + if not v: + return None + # Reject ASCII control characters + if re.search(r"[\x00-\x1f]", v): + raise ValueError("Name must not contain control characters") + return v + class UpdateUserRoleRequest(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/frontend/src/components/admin/CreateUserDialog.tsx b/frontend/src/components/admin/CreateUserDialog.tsx index e4e8023..299f54d 100644 --- a/frontend/src/components/admin/CreateUserDialog.tsx +++ b/frontend/src/components/admin/CreateUserDialog.tsx @@ -25,6 +25,10 @@ export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialo const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [role, setRole] = useState('standard'); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [email, setEmail] = useState(''); + const [preferredName, setPreferredName] = useState(''); const createUser = useCreateUser(); @@ -32,12 +36,26 @@ export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialo e.preventDefault(); if (!username.trim() || !password.trim()) return; + const payload: Parameters[0] = { + username: username.trim(), + password, + role, + }; + if (email.trim()) payload.email = email.trim(); + if (firstName.trim()) payload.first_name = firstName.trim(); + if (lastName.trim()) payload.last_name = lastName.trim(); + if (preferredName.trim()) payload.preferred_name = preferredName.trim(); + try { - await createUser.mutateAsync({ username: username.trim(), password, role }); + await createUser.mutateAsync(payload); toast.success(`User "${username.trim()}" created successfully`); setUsername(''); setPassword(''); setRole('standard'); + setFirstName(''); + setLastName(''); + setEmail(''); + setPreferredName(''); onOpenChange(false); } catch (err) { toast.error(getErrorMessage(err, 'Failed to create user')); @@ -96,6 +114,51 @@ export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialo +
+

+ Optional Profile +

+
+
+ + setFirstName(e.target.value)} + placeholder="First name" + /> +
+
+ + setLastName(e.target.value)} + placeholder="Last name" + /> +
+
+
+ + setEmail(e.target.value)} + placeholder="user@example.com" + /> +
+
+ + setPreferredName(e.target.value)} + placeholder="Display name" + /> +
+
+ +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search users..." + className="pl-8 h-8 w-48 text-xs" + /> +
+ +
{usersLoading ? ( @@ -126,8 +155,10 @@ export default function IAMPage() { ))} - ) : !users?.length ? ( -

No users found.

+ ) : !filteredUsers.length ? ( +

+ {searchQuery ? 'No users match your search.' : 'No users found.'} +

) : (
- +
@@ -136,6 +167,9 @@ export default function IAMPage() { + @@ -160,15 +194,24 @@ export default function IAMPage() { - {users.map((user: AdminUserDetail, idx) => ( + {filteredUsers.map((user: AdminUserDetail, idx) => ( setSelectedUserId(selectedUserId === user.id ? null : user.id)} className={cn( - 'border-b border-border transition-colors hover:bg-card-elevated/50', - idx % 2 === 0 ? '' : 'bg-card-elevated/25' + 'border-b border-border transition-colors cursor-pointer', + selectedUserId === user.id + ? 'bg-accent/5 border-l-2 border-l-accent' + : cn( + 'hover:bg-card-elevated/50', + idx % 2 === 0 ? '' : 'bg-card-elevated/25' + ) )} > + @@ -206,7 +249,7 @@ export default function IAMPage() { - @@ -218,6 +261,14 @@ export default function IAMPage() { + {/* User detail section */} + {selectedUserId !== null && ( + setSelectedUserId(null)} + /> + )} + {/* System settings */} diff --git a/frontend/src/components/admin/UserDetailSection.tsx b/frontend/src/components/admin/UserDetailSection.tsx new file mode 100644 index 0000000..0f09563 --- /dev/null +++ b/frontend/src/components/admin/UserDetailSection.tsx @@ -0,0 +1,197 @@ +import { X, User, ShieldCheck, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Select } from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAdminUserDetail, useUpdateRole, getErrorMessage } from '@/hooks/useAdmin'; +import { getRelativeTime } from '@/lib/date-utils'; +import { cn } from '@/lib/utils'; +import type { UserRole } from '@/types'; + +interface UserDetailSectionProps { + userId: number; + onClose: () => void; +} + +function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value || '—'} +
+ ); +} + +function StatusBadge({ active }: { active: boolean }) { + return ( + + {active ? 'Active' : 'Disabled'} + + ); +} + +function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean }) { + if (enabled) { + return ( + + Enabled + + ); + } + if (pending) { + return ( + + Pending + + ); + } + return Off; +} + +export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) { + const { data: user, isLoading } = useAdminUserDetail(userId); + const updateRole = useUpdateRole(); + + const handleRoleChange = async (newRole: UserRole) => { + if (!user || newRole === user.role) return; + try { + await updateRole.mutateAsync({ userId: user.id, role: newRole }); + toast.success(`Role updated to "${newRole}"`); + } catch (err) { + toast.error(getErrorMessage(err, 'Failed to update role')); + } + }; + + if (isLoading) { + return ( +
+ + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + + + + {Array.from({ length: 7 }).map((_, i) => ( + + ))} + + +
+ ); + } + + if (!user) return null; + + return ( +
+ {/* User Information (read-only) */} + + +
+
+ +
+ User Information +
+ +
+ + + + + + + + +
+ + {/* Security & Permissions */} + + +
+
+ +
+ Security & Permissions +
+
+ +
+ Role +
+ + {updateRole.isPending && ( + + )} +
+
+ } + /> + + } + /> + + + + + +
+
+
+ ); +} diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts index 58d2521..ac0cb52 100644 --- a/frontend/src/hooks/useAdmin.ts +++ b/frontend/src/hooks/useAdmin.ts @@ -22,6 +22,10 @@ interface CreateUserPayload { username: string; password: string; role: UserRole; + email?: string; + first_name?: string; + last_name?: string; + preferred_name?: string; } interface UpdateRolePayload { @@ -46,6 +50,17 @@ export function useAdminUsers() { }); } +export function useAdminUserDetail(userId: number | null) { + return useQuery({ + queryKey: ['admin', 'users', userId], + queryFn: async () => { + const { data } = await api.get(`/admin/users/${userId}`); + return data; + }, + enabled: userId !== null, + }); +} + export function useAdminDashboard() { return useQuery({ queryKey: ['admin', 'dashboard'], diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ba85daf..fed8531 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -222,6 +222,9 @@ export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse | Lo export interface AdminUser { id: number; username: string; + email: string | null; + first_name: string | null; + last_name: string | null; role: UserRole; is_active: boolean; last_login_at: string | null; @@ -233,6 +236,9 @@ export interface AdminUser { export interface AdminUserDetail extends AdminUser { active_sessions: number; + preferred_name?: string | null; + must_change_password?: boolean; + locked_until?: string | null; } export interface SystemConfig { From c68fd69cdfd0d5f3b4849e7ece955a0c09adec00 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 27 Feb 2026 23:35:18 +0800 Subject: [PATCH 25/31] Make temp password click-to-copy in reset password flow Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/admin/UserActionsMenu.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/admin/UserActionsMenu.tsx b/frontend/src/components/admin/UserActionsMenu.tsx index a2318e0..f4a641d 100644 --- a/frontend/src/components/admin/UserActionsMenu.tsx +++ b/frontend/src/components/admin/UserActionsMenu.tsx @@ -177,7 +177,14 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe {tempPassword ? (

Temporary password:

- + { + navigator.clipboard.writeText(tempPassword); + toast.success('Password copied to clipboard'); + }} + > {tempPassword}
- {lockoutMessage && ( + {loginError && (
-
)}
@@ -493,7 +496,7 @@ export default function LockScreen() { id="username" type="text" value={username} - onChange={(e) => { setUsername(e.target.value); setLockoutMessage(null); }} + onChange={(e) => { setUsername(e.target.value); setLoginError(null); }} placeholder="Enter username" required autoFocus @@ -506,7 +509,7 @@ export default function LockScreen() { id="password" type="password" value={password} - onChange={(e) => { setPassword(e.target.value); setLockoutMessage(null); }} + onChange={(e) => { setPassword(e.target.value); setLoginError(null); }} placeholder={isSetup ? 'Create a password' : 'Enter password'} required autoComplete={isSetup ? 'new-password' : 'current-password'} @@ -532,7 +535,7 @@ export default function LockScreen() {
Username + Email + Role
{user.username} + {user.email || '—'} + {getRelativeTime(user.created_at)} + e.stopPropagation()}>