Files
kvitt/web/src/features/auth/LoginPage.tsx
T
jacobh2 2ae65a5a9f
Build Kvitt images / build (push) Failing after 1h14m5s
W0 web foundation: Vite + React SPA under /app/, sync-pull ledger, auth, tokens, shared components
web/ is the browser client's scaffold: Vite 8, React 19, TypeScript 5.9 strict, React Router 7 (basename /app, every
fixed route with a lazy placeholder page), TanStack Query 5 (refetch on focus and every 60 s), openapi-typescript
types generated from contracts/openapi.yaml with openapi-fetch. Reads go through GET /sync (no REST reads exist for
the ledger, SPEC §7): useLedger() pulls from cursor 0 and then incrementally, restarting on a new epoch or
cursor_ahead. Login pairs the browser as "Web · <browser>" with an invite code (E-3); every call sends the SPEC
§14.3.1 headers, a 401 signs out. kvitt-domain's split, balances, allocation and settlement rules are ported to
web/src/lib and pass every case of contracts/test-vectors. Design tokens from design-spec §2 in tokens.css
(light-dark()), Material Symbols Rounded (Apache-2.0) as generated inline SVG, sv/en catalogs generated from
docs/ux/strings.csv plus web-strings.csv. Gate: cd web && npm run check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-21 21:03:25 +02:00

218 lines
8.3 KiB
TypeScript

