BillTracker/client/pages/LoginPage.tsx

646 lines
24 KiB
TypeScript

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 { startAuthentication } from '@simplewebauthn/browser';
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<string, string> = {
tracker: '/',
calendar: '/calendar',
summary: '/summary',
spending: '/spending',
};
function TrustItem({
icon: Icon,
title,
children,
}: {
icon: ComponentType<{ className?: string }>;
title: string;
children: ReactNode;
}) {
return (
<div className="flex gap-3 rounded-xl border border-border/65 bg-card/50 p-3 shadow-sm shadow-black/5 backdrop-blur-sm">
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-primary/20 bg-primary/10 text-primary">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">{children}</p>
</div>
</div>
);
}
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<AuthModeInfo | null>(null); // null = still loading
const [pendingUser, setPendingUser] = useState<User | null>(null);
const [showChangePw, setShowChangePw] = useState(false);
const [showPrivacy, setShowPrivacy] = useState(false);
// TOTP challenge state
const [totpChallenge, setTotpChallenge] = useState<string | null>(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<string | null>(null);
const [webauthnOptions, setWebauthnOptions] = useState<unknown>(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<string> => {
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<string, unknown>;
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 {
const response = await startAuthentication({
optionsJSON: options as Parameters<typeof startAuthentication>[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.
const cancelled = (err as Error)?.name === 'NotAllowedError';
setWebauthnError(
cancelled
? 'Security key prompt was cancelled. Try again, or go back and sign in again.'
: errMessage(err, 'Security key verification failed.'),
);
} 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) {
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 (
<div className="min-h-screen bg-[radial-gradient(circle_at_20%_0%,oklch(var(--primary)/0.16),transparent_30rem),radial-gradient(circle_at_84%_82%,oklch(var(--accent)/0.12),transparent_26rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.18))] p-4 sm:p-6 lg:p-8">
<div className="mx-auto grid min-h-[calc(100vh-2rem)] w-full max-w-6xl items-center gap-8 sm:min-h-[calc(100vh-3rem)] lg:min-h-[calc(100vh-4rem)] lg:grid-cols-[minmax(0,1fr)_minmax(22rem,25rem)]">
<section className="hidden lg:block">
<div className="max-w-xl space-y-8">
<div>
<BrandMark className="h-auto w-[18rem] drop-shadow-[0_18px_44px_rgba(0,0,0,0.22)]" />
<p className="mt-5 max-w-md text-lg leading-8 text-muted-foreground">
{BRAND.tagline} Built for fast bill scanning, clear household decisions, and private
defaults.
</p>
</div>
<div className="grid max-w-lg gap-3">
<TrustItem icon={ShieldCheck} title="Private by design">
Admins can manage access, but they cannot inspect personal bills or spending
history.
</TrustItem>
<TrustItem icon={Gauge} title="Fast daily control">
Dense tracker views, tabular money, and calm status cues stay front and center.
</TrustItem>
<TrustItem icon={LockKeyhole} title="Ready for stronger sign-in">
Local login, SSO, and two-factor flows share one clean entry point.
</TrustItem>
</div>
</div>
</section>
<div className="mx-auto w-full max-w-sm space-y-6">
{/* Logo / Brand */}
<div className="flex flex-col items-center text-center lg:hidden">
<BrandMark className="h-auto w-[78%] max-w-[12rem] drop-shadow-[0_10px_28px_rgba(0,0,0,0.18)]" />
<p className="mt-3 max-w-xs text-sm leading-6 text-muted-foreground">{BRAND.tagline}</p>
</div>
{/* TOTP step — shown after password is accepted */}
{totpChallenge && (
<div className="surface-elevated p-8 space-y-5">
<div className="flex gap-3">
<BrandGlyph name="security" className="h-11 w-11 rounded-2xl" />
<div>
<h1 className="text-lg font-semibold">Two-factor authentication</h1>
<p className="text-sm text-muted-foreground mt-1">
{useRecovery
? 'Enter one of your recovery codes.'
: 'Enter the 6-digit code from your authenticator app.'}
</p>
</div>
</div>
<form onSubmit={handleTotpSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="totp-code">
{useRecovery ? 'Recovery code' : 'Authenticator code'}
</Label>
<Input
id="totp-code"
value={totpCode}
onChange={(e) => 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
/>
</div>
{totpError && (
<div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{totpError}
</div>
)}
<Button type="submit" className="w-full" disabled={totpLoading || !totpCode.trim()}>
{totpLoading ? 'Verifying…' : 'Verify'}
</Button>
</form>
<div className="text-center space-y-2">
<button
type="button"
className="text-xs text-muted-foreground underline-offset-4 hover:underline hover:text-foreground transition-colors"
onClick={() => {
setUseRecovery((v) => !v);
setTotpCode('');
setTotpError('');
}}
>
{useRecovery
? 'Use authenticator app instead'
: "Can't access your app? Use a recovery code"}
</button>
<br />
<button
type="button"
className="text-xs text-muted-foreground underline-offset-4 hover:underline hover:text-foreground transition-colors"
onClick={() => {
setTotpChallenge(null);
setTotpCode('');
setTotpError('');
}}
>
Back to sign in
</button>
</div>
</div>
)}
{/* WebAuthn step — shown after password is accepted for key-protected accounts */}
{webauthnChallenge && (
<div className="surface-elevated p-8 space-y-5">
<div className="flex gap-3">
<BrandGlyph name="security" className="h-11 w-11 rounded-2xl" />
<div>
<h1 className="text-lg font-semibold">Security key</h1>
<p className="text-sm text-muted-foreground mt-1">
Confirm sign-in with your passkey or hardware security key.
</p>
</div>
</div>
{webauthnError && (
<div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{webauthnError}
</div>
)}
<Button
type="button"
className="w-full"
disabled={webauthnLoading}
onClick={() => void verifyWebauthn(webauthnChallenge, webauthnOptions)}
>
{webauthnLoading ? 'Waiting for your key…' : 'Use security key'}
</Button>
<div className="text-center">
<button
type="button"
className="text-xs text-muted-foreground underline-offset-4 hover:underline hover:text-foreground transition-colors"
onClick={() => {
setWebauthnChallenge(null);
setWebauthnOptions(null);
setWebauthnError('');
}}
>
Back to sign in
</button>
</div>
</div>
)}
{/* Sign-in card — hidden while auth mode resolves or during a 2FA step */}
{!totpChallenge &&
!webauthnChallenge &&
(authMode === null ? (
<div className="surface-elevated flex min-h-[120px] items-center justify-center p-8">
<span className="text-sm text-muted-foreground">Loading</span>
</div>
) : (
<div className="surface-elevated space-y-6 p-8">
<div>
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
<KeyRound className="h-3.5 w-3.5" aria-hidden="true" />
Secure household access
</div>
<h1 className="text-xl font-semibold tracking-tight">Sign in</h1>
<p className="text-sm text-muted-foreground mt-1">
{localEnabled
? 'Enter your credentials to continue.'
: `Continue with ${providerName}.`}
</p>
</div>
{oidcEnabled && (
<Button
type="button"
variant={localEnabled ? 'outline' : 'default'}
className="w-full gap-2"
onClick={() => {
window.location.href = authMode?.oidc_login_url || '';
}}
>
{isAuthentikProvider && (
<img
src="/img/auth.png"
alt=""
aria-hidden="true"
className="h-5 w-5 object-contain shrink-0"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
Continue with {providerName}
</Button>
)}
{localEnabled && oidcEnabled && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="h-px flex-1 bg-border" />
<span>or</span>
<div className="h-px flex-1 bg-border" />
</div>
)}
{localEnabled && (
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="username">Username</Label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
required
/>
</div>
{error && (
<div
className="text-sm text-destructive bg-destructive/10
border border-destructive/20 rounded-md px-3 py-2"
>
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Signing in…' : 'Sign In'}
</Button>
</form>
)}
<p className="text-center text-xs text-muted-foreground">
<a
href={BUILD_LINK_URL}
target="_blank"
rel="noreferrer"
className="underline-offset-4 transition-colors hover:text-foreground hover:underline"
>
Build v{APP_VERSION}
</a>
</p>
<div className="flex items-center justify-center gap-3 text-xs text-muted-foreground">
<Link
to="/about"
className="underline-offset-4 transition-colors hover:text-foreground hover:underline"
>
About
</Link>
<Link
to="/release-notes"
className="underline-offset-4 transition-colors hover:text-foreground hover:underline"
>
Release Notes
</Link>
</div>
</div>
))}{' '}
{/* end !totpChallenge + authMode check */}
</div>
</div>
{/* Change Password Dialog */}
<Dialog open={showChangePw}>
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<div className="mb-2 flex items-center gap-3">
<BrandGlyph name="security" className="h-11 w-11 rounded-2xl" />
<div>
<DialogTitle>Change your password</DialogTitle>
<DialogDescription>Set a new password before continuing.</DialogDescription>
</div>
</div>
</DialogHeader>
<form onSubmit={handleChangePassword} className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label>New password</Label>
<Input
type="password"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label>Confirm password</Label>
<Input
type="password"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
required
/>
</div>
<DialogFooter>
<Button type="submit" className="w-full" disabled={pwLoading}>
{pwLoading ? 'Saving…' : 'Set Password'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Privacy Dialog */}
<Dialog open={showPrivacy}>
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<div className="mb-2 flex items-center gap-3">
<BrandGlyph name="security" className="h-11 w-11 rounded-2xl" />
<div>
<DialogTitle>Privacy notice</DialogTitle>
<DialogDescription>
A quick boundary check before your first session.
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground py-2">
<p>Your financial data is stored privately and is accessible only to you.</p>
<div className="rounded-xl bg-muted/50 border border-border p-4 space-y-2">
<p className="font-medium text-foreground">What your administrator can do:</p>
<ul className="space-y-1.5">
<li className="flex gap-2">
<ShieldCheck
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500"
aria-hidden="true"
/>
<span>Reset your password if locked out</span>
</li>
<li className="flex gap-2">
<LockKeyhole
className="mt-0.5 h-4 w-4 shrink-0 text-rose-500"
aria-hidden="true"
/>
<span>Cannot view your financial data</span>
</li>
</ul>
</div>
</div>
<DialogFooter>
<Button className="w-full" onClick={handleAcknowledgePrivacy}>
I understand continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}