import { useState, useEffect, type ComponentType, type ReactNode } from 'react'; import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Gauge, KeyRound, LockKeyhole, ShieldCheck } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; import { errMessage } from '@/lib/utils'; import { useAuth, type User } from '@/hooks/useAuth'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { APP_VERSION } from '@/lib/version'; import { BRAND } from '@/lib/brand'; import { BrandGlyph, BrandMark } from '@/components/brand/Brand'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog'; const BUILD_LINK_URL = 'https://dream.scheller.ltd/null/BillTracker'; interface AuthModeInfo { auth_mode?: string; local_enabled?: boolean; oidc_enabled?: boolean; oidc_login_url?: string; oidc_provider_name?: string; } const LANDING_ROUTES: Record = { tracker: '/', calendar: '/calendar', summary: '/summary', spending: '/spending', }; function TrustItem({ icon: Icon, title, children, }: { icon: ComponentType<{ className?: string }>; title: string; children: ReactNode; }) { return (

{title}

{children}

); } export default function LoginPage() { const navigate = useNavigate(); const location = useLocation(); const { setUser, refresh } = useAuth(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [authMode, setAuthMode] = useState(null); // null = still loading const [pendingUser, setPendingUser] = useState(null); const [showChangePw, setShowChangePw] = useState(false); const [showPrivacy, setShowPrivacy] = useState(false); // TOTP challenge state const [totpChallenge, setTotpChallenge] = useState(null); // challenge_token string const [totpCode, setTotpCode] = useState(''); const [totpError, setTotpError] = useState(''); const [totpLoading, setTotpLoading] = useState(false); const [useRecovery, setUseRecovery] = useState(false); // WebAuthn second step — mirrors the TOTP flow: password first, then the key. const [webauthnChallenge, setWebauthnChallenge] = useState(null); const [webauthnOptions, setWebauthnOptions] = useState(null); const [webauthnError, setWebauthnError] = useState(''); const [webauthnLoading, setWebauthnLoading] = useState(false); const [newPw, setNewPw] = useState(''); const [confirmPw, setConfirmPw] = useState(''); const [pwLoading, setPwLoading] = useState(false); const destFor = (user?: User | null) => (user?.is_default_admin ? '/admin' : '/'); // Where to send the user after signing in. Precedence: admin console → // explicit deep-link they were bounced from → their default landing page → /. const resolveDest = async (user?: User | null): Promise => { if (user?.is_default_admin) return '/admin'; const from = (location.state as { from?: { pathname?: string } } | null)?.from?.pathname; if (from && from !== '/login') return from; try { const s = (await api.settings()) as Record; const landing = typeof s.default_landing_page === 'string' ? LANDING_ROUTES[s.default_landing_page] : undefined; return landing || '/'; } catch { return '/'; // never block login on a settings-fetch failure } }; const navigateAfterAuth = async (user?: User | null) => { navigate(await resolveDest(user), { replace: true }); }; useEffect(() => { api .authMode() .then((raw) => { const d = raw as AuthModeInfo; setAuthMode(d); if (d.auth_mode === 'single') navigate('/', { replace: true }); }) .catch((err) => console.error('[LoginPage] authMode check failed', err)); api .me() .then((raw) => { const d = raw as { user?: User }; if (d.user) navigate(destFor(d.user), { replace: true }); }) .catch((err) => console.error('[LoginPage] session check failed', err)); }, []); // eslint-disable-line const handlePostLogin = (user: User) => { setUser(user); if (user.must_change_password) { setPendingUser(user); setShowChangePw(true); setShowPrivacy(false); } else if (user.first_login) { setPendingUser(user); setShowPrivacy(true); setShowChangePw(false); } else { void navigateAfterAuth(user); } }; const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setLoading(true); try { const data = await api.login({ username, password }); if (data.requires_totp) { setTotpChallenge(data.challenge_token ?? null); setTotpCode(''); setTotpError(''); setUseRecovery(false); return; } if (data.requires_webauthn) { setWebauthnChallenge(data.challenge_token ?? null); setWebauthnOptions(data.webauthn_options ?? null); setWebauthnError(''); // Prompt for the key immediately — the browser dialog is the whole step. void verifyWebauthn(data.challenge_token ?? null, data.webauthn_options ?? null); return; } handlePostLogin(data.user!); } catch (err) { setError(errMessage(err, 'Login failed.')); } finally { setLoading(false); } }; const verifyWebauthn = async (challengeToken: string | null, options: unknown) => { if (!challengeToken || !options) { setWebauthnError('Security-key sign-in is unavailable. Please sign in again.'); return; } setWebauthnError(''); setWebauthnLoading(true); try { // Lazy: keep the WebAuthn lib out of the login-critical entry bundle // (only key-protected accounts ever reach this; ProfilePage does the same). const { startAuthentication } = await import('@simplewebauthn/browser'); const response = await startAuthentication({ optionsJSON: options as Parameters[0]['optionsJSON'], }); const data = await api.webauthnChallenge({ challenge_token: challengeToken, response }); handlePostLogin((data as { user?: User }).user!); } catch (err) { // NotAllowedError = user cancelled / timed out at the browser prompt — // the endpoint was never called, so the single-use challenge token is // still valid and retry works. Any SERVER failure has already consumed // the token (anti-replay), so a retry can only ever return "Challenge // expired" — send the user back to sign in instead of a dead button. const cancelled = (err as Error)?.name === 'NotAllowedError'; if (cancelled) { setWebauthnError('Security key prompt was cancelled. Try again.'); } else { setWebauthnChallenge(null); setWebauthnOptions(null); setWebauthnError(''); setError(errMessage(err, 'Security key verification failed.') + ' Please sign in again.'); } } finally { setWebauthnLoading(false); } }; const handleTotpSubmit = async (e: React.FormEvent) => { e.preventDefault(); setTotpError(''); setTotpLoading(true); try { const payload: { challenge_token: string | null; recovery_code?: string; code?: string } = { challenge_token: totpChallenge, }; if (useRecovery) payload.recovery_code = totpCode; else payload.code = totpCode; const data = await api.totpChallenge(payload); handlePostLogin(data.user!); } catch (err) { // The server consumes the single-use challenge token BEFORE verifying the // code (anti-replay), so after any server rejection a retry with the same // token can only return "Challenge expired" — restart the sign-in instead // of leaving a form that can never succeed. (Same semantics as WebAuthn.) setTotpChallenge(null); setTotpCode(''); setTotpError(''); setError(errMessage(err, 'Invalid code.') + ' Please sign in again.'); } finally { setTotpLoading(false); } }; const localEnabled = authMode?.local_enabled !== false; const oidcEnabled = !!authMode?.oidc_enabled && !!authMode?.oidc_login_url; const providerName = authMode?.oidc_provider_name || 'authentik'; const handleChangePassword = async (e: React.FormEvent) => { e.preventDefault(); if (newPw !== confirmPw) { toast.error('Passwords do not match.'); return; } if (newPw.length < 8) { toast.error('Password must be at least 8 characters.'); return; } setPwLoading(true); try { await api.changePassword({ new_password: newPw }); toast.success('Password updated.'); setShowChangePw(false); refresh(); if (pendingUser?.first_login) { setShowPrivacy(true); } else { void navigateAfterAuth(pendingUser); } } catch (err) { toast.error(errMessage(err, 'Failed to change password.')); } finally { setPwLoading(false); } }; const handleAcknowledgePrivacy = async () => { try { await api.acknowledgePrivacy(); } catch { /* ignore */ } refresh(); setShowPrivacy(false); void navigateAfterAuth(pendingUser); }; return (

{BRAND.tagline} Built for fast bill scanning, clear household decisions, and private defaults.

Admins can manage access, but they cannot inspect personal bills or spending history. Dense tracker views, tabular money, and calm status cues stay front and center. Local login, SSO, and two-factor flows share one clean entry point.
{/* Logo / Brand */}

{BRAND.tagline}

{/* TOTP step — shown after password is accepted */} {totpChallenge && (

Two-factor authentication

{useRecovery ? 'Enter one of your recovery codes.' : 'Enter the 6-digit code from your authenticator app.'}

setTotpCode(e.target.value)} placeholder={useRecovery ? 'XXXXX-XXXXX' : '000 000'} autoComplete="one-time-code" autoFocus disabled={totpLoading} maxLength={useRecovery ? 11 : 7} className="text-center tracking-widest text-lg font-mono" required />
{totpError && (
{totpError}
)}

)} {/* WebAuthn step — shown after password is accepted for key-protected accounts */} {webauthnChallenge && (

Security key

Confirm sign-in with your passkey or hardware security key.

{webauthnError && (
{webauthnError}
)}
)} {/* Sign-in card — hidden while auth mode resolves or during a 2FA step */} {!totpChallenge && !webauthnChallenge && (authMode === null ? (
Loading…
) : (

Sign in

{localEnabled ? 'Enter your credentials to continue.' : `Continue with ${providerName}.`}

{oidcEnabled && ( )} {localEnabled && oidcEnabled && (
or
)} {localEnabled && (
setUsername(e.target.value)} disabled={loading} required />
setPassword(e.target.value)} disabled={loading} required />
{error && (
{error}
)}
)}

Build v{APP_VERSION}

About Release Notes
))}{' '} {/* end !totpChallenge + authMode check */}
{/* Change Password Dialog */} e.preventDefault()}>
Change your password Set a new password before continuing.
setNewPw(e.target.value)} required />
setConfirmPw(e.target.value)} required />
{/* Privacy Dialog */} e.preventDefault()}>
Privacy notice A quick boundary check before your first session.

Your financial data is stored privately and is accessible only to you.

What your administrator can do:

); }