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); 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; } handlePostLogin(data.user!); } catch (err) { setError(errMessage(err, 'Login failed.')); } finally { setLoading(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) { setTotpError(errMessage(err, 'Invalid code.')); setTotpCode(''); } 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 isAuthentikProvider = providerName.toLowerCase().includes('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}
)}

)} {/* Sign-in card — hidden while auth mode resolves or during TOTP step */} {!totpChallenge && (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:

); }