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 · ") 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(null); const [name, setName] = useState(''); const [needsName, setNeedsName] = useState(false); const [codeError, setCodeError] = useState(null); const [nameError, setNameError] = useState(null); const [formError, setFormError] = useState(null); const [busy, setBusy] = useState(false); const [landing, setLanding] = useState(null); useDocumentTitle(t('web_login_title')); const from = (location.state as { from?: string } | null)?.from ?? paths.home; if (session !== null) return ; 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 (

{t('common_wordmark')}

{t('common_tagline')}

{t('web_login_title')}

{signedOut === 'device_revoked' ? (

{t('onboarding_removed_title')}

{t('onboarding_removed_new_code')}

) : signedOut === 'unauthorized' ? (

{t('web_signed_out')}

) : null}
void submit(event)} noValidate>
{t('web_login_server')} {window.location.host}
void paste()} icon={}> {t('onboarding_code_paste')} } /> {needsName ? ( <>

{t('web_login_name_needed')}

{ setName(value); setNameError(null); }} error={nameError} autoComplete="given-name" /> ) : null} {formError === null ? null : (

{formError}

)}
); }