SPB Git

Repository Public

Invite.tsx 4.1 KB 72 lines tsx

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
Files main
Menu

Theme