Repository Public
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