import { useState, FormEvent } from 'react'; import { Navigate } from 'react-router-dom'; import { toast } from 'sonner'; import { AlertTriangle, Copy, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; 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 { if (password.length < 12) return 'Password must be at least 12 characters'; if (password.length > 128) return 'Password must be at most 128 characters'; if (!/[a-zA-Z]/.test(password)) return 'Password must contain at least one letter'; if (!/[^a-zA-Z]/.test(password)) return 'Password must contain at least one non-letter character'; return null; } 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 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 ── const [totpCode, setTotpCode] = useState(''); const [useBackupCode, setUseBackupCode] = useState(false); // ── Registration mode ── const [mode, setMode] = useState('login'); // ── Inline error (423 lockout, 403 disabled, 401 bad creds) ── const [loginError, setLoginError] = useState(null); // ── 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(); setLoginError(null); if (isSetup) { 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 }); } catch (error) { toast.error(getErrorMessage(error, 'Failed to create account')); } 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) { const status = error?.response?.status; if (status === 423) { setLoginError(error.response.data?.detail || 'Account locked. Try again later.'); } else if (status === 403) { setLoginError(error.response.data?.detail || 'Account is disabled. Contact an administrator.'); } else { setLoginError(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')); } }; const handleTotpSubmit = async (e: FormEvent) => { e.preventDefault(); try { await verifyTotp({ code: totpCode, isBackup: useBackupCode }); } 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(/[^A-Za-z0-9-]/g, '').toUpperCase() : 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'}
{loginError && (
)}
setUsername(e.target.value)} placeholder="Enter username" required autoFocus autoComplete="username" />
setPassword(e.target.value)} 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 */} UMBRA {/* Auth card */} {activeMode === 'totp' && renderTotpChallenge()} {activeMode === 'mfa_enforce' && renderMfaEnforce()} {activeMode === 'force_pw' && renderForcedPasswordChange()} {activeMode === 'register' && renderRegister()} {(activeMode === 'login' || activeMode === 'setup') && renderLoginOrSetup()}
); }