SPB Git

Repository Public

security.tsx 8.5 KB 159 lines tsx

1'use client';2import { useState } from 'react';3import { useQueryClient } from '@tanstack/react-query';4import type { Device } from '@cr/shared';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
Files main
Menu

Theme