W0 web foundation: Vite + React SPA under /app/, sync-pull ledger, auth, tokens, shared components
Build Kvitt images / build (push) Failing after 1h14m5s
Build Kvitt images / build (push) Failing after 1h14m5s
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
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user