SPB Git

Repository Public

Settings.tsx 23.3 KB 326 lines tsx

1// Account settings: profile, security (passkeys, recovery codes, authenticator app), devices, activity.2import { formatBytes } from '@spd/shared';3import { useQuery, useQueryClient } from '@tanstack/react-query';4import QRCode from 'qrcode';5import { type FormEvent, useEffect, useState } from 'react';6import { Link, Redirect } from 'wouter';7import { del, get, patch, post } from '../api.ts';8import { Glyph } from '../glyphs.tsx';9import { errorText, fmtDate, fmtRelative, getLocale, type Locale, useT } from '../i18n.ts';10import { addPasskey, deviceLabel, PasskeyCancelled, passkeysSupported, useAuth, useRefreshAuth } from '../session.ts';11import { Alert, changeLocale, Page, RecoveryCodesStep, Section, sensitive, summariseUA } from '../shell/account-ui.tsx';12import { setTheme, type ThemeChoice, useTheme } from '../theme.ts';13import { Badge, Button, ConfirmDialog, CopyField, cx, Dialog, EmptyState, ErrorState, IconButton, Loading, ProgressBar, Select, TextField, useToast } from '../ui.tsx';1415const TABS = [{ id: 'profil', icon: 'user' }, { id: 'securite', icon: 'shield' }, { id: 'appareils', icon: 'laptop' }, { id: 'activite', icon: 'activity' }] as const;16type TabId = (typeof TABS)[number]['id'];1718interface Passkey { id: string; label: string; deviceType: string; backedUp: boolean; transports: string[]; createdAt: string; lastUsedAt: string | null }19interface SessionRow { id: string; createdAt: string; lastSeenAt: string; ip: string; userAgent: string; method: string; current: boolean }20interface Security { recoveryCodesLeft: number; totpEnabled: boolean }21interface Activity { id: string; at: string; action: string; ip: string; userAgent: string; meta: unknown }2223const row = 'flex items-center gap-3 py-3';24const rows = 'divide-y divide-line';2526// --- profile -------------------------------------------------------------------------------------------------27function Profile() {28  const t = useT();29  const toast = useToast();30  const { state } = useAuth();31  const refresh = useRefreshAuth();32  const { choice } = useTheme();33  const user = state?.user;34  const [name, setName] = useState(user?.display_name ?? '');35  const [busy, setBusy] = useState(false);36  const [failure, setFailure] = useState<string | null>(null);37  if (!user) return <Loading />;38  const dirty = name.trim() !== user.display_name && name.trim().length > 0;39  const save = async (e: FormEvent) => {40    e.preventDefault();41    if (!dirty) return;42    setBusy(true); setFailure(null);43    try { await patch('/account', { displayName: name.trim() }); await refresh(); toast(t('account.profile.saved'), { tone: 'ok' }); } catch (err) { setFailure(errorText(err)); }44    setBusy(false);45  };46  const q = state.quota;47  const ratio = q && q.limit > 0 ? q.used / q.limit : 0;48  const themes: { id: ThemeChoice; icon: string }[] = [{ id: 'light', icon: 'sun' }, { id: 'dark', icon: 'moon' }, { id: 'system', icon: 'laptop' }];49  return (50    <>51      <Section title={t('account.profile.identity')}>52        <form onSubmit={save} className="flex flex-col gap-4" noValidate>53          <TextField label={t('account.profile.name')} value={name} onChange={(e) => setName(e.currentTarget.value)} maxLength={80} autoComplete="name" error={failure} />54          <TextField label={t('account.profile.email')} value={user.email} readOnly disabled hint={t('account.profile.emailHint')} />55          <div><Button type="submit" variant="primary" busy={busy} disabled={!dirty}>{t('common.save')}</Button></div>56        </form>57      </Section>58      <Section title={t('account.profile.appearance')}>59        <div className="flex flex-col gap-5">60          <Select label={t('account.profile.language')} value={getLocale()} onChange={(e) => { changeLocale(e.currentTarget.value as Locale, true); void refresh(); }}>61            <option value="fr">Français</option>62            <option value="en">English</option>63          </Select>64          <fieldset>65            <legend className="mb-1.5 text-sm font-medium text-ink">{t('account.profile.theme')}</legend>66            <div className="grid grid-cols-3 gap-2">67              {themes.map((x) => (68                <label key={x.id} className={cx('flex min-h-11 cursor-pointer items-center justify-center gap-2 rounded-xl border px-2 text-sm font-medium transition has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent',69                  choice === x.id ? 'border-accent bg-accent-soft text-accent' : 'border-line-strong bg-surface text-ink-2 hover:bg-hover')}>70                  <input type="radio" name="theme" value={x.id} checked={choice === x.id} onChange={() => setTheme(x.id)} className="sr-only" />71                  <Glyph name={x.icon} size={18} className="shrink-0" /><span className="truncate">{t(`account.theme.${x.id}`)}</span>72                </label>73              ))}74            </div>75          </fieldset>76        </div>77      </Section>78      {q ? (79        <Section title={t('account.profile.storage')}>80          <div className="flex flex-col gap-2">81            <p className="text-sm text-ink"><span className="text-2xl font-semibold tracking-tight">{formatBytes(q.used, getLocale())}</span> <span className="text-ink-2">{t('account.quota.of', { limit: formatBytes(q.limit, getLocale()) })}</span></p>82            <ProgressBar value={ratio} label={t('account.profile.storage')} />83            <p className="text-xs text-ink-3">{t('account.profile.storageHint')}</p>84          </div>85        </Section>86      ) : null}87    </>88  );89}9091// --- security ------------------------------------------------------------------------------------------------92function PasskeyNameDialog({ title, initial, confirm, icon, onSubmit, onClose }: { title: string; initial: string; confirm: string; icon?: string; onSubmit: (label: string) => Promise<void>; onClose: () => void }) {93  const t = useT();94  const [label, setLabel] = useState(initial);95  const [busy, setBusy] = useState(false);96  const [failure, setFailure] = useState<string | null>(null);97  const submit = async (e: FormEvent) => {98    e.preventDefault();99    if (!label.trim()) return;100    setBusy(true); setFailure(null);101    try { await onSubmit(label.trim()); onClose(); } catch (err) { setFailure(err instanceof PasskeyCancelled ? t('account.reauth.cancelled') : errorText(err)); setBusy(false); }102  };103  return (104    <Dialog title={title} onClose={onClose}>105      <form onSubmit={submit} className="flex flex-col gap-4" noValidate>106        <TextField label={t('account.passkey.name')} hint={t('account.passkey.nameHint')} value={label} onChange={(e) => setLabel(e.currentTarget.value)} maxLength={60} autoComplete="off" data-autofocus error={failure} />107        <div className="flex justify-end gap-2"><Button onClick={onClose}>{t('common.cancel')}</Button><Button type="submit" variant="primary" icon={icon} busy={busy} disabled={!label.trim()}>{confirm}</Button></div>108      </form>109    </Dialog>110  );111}112113function Passkeys() {114  const t = useT();115  const toast = useToast();116  const qc = useQueryClient();117  const q = useQuery({ queryKey: ['account', 'passkeys'], queryFn: () => get<Passkey[]>('/account/passkeys') });118  const [adding, setAdding] = useState(false);119  const [renaming, setRenaming] = useState<Passkey | null>(null);120  const [removing, setRemoving] = useState<Passkey | null>(null);121  const reload = () => qc.invalidateQueries({ queryKey: ['account', 'passkeys'] });122  const only = (q.data?.length ?? 0) <= 1;123  return (124    <Section title={t('account.passkey.title')} text={t('account.passkey.text')}125      action={<Button variant="primary" icon="plus" disabled={!passkeysSupported()} onClick={() => setAdding(true)}>{t('account.passkey.add')}</Button>}>126      {q.isLoading ? <Loading /> : q.error || !q.data ? <ErrorState error={q.error} onRetry={() => void q.refetch()} /> : (127        <ul className={rows} aria-label={t('account.passkey.title')}>128          {q.data.map((p) => (129            <li key={p.id} className={row} data-testid="passkey">130              <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-accent-soft text-accent"><Glyph name="key" size={20} /></span>131              <div className="min-w-0 flex-1">132                <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm font-medium text-ink"><span className="min-w-0 truncate">{p.label}</span>{p.backedUp ? <Badge tone="ok">{t('account.passkey.synced')}</Badge> : <Badge>{t('account.passkey.single')}</Badge>}</p>133                <p className="mt-0.5 text-xs text-ink-3">{t('account.passkey.created', { date: fmtDate(p.createdAt, { dateStyle: 'medium' }) })} · {p.lastUsedAt ? t('account.passkey.lastUsed', { when: fmtRelative(p.lastUsedAt) }) : t('account.passkey.neverUsed')}</p>134              </div>135              <IconButton icon="pencil" label={t('account.passkey.rename', { name: p.label })} onClick={() => setRenaming(p)} />136              <IconButton icon="trash" label={only ? t('account.passkey.lastOne') : t('account.passkey.remove', { name: p.label })} disabled={only} onClick={() => setRemoving(p)} />137            </li>138          ))}139        </ul>140      )}141      {only && q.data ? <p className="mt-1 text-xs text-ink-3">{t('account.passkey.lastOneHint')}</p> : null}142      {adding ? <PasskeyNameDialog title={t('account.passkey.addTitle')} initial={deviceLabel()} confirm={t('account.passkey.addConfirm')} icon="key" onClose={() => setAdding(false)}143        onSubmit={async (label) => { await addPasskey(label); await reload(); void qc.invalidateQueries({ queryKey: ['notifications'] }); toast(t('account.passkey.added'), { tone: 'ok' }); }} /> : null}144      {renaming ? <PasskeyNameDialog title={t('account.passkey.renameTitle')} initial={renaming.label} confirm={t('common.save')} onClose={() => setRenaming(null)}145        onSubmit={async (label) => { await patch(`/account/passkeys/${renaming.id}`, { label }); await reload(); }} /> : null}146      {removing ? <ConfirmDialog danger title={t('account.passkey.removeTitle')} text={t('account.passkey.removeText', { name: removing.label })} confirmLabel={t('account.passkey.removeConfirm')} onClose={() => setRemoving(null)}147        onConfirm={async () => { await sensitive(() => del(`/account/passkeys/${removing.id}`), t('account.reauth.cancelled')); await reload(); void qc.invalidateQueries({ queryKey: ['notifications'] }); toast(t('account.passkey.removed'), { tone: 'ok' }); }} /> : null}148    </Section>149  );150}151152function TotpSetup({ setup, onClose, onEnabled }: { setup: { challengeId: string; secret: string; uri: string }; onClose: () => void; onEnabled: () => void }) {153  const t = useT();154  const [qr, setQr] = useState<string | null>(null);155  const [code, setCode] = useState('');156  const [busy, setBusy] = useState(false);157  const [failure, setFailure] = useState<string | null>(null);158  useEffect(() => { let live = true; void QRCode.toDataURL(setup.uri, { margin: 2, width: 440, errorCorrectionLevel: 'M' }).then((u) => { if (live) setQr(u); }, () => undefined); return () => { live = false; }; }, [setup.uri]);159  const submit = async (e: FormEvent) => {160    e.preventDefault();161    if (code.length !== 6) return;162    setBusy(true); setFailure(null);163    try { await sensitive(() => post('/account/totp/enable', { challengeId: setup.challengeId, code }), t('account.reauth.cancelled')); onEnabled(); onClose(); } catch (err) { setFailure(errorText(err)); setBusy(false); }164  };165  return (166    <Dialog title={t('account.totp.setupTitle')} onClose={onClose}>167      <form onSubmit={submit} className="flex flex-col gap-4" noValidate>168        <p className="text-sm leading-relaxed text-ink-2">{t('account.totp.setupText')}</p>169        {/* a QR code is always dark on white, whatever the theme, so that every camera reads it */}170        <div className="mx-auto flex h-[220px] w-[220px] items-center justify-center rounded-2xl border border-line bg-white">{qr ? <img src={qr} width={220} height={220} alt={t('account.totp.qrAlt')} className="rounded-2xl" /> : <Loading />}</div>171        <div><p className="mb-1.5 text-sm font-medium text-ink">{t('account.totp.secret')}</p><CopyField value={setup.secret} label={t('account.totp.secret')} /></div>172        <TextField label={t('account.totp.code')} hint={t('account.totp.codeHint')} value={code} onChange={(e) => setCode(e.currentTarget.value.replace(/\D/g, '').slice(0, 6))} inputMode="numeric" autoComplete="one-time-code" maxLength={6} className="font-mono tracking-widest" error={failure} />173        <div className="flex justify-end gap-2"><Button onClick={onClose}>{t('common.cancel')}</Button><Button type="submit" variant="primary" busy={busy} disabled={code.length !== 6}>{t('account.totp.enable')}</Button></div>174      </form>175    </Dialog>176  );177}178179function RecoveryAndTotp() {180  const t = useT();181  const toast = useToast();182  const qc = useQueryClient();183  const { state } = useAuth();184  const q = useQuery({ queryKey: ['account', 'security'], queryFn: () => get<Security>('/account/security') });185  const [confirmCodes, setConfirmCodes] = useState(false);186  const [codes, setCodes] = useState<string[] | null>(null);187  const [setup, setSetup] = useState<{ challengeId: string; secret: string; uri: string } | null>(null);188  const [starting, setStarting] = useState(false);189  const [disabling, setDisabling] = useState(false);190  const reload = () => qc.invalidateQueries({ queryKey: ['account', 'security'] });191  const start = async () => {192    setStarting(true);193    try { setSetup(await sensitive(() => post<{ challengeId: string; secret: string; uri: string }>('/account/totp/setup'), t('account.reauth.cancelled'))); } catch (e) { toast(errorText(e), { tone: 'error' }); }194    setStarting(false);195  };196  const left = q.data?.recoveryCodesLeft ?? 0;197  return (198    <>199      <Section title={t('account.codes.sectionTitle')} text={t('account.codes.sectionText')} action={<Button icon="refresh" onClick={() => setConfirmCodes(true)}>{t('account.codes.regenerate')}</Button>}>200        {q.isLoading ? <Loading /> : q.error || !q.data ? <ErrorState error={q.error} onRetry={() => void q.refetch()} /> : (201          <div className="flex flex-col gap-3">202            <p className="flex items-center gap-2 text-sm text-ink" data-testid="codes-left"><Badge tone={left === 0 ? 'danger' : left <= 3 ? 'warn' : 'ok'}>{left} / 10</Badge>{t('account.codes.left', { n: left })}</p>203            {left <= 3 ? <Alert tone="warn">{t('account.codes.low')}</Alert> : null}204          </div>205        )}206      </Section>207      <Section title={t('account.totp.title')} text={t('account.totp.text')}208        action={q.data ? (q.data.totpEnabled ? <Button onClick={() => setDisabling(true)}>{t('account.totp.disable')}</Button> : <Button busy={starting} onClick={() => void start()}>{t('account.totp.setup')}</Button>) : null}>209        {q.isLoading ? <Loading /> : q.error || !q.data ? <ErrorState error={q.error} onRetry={() => void q.refetch()} />210          : <p className="flex items-center gap-2 text-sm text-ink-2" data-testid="totp-state">{q.data.totpEnabled ? <Badge tone="ok">{t('account.totp.on')}</Badge> : <Badge>{t('account.totp.off')}</Badge>}{q.data.totpEnabled ? t('account.totp.onText') : t('account.totp.offText')}</p>}211      </Section>212      {confirmCodes ? <ConfirmDialog title={t('account.codes.regenerateTitle')} text={t('account.codes.regenerateText')} confirmLabel={t('account.codes.regenerate')} onClose={() => setConfirmCodes(false)}213        onConfirm={async () => { const r = await sensitive(() => post<{ recoveryCodes: string[] }>('/account/recovery-codes'), t('account.reauth.cancelled')); setCodes(r.recoveryCodes); void reload(); }} /> : null}214      {codes ? (215        <Dialog title={t('account.codes.title')} onClose={() => setCodes(null)} dismissable={false} wide>216          <div className="flex flex-col gap-4">217            <p className="text-sm leading-relaxed text-ink-2">{t('account.codes.introNew')}</p>218            <RecoveryCodesStep codes={codes} email={state?.user?.email} onDone={() => setCodes(null)} doneLabel={t('account.codes.done')} />219          </div>220        </Dialog>221      ) : null}222      {setup ? <TotpSetup setup={setup} onClose={() => setSetup(null)} onEnabled={() => { void reload(); toast(t('account.totp.enabled'), { tone: 'ok' }); }} /> : null}223      {disabling ? <ConfirmDialog danger title={t('account.totp.disableTitle')} text={t('account.totp.disableText')} confirmLabel={t('account.totp.disable')} onClose={() => setDisabling(false)}224        onConfirm={async () => { await sensitive(() => del('/account/totp'), t('account.reauth.cancelled')); await reload(); toast(t('account.totp.disabled'), { tone: 'ok' }); }} /> : null}225    </>226  );227}228229// --- devices -------------------------------------------------------------------------------------------------230function Devices() {231  const t = useT();232  const toast = useToast();233  const qc = useQueryClient();234  const q = useQuery({ queryKey: ['account', 'sessions'], queryFn: () => get<SessionRow[]>('/account/sessions') });235  const [revoking, setRevoking] = useState<SessionRow | null>(null);236  const [all, setAll] = useState(false);237  const reload = () => qc.invalidateQueries({ queryKey: ['account', 'sessions'] });238  const others = q.data?.filter((s) => !s.current).length ?? 0;239  return (240    <Section title={t('account.devices.title')} text={t('account.devices.text')} action={<Button icon="logout" disabled={!others} onClick={() => setAll(true)}>{t('account.devices.revokeOthers')}</Button>}>241      {q.isLoading ? <Loading /> : q.error || !q.data ? <ErrorState error={q.error} onRetry={() => void q.refetch()} /> : !q.data.length ? <EmptyState icon="laptop" title={t('account.devices.empty')} /> : (242        <ul className={rows} aria-label={t('account.devices.title')}>243          {q.data.map((s) => {244            const ua = summariseUA(s.userAgent, t('account.devices.unknown'));245            return (246              <li key={s.id} className={row} data-testid="session" data-current={s.current || undefined}>247                <span className={cx('flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl', s.current ? 'bg-accent-soft text-accent' : 'bg-hover text-ink-2')}><Glyph name={ua.icon} size={20} /></span>248                <div className="min-w-0 flex-1">249                  <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm font-medium text-ink"><span className="min-w-0 truncate" title={s.userAgent}>{ua.text}</span>{s.current ? <Badge tone="accent">{t('account.devices.current')}</Badge> : null}{s.method === 'recovery' ? <Badge tone="warn">{t('account.devices.recovery')}</Badge> : null}</p>250                  <p className="mt-0.5 text-xs break-words text-ink-3">{s.ip || '—'} · {s.current ? t('account.devices.activeNow') : t('account.devices.lastSeen', { when: fmtRelative(s.lastSeenAt) })} · {t('account.devices.since', { date: fmtDate(s.createdAt, { dateStyle: 'medium' }) })}</p>251                </div>252                {s.current ? null : <Button size="sm" className="h-10" onClick={() => setRevoking(s)} aria-label={t('account.devices.revokeLabel', { name: ua.text })}>{t('account.devices.revoke')}</Button>}253              </li>254            );255          })}256        </ul>257      )}258      {revoking ? <ConfirmDialog danger title={t('account.devices.revokeTitle')} text={t('account.devices.revokeText', { name: summariseUA(revoking.userAgent, t('account.devices.unknown')).text })} confirmLabel={t('account.devices.revoke')} onClose={() => setRevoking(null)}259        onConfirm={async () => { await del(`/account/sessions/${revoking.id}`); await reload(); toast(t('account.devices.revoked'), { tone: 'ok' }); }} /> : null}260      {all ? <ConfirmDialog danger title={t('account.devices.revokeOthers')} text={t('account.devices.revokeOthersText')} confirmLabel={t('account.devices.revokeOthersConfirm')} onClose={() => setAll(false)}261        onConfirm={async () => { const r = await post<{ revoked: number }>('/account/sessions/revoke-others'); await reload(); toast(t('account.devices.revokedN', { n: r.revoked }), { tone: 'ok' }); }} /> : null}262    </Section>263  );264}265266// --- activity ------------------------------------------------------------------------------------------------267const ACTION: Record<string, { icon: string; tone: 'neutral' | 'ok' | 'warn' | 'danger' }> = {268  'auth.login': { icon: 'key', tone: 'ok' }, 'auth.login_failed': { icon: 'warning', tone: 'danger' }, 'auth.logout': { icon: 'logout', tone: 'neutral' },269  'auth.recovery_used': { icon: 'shield', tone: 'warn' }, 'auth.recovery_failed': { icon: 'warning', tone: 'danger' }, 'account.created': { icon: 'user', tone: 'ok' },270  'account.passkey_added': { icon: 'key', tone: 'ok' }, 'account.passkey_removed': { icon: 'key', tone: 'warn' }, 'account.session_revoked': { icon: 'laptop', tone: 'neutral' },271  'account.sessions_revoked': { icon: 'laptop', tone: 'neutral' }, 'account.recovery_codes_regenerated': { icon: 'refresh', tone: 'warn' }, 'account.totp_enabled': { icon: 'lock', tone: 'ok' },272  'account.totp_disabled': { icon: 'lock', tone: 'warn' },273};274const TONE = { neutral: 'bg-hover text-ink-2', ok: 'bg-ok-soft text-ok', warn: 'bg-warn-soft text-warn', danger: 'bg-danger-soft text-danger' };275function ActivityList() {276  const t = useT();277  const q = useQuery({ queryKey: ['account', 'activity'], queryFn: () => get<Activity[]>('/account/activity') });278  return (279    <Section title={t('account.activity.title')} text={t('account.activity.text')}>280      {q.isLoading ? <Loading /> : q.error || !q.data ? <ErrorState error={q.error} onRetry={() => void q.refetch()} /> : !q.data.length ? <EmptyState icon="activity" title={t('account.activity.empty')} /> : (281        <ul className={rows} aria-label={t('account.activity.title')}>282          {q.data.map((e) => {283            const a = ACTION[e.action];284            const ua = e.userAgent ? summariseUA(e.userAgent, '').text : '';285            return (286              <li key={e.id} className="flex items-start gap-3 py-3" data-action={e.action}>287                <span className={cx('flex h-9 w-9 shrink-0 items-center justify-center rounded-full', TONE[a?.tone ?? 'neutral'])}><Glyph name={a?.icon ?? 'info'} size={17} /></span>288                <div className="min-w-0 flex-1">289                  <p className="text-sm font-medium text-ink">{a ? t(`account.activity.${e.action}`) : e.action}</p>290                  <p className="mt-0.5 text-xs break-words text-ink-3">{[fmtDate(e.at), e.ip, ua].filter(Boolean).join(' · ')}</p>291                </div>292              </li>293            );294          })}295        </ul>296      )}297    </Section>298  );299}300301export function Settings({ tab }: { tab?: string }) {302  const t = useT();303  if (tab && !TABS.some((x) => x.id === tab)) return <Redirect to="/settings/profil" replace />;304  const current: TabId = (tab as TabId | undefined) ?? 'profil';305  return (306    <Page title={t('account.settings.title')}>307      <nav aria-label={t('account.settings.title')} className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0">308        <ul className="flex w-max min-w-full gap-1 border-b border-line">309          {TABS.map((x) => {310            const on = x.id === current;311            return (312              <li key={x.id}>313                <Link href={`/settings/${x.id}`} aria-current={on ? 'page' : undefined}314                  className={cx('-mb-px flex h-11 items-center gap-2 border-b-2 px-3 text-sm font-medium whitespace-nowrap transition', on ? 'border-accent text-accent' : 'border-transparent text-ink-2 hover:text-ink')}>315                  <Glyph name={x.icon} size={18} />{t(`account.settings.tab.${x.id}`)}316                </Link>317              </li>318            );319          })}320        </ul>321      </nav>322      {current === 'profil' ? <Profile /> : current === 'securite' ? <><Passkeys /><RecoveryAndTotp /></> : current === 'appareils' ? <Devices /> : <ActivityList />}323    </Page>324  );325}326
Files main
Menu

Theme