import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Navigate, useLocation } from 'react-router';
import { ApiError, unwrap } from '../../api/client';
import { useApi, useSessionStore } from '../../api/useApi';
import { webDeviceName } from '../../app/buildInfo';
import { paths } from '../../app/routes';
import { useDocumentTitle } from '../../app/useDocumentTitle';
import { useSession } from '../../app/useSession';
import { Button } from '../../components/Button';
import { Card } from '../../components/Card';
import { TextField } from '../../components/TextField';
import { Icon } from '../../design/Icon';
import type { StringKey } from '../../i18n/catalog';
import { useI18n } from '../../i18n/useI18n';
import { formatInviteCode, inviterFromLink, isValidInviteCode, normaliseInviteCode } from './inviteCode';
import styles from './LoginPage.module.css';
/** SPEC §9.6: a display name is 1 to 40 code points after trimming. */
const DISPLAY_NAME_MAX = 40;
function errorKey(error: unknown): StringKey {
if (!(error instanceof ApiError)) return 'error_server';
switch (error.code) {
case 'network':
return 'onboarding_error_network';
case 'invite_invalid':
return 'onboarding_error_invite_invalid';
case 'rate_limited':
return 'onboarding_error_rate_limited';
case 'household_full':
return 'error_household_full';
case 'validation_failed':
case 'invalid_request':
return 'error_validation';
default:
return 'error_server';
}
}
/**
* Sign-in like a phone's pairing (S-1): the server is this origin, the user types an invite code (12 characters in
* groups of 4) and the browser becomes a device ("Web · <browser>") through E-3. A display name is asked for only
* when the code turns out to need one (`admin_setup` or `partner` invites: `invalid_request` on `display_name`).
* After a 401 it says why the session ended: S-18's text for `device_revoked` (D-130), otherwise "Du loggades ut".
*/
export default function LoginPage() {
const { t, tp } = useI18n();
const api = useApi();
const sessionStore = useSessionStore();
const session = useSession();
const queryClient = useQueryClient();
const location = useLocation();
const [code, setCode] = useState('');
const [inviter, setInviter] = useState<string | null>(null);
const [name, setName] = useState('');
const [needsName, setNeedsName] = useState(false);
const [codeError, setCodeError] = useState<string | null>(null);
const [nameError, setNameError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [landing, setLanding] = useState<string | null>(null);
useDocumentTitle(t('web_login_title'));
const from = (location.state as { from?: string } | null)?.from ?? paths.home;
if (session !== null) return <Navigate to={landing ?? from} replace />;
const signedOut = sessionStore.lastSignOut();
const onCodeChange = (text: string) => {
const name = inviterFromLink(text);
if (name !== null) setInviter(name);
setCode(formatInviteCode(normaliseInviteCode(text).slice(0, 12)));
setCodeError(null);
setFormError(null);
};
const submit = async (event: { preventDefault: () => void }) => {
event.preventDefault();
const normalised = normaliseInviteCode(code);
if (!isValidInviteCode(normalised)) {
setCodeError(t('onboarding_error_code_length'));
return;
}
const displayName = name.trim();
if (needsName) {
if (displayName.length === 0) {
setNameError(t('onboarding_error_name_required'));
return;
}
if (Array.from(displayName).length > DISPLAY_NAME_MAX) {
setNameError(tp('error_too_long', DISPLAY_NAME_MAX));
return;
}
}
setBusy(true);
setFormError(null);
try {
const accepted = await unwrap(
api.POST('/invites/accept', {
body: {
code: normalised,
device_name: webDeviceName(navigator.userAgent),
...(needsName ? { display_name: displayName } : {}),
},
}),
);
queryClient.clear();
// After a partner invite the first page is the profile, like the app's S-2 (AC-2.10, D-086).
setLanding(accepted.invite_kind === 'partner' ? paths.settingsProfile : from);
sessionStore.signIn({
token: accepted.token,
userId: accepted.user.id,
deviceId: accepted.device.id,
inviteKind: accepted.invite_kind,
});
} catch (error) {
if (error instanceof ApiError && error.code === 'invalid_request' && error.field === 'display_name') {
setNeedsName(true);
} else if (error instanceof ApiError && error.code === 'validation_failed' && error.field === 'display_name') {
setNeedsName(true);
setNameError(tp('error_too_long', DISPLAY_NAME_MAX));
} else if (error instanceof ApiError && error.code === 'invite_invalid') {
setCodeError(t('onboarding_error_invite_invalid'));
} else {
setFormError(t(errorKey(error)));
}
} finally {
setBusy(false);
}
};
const paste = async () => {
try {
onCodeChange(await navigator.clipboard.readText());
} catch (error) {
// Clipboard access refused or unavailable: the user types or pastes into the field instead.
if (!(error instanceof DOMException) && !(error instanceof TypeError)) throw error;
}
};
return (
<main className={styles.page}>
<div className={styles.column}>
<div className={styles.brand}>
<span className={styles.mark} aria-hidden="true">
<Icon name="receipt_long" filled size={32} />
</span>
<p className={styles.wordmark}>{t('common_wordmark')}</p>
<p className={styles.tagline}>{t('common_tagline')}</p>
</div>
<Card>
<h1 className={styles.title}>{t('web_login_title')}</h1>
{signedOut === 'device_revoked' ? (
<div className={styles.notice} role="status">
<p className={styles.noticeTitle}>{t('onboarding_removed_title')}</p>
<p>{t('onboarding_removed_new_code')}</p>
</div>
) : signedOut === 'unauthorized' ? (
<p className={styles.notice} role="status">
{t('web_signed_out')}
</p>
) : null}
<form className={styles.form} onSubmit={(event) => void submit(event)} noValidate>
<div className={styles.server}>
<span className={styles.serverLabel}>{t('web_login_server')}</span>
<span className={styles.serverValue}>
<Icon name="dns" size={18} />
{window.location.host}
</span>
</div>
<TextField
label={t('common_invite_code')}
value={code}
onChange={onCodeChange}
error={codeError}
supporting={inviter === null ? t('web_login_body') : t('onboarding_code_help', inviter)}
placeholder={t('onboarding_code_placeholder')}
autoComplete="one-time-code"
autoCapitalize="characters"
spellCheck={false}
autoFocus
inputClassName={styles.code}
suffix={
<Button variant="text" onClick={() => void paste()} icon={<Icon name="content_paste" size={20} />}>
{t('onboarding_code_paste')}
</Button>
}
/>
{needsName ? (
<>
<p className={styles.hint}>{t('web_login_name_needed')}</p>
<TextField
label={t('onboarding_name_label')}
value={name}
onChange={(value) => {
setName(value);
setNameError(null);
}}
error={nameError}
autoComplete="given-name"
/>
</>
) : null}
{formError === null ? null : (
<p className={styles.error} role="alert">
{formError}
</p>
)}
<Button type="submit" size="medium" disabled={busy}>
{busy ? t('onboarding_pairing') : t('onboarding_pair')}
</Button>
</form>
</Card>
</div>
</main>
);
}