Repository Public
1'use client';2import { useState } from 'react';3import { useQueryClient } from '@tanstack/react-query';4import type { Device } from '@/lib/dto-auth';5import { KeyRound, Monitor } from 'lucide-react';6import { api, errorMessage } from '@/lib/api';7import { qk, useAuthStatus, useDevices } from '@/lib/queries';8import { fmtDateTime, fmtRelative } from '@/lib/format';9import { passwordChecks } from '@/lib/password';10import { toast } from '@/stores/toasts';11import { Badge } from '@/components/ui/badge';12import { Button } from '@/components/ui/button';13import { ConfirmDialog, Dialog } from '@/components/ui/dialog';14import { Field, Input } from '@/components/ui/input';15import { EmptyState, ErrorState, Section, Skeleton } from '@/components/ui/misc';16import { PasskeyEnroll, TotpEnroll } from '@/components/auth/second-factor';1718export function SecuritySettings() {19 const qc = useQueryClient();20 const auth = useAuthStatus();21 const devices = useDevices();22 const [revoking, setRevoking] = useState<Device | null>(null);23 const [dialog, setDialog] = useState<'passkey' | 'totp' | 'password' | 'totp-remove' | null>(null);24 const refresh = () => {25 void qc.invalidateQueries({ queryKey: qk.devices });26 void qc.invalidateQueries({ queryKey: qk.auth });27 };28 const m = auth.data?.methods;29 const logout = async () => {30 try {31 await api.post('/auth/logout');32 } finally {33 api.clearCsrf();34 location.assign('/login/');35 }36 };37 return (38 <>39 <Section title="Sign-in methods">40 <ul className="divide-y divide-line text-xs">41 <li className="flex flex-wrap items-center gap-3 px-4 py-3">42 <span className="min-w-0 flex-1"><span className="block text-[13px] text-text">Password</span><span className="text-muted">Signed in as {auth.data?.user?.username ?? '—'}</span></span>43 <Button size="sm" onClick={() => setDialog('password')}>Change password</Button>44 </li>45 <li className="flex flex-wrap items-center gap-3 px-4 py-3">46 <span className="min-w-0 flex-1"><span className="block text-[13px] text-text">Authenticator app (TOTP)</span><span className="text-muted">{m?.totp ? 'Enabled' : 'Not enabled'}</span></span>47 <Button size="sm" onClick={() => setDialog('totp')}>{m?.totp ? 'Re-enrol' : 'Enable'}</Button>48 {m?.totp && <Button size="sm" variant="ghost" disabled={(m?.passkeys ?? 0) === 0} title={(m?.passkeys ?? 0) === 0 ? 'Add a passkey first: one second factor must remain' : undefined} onClick={() => setDialog('totp-remove')}>Remove</Button>}49 </li>50 <li className="flex flex-wrap items-center gap-3 px-4 py-3">51 <span className="min-w-0 flex-1"><span className="block text-[13px] text-text">Passkeys</span><span className="tnum text-muted">{m ? `${m.passkeys} registered` : '—'}</span></span>52 <Button size="sm" onClick={() => setDialog('passkey')} data-testid="add-passkey"><KeyRound className="size-3.5" /> Add passkey</Button>53 </li>54 </ul>55 </Section>56 <Section title="Devices and sessions" aside={<Button size="sm" variant="ghost" onClick={logout}>Sign out of this browser</Button>}>57 {devices.isPending ? <div className="p-4"><Skeleton className="h-16" /></div>58 : devices.isError ? <ErrorState error={devices.error} onRetry={() => devices.refetch()} />59 : devices.data.length === 0 ? <EmptyState title="No devices" className="py-8" />60 : (61 <ul className="divide-y divide-line" data-testid="devices">62 {devices.data.map((d) => (63 <li key={d.id} className="flex flex-wrap items-center gap-3 px-4 py-2.5 text-xs">64 {d.kind === 'passkey' ? <KeyRound className="size-4 shrink-0 text-muted" aria-label="Passkey" /> : <Monitor className="size-4 shrink-0 text-muted" aria-label="Browser session" />}65 <span className="min-w-0 flex-1 basis-48">66 <span className="flex items-center gap-2 text-[13px] text-text">{d.label}{d.current && <Badge tone="accent">this browser</Badge>}</span>67 <span className="tnum block truncate text-[11px] text-muted" title={d.userAgent ?? undefined}>68 created {fmtDateTime(d.createdAt)} · last used {d.lastUsedAt ? fmtRelative(d.lastUsedAt) : 'never'}{d.ip ? ` · ${d.ip}` : ''}{d.userAgent ? ` · ${d.userAgent}` : ''}69 </span>70 </span>71 <Button size="sm" variant="danger" onClick={() => setRevoking(d)}>Revoke</Button>72 </li>73 ))}74 </ul>75 )}76 </Section>77 <ConfirmDialog78 open={!!revoking}79 onOpenChange={(o) => !o && setRevoking(null)}80 title={revoking?.kind === 'passkey' ? 'Remove passkey?' : 'Revoke session?'}81 description={revoking?.current ? 'This is the browser you are using: you will be signed out.' : revoking?.kind === 'passkey' ? `“${revoking.label}” will no longer be able to sign in.` : 'That browser will be signed out immediately.'}82 confirmLabel="Revoke"83 danger84 onConfirm={async () => {85 if (!revoking) return;86 try {87 await api.delete(`/auth/devices/${revoking.id}`);88 if (revoking.current) location.assign('/login/');89 else refresh();90 } catch (e) {91 toast.error('Could not revoke', errorMessage(e));92 }93 }}94 />95 <ConfirmDialog96 open={dialog === 'totp-remove'}97 onOpenChange={(o) => !o && setDialog(null)}98 title="Remove authenticator app?"99 description="Sign-in will rely on your passkeys as the second factor."100 confirmLabel="Remove"101 danger102 onConfirm={async () => {103 try {104 await api.delete('/auth/totp');105 refresh();106 } catch (e) {107 toast.error('Could not remove TOTP', errorMessage(e));108 }109 }}110 />111 <Dialog open={dialog === 'passkey'} onOpenChange={(o) => !o && setDialog(null)} title="Add passkey">112 <PasskeyEnroll onDone={() => { setDialog(null); refresh(); toast.success('Passkey added'); }} />113 </Dialog>114 <Dialog open={dialog === 'totp'} onOpenChange={(o) => !o && setDialog(null)} title="Authenticator app" description="Enrolling again replaces the previous secret once the new code is verified.">115 <TotpEnroll onDone={() => { setDialog(null); refresh(); toast.success('Authenticator app enabled'); }} />116 </Dialog>117 <ChangePassword open={dialog === 'password'} onClose={() => setDialog(null)} username={auth.data?.user?.username ?? ''} />118 </>119 );120}121122function ChangePassword({ open, onClose, username }: { open: boolean; onClose: () => void; username: string }) {123 const [cur, setCur] = useState('');124 const [next, setNext] = useState('');125 const [confirm, setConfirm] = useState('');126 const [busy, setBusy] = useState(false);127 const [error, setError] = useState<string | null>(null);128 const checks = passwordChecks(next, username);129 const valid = cur.length > 0 && checks.every((c) => !c.required || c.ok) && next === confirm;130 const submit = async () => {131 setBusy(true);132 setError(null);133 try {134 await api.post('/auth/password', { currentPassword: cur, newPassword: next });135 setCur(''); setNext(''); setConfirm('');136 toast.success('Password changed');137 onClose();138 } catch (e) {139 setError(errorMessage(e));140 } finally {141 setBusy(false);142 }143 };144 return (145 <Dialog146 open={open}147 onOpenChange={(o) => !o && onClose()}148 title="Change password"149 footer={<><Button variant="ghost" onClick={onClose}>Cancel</Button><Button variant="primary" loading={busy} disabled={!valid} onClick={submit}>Change password</Button></>}150 >151 <form className="flex flex-col gap-4" onSubmit={(e) => { e.preventDefault(); if (valid) void submit(); }}>152 <Field label="Current password" error={error}><Input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" /></Field>153 <Field label="New password" hint={checks.filter((c) => c.required && !c.ok).map((c) => c.label).join(' · ') || 'Meets the requirements.'}><Input type="password" value={next} onChange={(e) => setNext(e.target.value)} autoComplete="new-password" /></Field>154 <Field label="Confirm new password" error={confirm && confirm !== next ? 'Passwords do not match.' : null}><Input type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" /></Field>155 </form>156 </Dialog>157 );158}159