2026-07-06 12:22:47 -05:00
|
|
|
import { useState, useEffect, type ComponentType, type ReactNode } from 'react';
|
2026-07-05 16:03:01 -05:00
|
|
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
2026-07-06 12:22:47 -05:00
|
|
|
import { Gauge, KeyRound, LockKeyhole, ShieldCheck } from 'lucide-react';
|
2026-05-03 19:51:57 -05:00
|
|
|
import { toast } from 'sonner';
|
2026-07-10 17:30:14 -05:00
|
|
|
import { startAuthentication } from '@simplewebauthn/browser';
|
2026-05-03 19:51:57 -05:00
|
|
|
import { api } from '@/api';
|
2026-07-04 22:23:31 -05:00
|
|
|
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';
|
2026-07-05 17:18:56 -05:00
|
|
|
import { BRAND } from '@/lib/brand';
|
2026-07-06 12:22:47 -05:00
|
|
|
import { BrandGlyph, BrandMark } from '@/components/brand/Brand';
|
2026-05-03 19:51:57 -05:00
|
|
|
import {
|
2026-07-06 14:23:53 -05:00
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
2026-05-03 19:51:57 -05:00
|
|
|
} from '@/components/ui/dialog';
|
|
|
|
|
|
2026-05-03 22:33:21 -05:00
|
|
|
const BUILD_LINK_URL = 'https://dream.scheller.ltd/null/BillTracker';
|
|
|
|
|
|
2026-07-04 22:23:31 -05:00
|
|
|
interface AuthModeInfo {
|
|
|
|
|
auth_mode?: string;
|
|
|
|
|
local_enabled?: boolean;
|
|
|
|
|
oidc_enabled?: boolean;
|
|
|
|
|
oidc_login_url?: string;
|
|
|
|
|
oidc_provider_name?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 16:03:01 -05:00
|
|
|
const LANDING_ROUTES: Record<string, string> = {
|
2026-07-06 14:23:53 -05:00
|
|
|
tracker: '/',
|
|
|
|
|
calendar: '/calendar',
|
|
|
|
|
summary: '/summary',
|
|
|
|
|
spending: '/spending',
|
2026-07-05 16:03:01 -05:00
|
|
|
};
|
|
|
|
|
|
2026-07-06 12:22:47 -05:00
|
|
|
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();
|
2026-07-05 16:03:01 -05:00
|
|
|
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('');
|
2026-07-04 22:23:31 -05:00
|
|
|
const [authMode, setAuthMode] = useState<AuthModeInfo | null>(null); // null = still loading
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-04 22:23:31 -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);
|
|
|
|
|
|
2026-06-04 04:10:14 -05:00
|
|
|
// TOTP challenge state
|
2026-07-04 22:23:31 -05:00
|
|
|
const [totpChallenge, setTotpChallenge] = useState<string | null>(null); // challenge_token string
|
2026-06-04 04:10:14 -05:00
|
|
|
const [totpCode, setTotpCode] = useState('');
|
|
|
|
|
const [totpError, setTotpError] = useState('');
|
|
|
|
|
const [totpLoading, setTotpLoading] = useState(false);
|
2026-07-04 22:23:31 -05:00
|
|
|
const [useRecovery, setUseRecovery] = useState(false);
|
2026-06-04 04:10:14 -05:00
|
|
|
|
2026-07-10 17:30:14 -05:00
|
|
|
// 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);
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
const [newPw, setNewPw] = useState('');
|
|
|
|
|
const [confirmPw, setConfirmPw] = useState('');
|
|
|
|
|
const [pwLoading, setPwLoading] = useState(false);
|
|
|
|
|
|
2026-07-04 22:23:31 -05:00
|
|
|
const destFor = (user?: User | null) => (user?.is_default_admin ? '/admin' : '/');
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-05 16:03:01 -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>;
|
2026-07-06 14:23:53 -05:00
|
|
|
const landing =
|
|
|
|
|
typeof s.default_landing_page === 'string'
|
|
|
|
|
? LANDING_ROUTES[s.default_landing_page]
|
|
|
|
|
: undefined;
|
2026-07-05 16:03:01 -05:00
|
|
|
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(() => {
|
2026-07-06 14:23:53 -05:00
|
|
|
api
|
|
|
|
|
.authMode()
|
|
|
|
|
.then((raw) => {
|
2026-07-04 22:23:31 -05:00
|
|
|
const d = raw as AuthModeInfo;
|
2026-05-03 19:51:57 -05:00
|
|
|
setAuthMode(d);
|
|
|
|
|
if (d.auth_mode === 'single') navigate('/', { replace: true });
|
|
|
|
|
})
|
2026-07-06 14:23:53 -05:00
|
|
|
.catch((err) => console.error('[LoginPage] authMode check failed', err));
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
api
|
|
|
|
|
.me()
|
|
|
|
|
.then((raw) => {
|
2026-07-04 22:23:31 -05:00
|
|
|
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-07-06 14:23:53 -05:00
|
|
|
.catch((err) => console.error('[LoginPage] session check failed', err));
|
2026-05-03 19:51:57 -05:00
|
|
|
}, []); // eslint-disable-line
|
|
|
|
|
|
2026-07-04 22:23:31 -05:00
|
|
|
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 {
|
2026-07-05 16:03:01 -05:00
|
|
|
void navigateAfterAuth(user);
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-04 22:23:31 -05:00
|
|
|
const handleLogin = async (e: React.FormEvent) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
e.preventDefault();
|
|
|
|
|
setError('');
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
2026-07-05 12:26:11 -05:00
|
|
|
const data = await api.login({ username, password });
|
2026-06-04 04:10:14 -05:00
|
|
|
if (data.requires_totp) {
|
2026-07-04 22:23:31 -05:00
|
|
|
setTotpChallenge(data.challenge_token ?? null);
|
2026-06-04 04:10:14 -05:00
|
|
|
setTotpCode('');
|
|
|
|
|
setTotpError('');
|
|
|
|
|
setUseRecovery(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-10 17:30:14 -05:00
|
|
|
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;
|
|
|
|
|
}
|
2026-07-05 12:26:11 -05:00
|
|
|
handlePostLogin(data.user!);
|
2026-05-03 19:51:57 -05:00
|
|
|
} catch (err) {
|
2026-07-04 22:23:31 -05:00
|
|
|
setError(errMessage(err, 'Login failed.'));
|
2026-05-03 19:51:57 -05:00
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-10 17:30:14 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-04 22:23:31 -05:00
|
|
|
const handleTotpSubmit = async (e: React.FormEvent) => {
|
2026-06-04 04:10:14 -05:00
|
|
|
e.preventDefault();
|
|
|
|
|
setTotpError('');
|
|
|
|
|
setTotpLoading(true);
|
|
|
|
|
try {
|
2026-07-06 14:23:53 -05:00
|
|
|
const payload: { challenge_token: string | null; recovery_code?: string; code?: string } = {
|
|
|
|
|
challenge_token: totpChallenge,
|
|
|
|
|
};
|
2026-06-04 04:10:14 -05:00
|
|
|
if (useRecovery) payload.recovery_code = totpCode;
|
|
|
|
|
else payload.code = totpCode;
|
2026-07-05 12:26:11 -05:00
|
|
|
const data = await api.totpChallenge(payload);
|
|
|
|
|
handlePostLogin(data.user!);
|
2026-06-04 04:10:14 -05:00
|
|
|
} catch (err) {
|
2026-07-04 22:23:31 -05:00
|
|
|
setTotpError(errMessage(err, 'Invalid code.'));
|
2026-06-04 04:10:14 -05:00
|
|
|
setTotpCode('');
|
|
|
|
|
} finally {
|
|
|
|
|
setTotpLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-04 03:53:38 -05:00
|
|
|
const localEnabled = authMode?.local_enabled !== false;
|
|
|
|
|
const oidcEnabled = !!authMode?.oidc_enabled && !!authMode?.oidc_login_url;
|
|
|
|
|
const providerName = authMode?.oidc_provider_name || 'authentik';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-04 22:23:31 -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 {
|
2026-07-05 16:03:01 -05:00
|
|
|
void navigateAfterAuth(pendingUser);
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
2026-07-04 22:23:31 -05:00
|
|
|
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();
|
2026-07-06 14:23:53 -05:00
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
refresh();
|
|
|
|
|
setShowPrivacy(false);
|
2026-07-05 16:03:01 -05:00
|
|
|
void navigateAfterAuth(pendingUser);
|
2026-05-03 19:51:57 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-06 12:22:47 -05:00
|
|
|
<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">
|
2026-07-06 14:23:53 -05:00
|
|
|
{BRAND.tagline} Built for fast bill scanning, clear household decisions, and private
|
|
|
|
|
defaults.
|
2026-07-06 12:22:47 -05:00
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-06 12:22:47 -05:00
|
|
|
<div className="grid max-w-lg gap-3">
|
|
|
|
|
<TrustItem icon={ShieldCheck} title="Private by design">
|
2026-07-06 14:23:53 -05:00
|
|
|
Admins can manage access, but they cannot inspect personal bills or spending
|
|
|
|
|
history.
|
2026-07-06 12:22:47 -05:00
|
|
|
</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-07-06 14:23:53 -05:00
|
|
|
{/* Logo / Brand */}
|
|
|
|
|
<div className="flex flex-col items-center text-center lg:hidden">
|
2026-07-10 18:03:15 -05:00
|
|
|
<BrandMark className="h-auto w-[78%] max-w-48 drop-shadow-[0_10px_28px_rgba(0,0,0,0.18)]" />
|
2026-07-06 14:23:53 -05:00
|
|
|
<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>
|
2026-06-04 04:10:14 -05:00
|
|
|
</div>
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
<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
|
|
|
|
|
/>
|
2026-06-04 04:10:14 -05:00
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
{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>
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-07-10 17:30:14 -05:00
|
|
|
{/* 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 */}
|
2026-07-06 14:23:53 -05:00
|
|
|
{!totpChallenge &&
|
2026-07-10 17:30:14 -05:00
|
|
|
!webauthnChallenge &&
|
2026-07-06 14:23:53 -05:00
|
|
|
(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>
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
) : (
|
|
|
|
|
<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>
|
2026-05-03 22:33:21 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
{oidcEnabled && (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant={localEnabled ? 'outline' : 'default'}
|
|
|
|
|
className="w-full gap-2"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
window.location.href = authMode?.oidc_login_url || '';
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-07-10 19:10:42 -05:00
|
|
|
<img
|
|
|
|
|
src="/img/auth.svg"
|
|
|
|
|
alt=""
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
className="h-5 w-5 object-contain shrink-0"
|
|
|
|
|
onError={(e) => {
|
|
|
|
|
(e.target as HTMLImageElement).style.display = 'none';
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-07-06 14:23:53 -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
|
|
|
|
|
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 */}
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
2026-07-06 12:22:47 -05:00
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
{/* Change Password Dialog */}
|
|
|
|
|
<Dialog open={showChangePw}>
|
2026-07-06 14:23:53 -05:00
|
|
|
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
|
2026-05-03 19:51:57 -05:00
|
|
|
<DialogHeader>
|
2026-07-06 12:22:47 -05:00
|
|
|
<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>
|
2026-07-06 14:23:53 -05:00
|
|
|
<DialogDescription>Set a new password before continuing.</DialogDescription>
|
2026-07-06 12:22:47 -05:00
|
|
|
</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}
|
2026-07-06 14:23:53 -05:00
|
|
|
onChange={(e) => setNewPw(e.target.value)}
|
2026-05-03 19:51:57 -05:00
|
|
|
required
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>Confirm password</Label>
|
|
|
|
|
<Input
|
|
|
|
|
type="password"
|
|
|
|
|
value={confirmPw}
|
2026-07-06 14:23:53 -05:00
|
|
|
onChange={(e) => setConfirmPw(e.target.value)}
|
2026-05-03 19:51:57 -05:00
|
|
|
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}>
|
2026-07-06 14:23:53 -05:00
|
|
|
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
|
2026-05-03 19:51:57 -05:00
|
|
|
<DialogHeader>
|
2026-07-06 12:22:47 -05:00
|
|
|
<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>
|
|
|
|
|
|
2026-07-06 12:22:47 -05:00
|
|
|
<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">
|
2026-07-06 14:23:53 -05:00
|
|
|
<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">
|
2026-07-06 14:23:53 -05:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|