Repository Public
1// Account creation from a one-time invitation: a name, a passkey, then the recovery codes shown once.2import { useQuery, useQueryClient } from '@tanstack/react-query';3import { type FormEvent, useState } from 'react';4import { Link, useLocation } from 'wouter';5import { ApiError, patch, post } from '../api.ts';6import { errorText, getLocale, useT } from '../i18n.ts';7import { AUTH_KEY, deviceLabel, PasskeyCancelled, passkeysSupported, register } from '../session.ts';8import { Alert, AuthFrame, linkCls, RecoveryCodesStep } from '../shell/account-ui.tsx';9import { Button, EmptyState, ErrorState, Loading, TextField } from '../ui.tsx';1011export function Invite({ token }: { token: string }) {12 const t = useT();13 const qc = useQueryClient();14 const [, navigate] = useLocation();15 const invite = useQuery({ queryKey: ['invite', token], queryFn: () => post<{ email: string }>('/auth/invite', { invite: token }), staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnWindowFocus: false });16 const [name, setName] = useState('');17 const [label, setLabel] = useState(deviceLabel());18 const [busy, setBusy] = useState(false);19 const [failure, setFailure] = useState<string | null>(null);20 const [cancelled, setCancelled] = useState(false);21 const [codes, setCodes] = useState<string[] | null>(null);2223 if (codes) {24 const done = async () => { await qc.invalidateQueries({ queryKey: AUTH_KEY }); navigate('/', { replace: true }); };25 return (26 <AuthFrame wide title={t('account.codes.title')} subtitle={t('account.codes.intro')}>27 <RecoveryCodesStep codes={codes} email={invite.data?.email} onDone={() => void done()} doneLabel={t('account.invite.finish')} />28 </AuthFrame>29 );30 }31 if (invite.isLoading) return <Loading />;32 if (invite.error || !invite.data) {33 const e = invite.error;34 const invalid = e instanceof ApiError && (e.status === 403 || e.status === 400 || e.code === 'invite_invalid');35 return (36 <AuthFrame title={t('account.invite.title')}>37 {invalid38 ? <EmptyState icon="link" title={t('account.invite.invalidTitle')} text={t('account.invite.invalidText')} action={<Link href="/login" className={linkCls}>{t('account.invite.toLogin')}</Link>} />39 : <ErrorState error={e} onRetry={() => void invite.refetch()} />}40 </AuthFrame>41 );42 }43 const supported = passkeysSupported();44 const submit = async (e: FormEvent) => {45 e.preventDefault();46 if (!name.trim()) return;47 setBusy(true); setFailure(null); setCancelled(false);48 try {49 const c = await register(token, name.trim(), label.trim());50 void patch('/account', { locale: getLocale() }).catch(() => undefined);51 setCodes(c);52 } catch (err) {53 if (err instanceof PasskeyCancelled) setCancelled(true);54 else if (err instanceof ApiError && err.code === 'invite_invalid') { setFailure(errorText(err)); void invite.refetch(); }55 else setFailure(errorText(err));56 } finally { setBusy(false); }57 };58 return (59 <AuthFrame title={t('account.invite.title')} subtitle={<>{t('account.invite.subtitle')} <strong className="font-semibold break-all text-ink">{invite.data.email}</strong></>}>60 <form onSubmit={submit} className="flex flex-col gap-4" noValidate>61 {!supported ? <Alert tone="warn">{t('account.login.unsupported')}</Alert> : null}62 {failure ? <Alert>{failure}</Alert> : null}63 <TextField label={t('account.invite.name')} value={name} onChange={(e) => setName(e.currentTarget.value)} maxLength={80} required autoComplete="name" autoFocus />64 <TextField label={t('account.passkey.name')} hint={t('account.passkey.nameHint')} value={label} onChange={(e) => setLabel(e.currentTarget.value)} maxLength={60} autoComplete="off" />65 <Button type="submit" variant="primary" size="lg" icon="key" busy={busy} disabled={!supported || !name.trim()} className="w-full">{t('account.invite.button')}</Button>66 {cancelled ? <p role="status" className="text-center text-sm text-ink-2">{t('account.login.cancelled')}</p> : null}67 <p className="text-xs leading-relaxed text-ink-3">{t('account.login.hint')}</p>68 </form>69 </AuthFrame>70 );71}72