BillTracker/client/pages/LoginPage.tsx

522 lines
19 KiB
TypeScript
Raw Normal View History

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';
2026-05-03 19:51:57 -05:00
import { toast } from 'sonner';
import { api } from '@/api';
import { errMessage } from '@/lib/utils';
import { useAuth, type User } from '@/hooks/useAuth';
2026-05-03 19:51:57 -05:00
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
2026-05-03 22:33:21 -05:00
import { APP_VERSION } from '@/lib/version';
import { BRAND } from '@/lib/brand';
import { BrandGlyph, BrandMark } from '@/components/brand/Brand';
2026-05-03 19:51:57 -05:00
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
2026-05-03 22:33:21 -05:00
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>
);
}
2026-05-03 19:51:57 -05:00
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
2026-05-03 19:51:57 -05:00
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
2026-05-03 19:51:57 -05:00
const [pendingUser, setPendingUser] = useState<User | null>(null);
2026-05-03 19:51:57 -05:00
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);
2026-05-03 19:51:57 -05:00
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [pwLoading, setPwLoading] = useState(false);
const destFor = (user?: User | null) => (user?.is_default_admin ? '/admin' : '/');
2026-05-03 19:51:57 -05:00
// 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 });
};
2026-05-03 19:51:57 -05:00
useEffect(() => {
api.authMode()
.then(raw => {
const d = raw as AuthModeInfo;
2026-05-03 19:51:57 -05:00
setAuthMode(d);
if (d.auth_mode === 'single') navigate('/', { replace: true });
})
2026-05-31 15:06:10 -05:00
.catch(err => console.error('[LoginPage] authMode check failed', err));
2026-05-03 19:51:57 -05:00
api.me()
.then(raw => {
const d = raw as { user?: User };
2026-05-04 23:34:24 -05:00
if (d.user) navigate(destFor(d.user), { replace: true });
2026-05-03 19:51:57 -05:00
})
2026-05-31 15:06:10 -05:00
.catch(err => console.error('[LoginPage] session check failed', err));
2026-05-03 19:51:57 -05:00
}, []); // eslint-disable-line
const handlePostLogin = (user: User) => {
2026-05-03 19:51:57 -05:00
setUser(user);
if (user.must_change_password) {
setPendingUser(user);
setShowChangePw(true);
2026-05-09 13:03:36 -05:00
setShowPrivacy(false);
2026-05-03 19:51:57 -05:00
} else if (user.first_login) {
setPendingUser(user);
setShowPrivacy(true);
2026-05-09 13:03:36 -05:00
setShowChangePw(false);
2026-05-03 19:51:57 -05:00
} else {
void navigateAfterAuth(user);
2026-05-03 19:51:57 -05:00
}
};
const handleLogin = async (e: React.FormEvent) => {
2026-05-03 19:51:57 -05:00
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!);
2026-05-03 19:51:57 -05:00
} catch (err) {
setError(errMessage(err, 'Login failed.'));
2026-05-03 19:51:57 -05:00
} 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';
2026-05-16 10:56:56 -05:00
const isAuthentikProvider = providerName.toLowerCase().includes('authentik');
2026-05-03 19:51:57 -05:00
const handleChangePassword = async (e: React.FormEvent) => {
2026-05-03 19:51:57 -05:00
e.preventDefault();
if (newPw !== confirmPw) {
toast.error('Passwords do not match.');
return;
}
2026-05-15 22:45:38 -05:00
if (newPw.length < 8) {
toast.error('Password must be at least 8 characters.');
2026-05-03 19:51:57 -05:00
return;
}
setPwLoading(true);
try {
await api.changePassword({ new_password: newPw });
toast.success('Password updated.');
setShowChangePw(false);
2026-05-09 13:03:36 -05:00
refresh();
2026-05-03 19:51:57 -05:00
if (pendingUser?.first_login) {
setShowPrivacy(true);
} else {
void navigateAfterAuth(pendingUser);
2026-05-03 19:51:57 -05:00
}
} catch (err) {
toast.error(errMessage(err, 'Failed to change password.'));
2026-05-03 19:51:57 -05:00
} finally {
setPwLoading(false);
}
};
const handleAcknowledgePrivacy = async () => {
try {
await api.acknowledgePrivacy();
} catch { /* ignore */ }
2026-05-03 19:51:57 -05:00
refresh();
setShowPrivacy(false);
void navigateAfterAuth(pendingUser);
2026-05-03 19:51:57 -05:00
};
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>
2026-05-03 19:51:57 -05:00
<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">
2026-05-03 19:51:57 -05:00
{/* 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>
2026-05-03 19:51:57 -05:00
</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>
)}
{/* Sign-in card — hidden while auth mode resolves or during TOTP step */}
{!totpChallenge && (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">
2026-05-03 19:51:57 -05:00
<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>
2026-05-03 19:51:57 -05:00
<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'}
2026-05-15 00:03:32 -05:00
className="w-full gap-2"
onClick={() => { window.location.href = authMode?.oidc_login_url || ''; }}
2026-05-03 19:51:57 -05:00
>
2026-05-16 10:56:56 -05:00
{isAuthentikProvider && (
2026-05-15 00:03:32 -05:00
<img
2026-05-16 10:56:56 -05:00
src="/img/auth.png"
2026-05-15 00:03:32 -05:00
alt=""
aria-hidden="true"
className="h-5 w-5 object-contain shrink-0"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
2026-05-15 00:03:32 -05:00
/>
)}
2026-05-03 19:51:57 -05:00
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
2026-05-03 19:51:57 -05:00
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>
)}
2026-05-03 22:33:21 -05:00
<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>
2026-05-04 20:12:57 -05:00
<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>
2026-05-03 19:51:57 -05:00
</div>
))} {/* end !totpChallenge + authMode check */}
2026-05-03 19:51:57 -05:00
</div>
</div>
2026-05-03 19:51:57 -05:00
{/* 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>
2026-05-03 19:51:57 -05:00
</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>
2026-05-03 19:51:57 -05:00
</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">
2026-05-03 19:51:57 -05:00
<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" />
2026-05-03 19:51:57 -05:00
<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" />
2026-05-03 19:51:57 -05:00
<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>
);
}