SPB Git

Repository Public

Security.tsx 9.7 KB 90 lines tsx

1import { startAuthentication, startRegistration } from '@simplewebauthn/browser';2import { useQuery, useQueryClient } from '@tanstack/react-query';3import { useContext, useState } from 'react';4import { get, post } from '../api';5import { useNetworks } from '../hooks';6import { errText, I18n, useT } from '../i18n';7import { Btn, Card, Coin, H, Icon, inputCls, Notice, Skeleton, when } from '../ui';89const fmt = (v: string, d: number) => { const s = v.padStart(d + 1, '0'); const i = s.slice(0, s.length - d), f = s.slice(s.length - d).replace(/0+$/, ''); return f ? `${i}.${f}` : i; };1011export function Security() {12  const t = useT(); const { locale } = useContext(I18n); const qc = useQueryClient();13  const nets = useNetworks();14  const ov = useQuery({ queryKey: ['security'], queryFn: () => get<any>('/security/overview'), refetchInterval: 30_000 });15  const au = useQuery({ queryKey: ['audit'], queryFn: () => get<any>('/security/audit?limit=60'), refetchInterval: 30_000 });16  const [showAudit, setShowAudit] = useState(false), [allLimits, setAllLimits] = useState(false), [lq, setLq] = useState('');17  const [halt, setHalt] = useState(''), [haltOpen, setHaltOpen] = useState(false), [busy, setBusy] = useState(''), [err, setErr] = useState('');18  if (ov.isLoading || !ov.data) return <div className="space-y-4"><Skeleton h="h-40" /><Skeleton h="h-40" /></div>;19  const s = ov.data.signer;20  const act = async (name: string, fn: () => Promise<unknown>) => { setBusy(name); setErr(''); try { await fn(); await qc.invalidateQueries(); } catch (e) { setErr(errText(t, e)); } finally { setBusy(''); } };21  const addDevice = () => act('device', async () => {22    const o = await post('/security/devices/options');23    const response = await startRegistration({ optionsJSON: o.options });24    const v = await post('/security/devices/verify', { challengeId: o.challengeId, response, label: `Passkey ${ov.data.devices.length + 1}` });25    const assertion = await startAuthentication({ optionsJSON: v.options }); // an existing passkey authorizes the new one26    await post('/security/devices/confirm', { pendingId: v.pendingId, assertion });27  });28  const keys: string[] = s.reachable ? Object.keys(s.policy.perTx).sort() : [];29  const nameOf = (net: string) => nets.data?.find((x) => x.id === net)?.name ?? net;30  const lf = lq.trim().toLowerCase(), shown = !allLimits || !lf ? [...keys] : keys.filter((k) => { const [net, sym] = k.split(':') as [string, string]; return sym.toLowerCase().includes(lf) || nameOf(net).toLowerCase().includes(lf) || net.includes(lf); });31  const decOf = (net: string, sym: string) => { const n = nets.data?.find((x) => x.id === net); return (n?.native.symbol === sym ? n.native : n?.tokens.find((x) => x.symbol === sym))?.decimals ?? 0; };32  return (33    <div className="space-y-4">34      <h1 className="pt-2 text-[30px] font-semibold tracking-[-0.02em]">{t('security')}</h1>35      {err && <Notice tone="danger">{err}</Notice>}36      <Card>37        <H>{t('signer')}</H>38        {!s.reachable ? <Notice tone="danger">{t('signer')} : {t('unreachable')}</Notice> : <>39          <div className={`flex items-center gap-2 text-[15px] font-medium ${s.halted ? 'text-danger' : 'text-emerald'}`}><Icon n={s.halted ? 'stop' : 'check'} />{s.halted ? t('signingOff') : t('signingOn')}</div>40          <dl className="mt-3 space-y-1.5 text-[13px] text-mute"><div className="flex justify-between gap-3"><dt>{t('keyId')}</dt><dd className="font-mono text-ink">{s.keyId}</dd></div>41            <div className="flex justify-between gap-3"><dt>{t('signerJournal')}</dt><dd className="text-ink">{s.journalLength} {s.journalLength === 1 ? t('qaEntries1') : t('entries')}</dd></div></dl>42          {!s.halted && (haltOpen ? <div className="mt-4 space-y-3"><Notice tone="danger">{t('haltConfirm')}</Notice>43            <input className={inputCls} value={halt} onChange={(e) => setHalt(e.target.value)} placeholder="STOP" autoCapitalize="characters" aria-label="STOP" />44            <div className="grid grid-cols-2 gap-3"><Btn kind="ghost" onClick={() => { setHaltOpen(false); setHalt(''); }}>{t('cancel')}</Btn>45              <Btn kind="danger" busy={busy === 'halt'} disabled={halt.trim().toUpperCase() !== 'STOP'} onClick={() => act('halt', () => post('/security/emergency-halt'))}>{t('halt')}</Btn></div></div>46            : <Btn kind="danger" full className="mt-4" onClick={() => setHaltOpen(true)}><Icon n="stop" />{t('halt')}</Btn>)}47        </>}48      </Card>4950      {s.reachable && <Card><H right={allLimits ? <span className="text-[12.5px] text-mute">{t('qaLimitsShown', { n: shown.length, total: keys.length })}</span> : undefined}>{t('limits')}</H>51        {allLimits && <input className={`${inputCls} mb-4`} type="search" placeholder={t('qaLimitsSearch')} aria-label={t('qaLimitsSearch')} value={lq} onChange={(e) => setLq(e.target.value)} autoCapitalize="off" autoCorrect="off" spellCheck={false} />}52        {allLimits && shown.length === 0 && <p className="py-4 text-center text-[14px] text-mute">{t('qaLimitsNone')}</p>}53        <ul className={`space-y-4 ${allLimits ? 'max-h-[60vh] overflow-y-auto pr-1' : ''}`}>{shown.sort((x, y) => Number(BigInt(s.spentToday[y] ?? '0') > 0n) - Number(BigInt(s.spentToday[x] ?? '0') > 0n) || Number(y.endsWith(':USDC')) - Number(x.endsWith(':USDC')) || x.localeCompare(y)).slice(0, allLimits ? 999 : 6).map((k) => { const [net, sym] = k.split(':') as [string, string];54          const dec = decOf(net, sym);55          const day = BigInt(s.policy.daily[k] ?? '0'), used = BigInt(s.spentToday[k] ?? '0'), pct = day > 0n ? Number((used * 100n) / day) : 0;56          return <li key={k}>57            <div className="flex items-center gap-3"><Coin sym={sym} net={net} size={36} />58              <div className="min-w-0 flex-1"><div className="text-[14.5px] font-semibold leading-tight">{sym} <span className="font-normal text-mute">· {nameOf(net)}</span></div>59                <div className="text-[12.5px] text-mute">{fmt(s.policy.perTx[k], dec)} {t('perTxShort')}</div></div>60              <div className="text-right"><div className="text-[14.5px] font-semibold leading-tight">{fmt((day - used > 0n ? day - used : 0n).toString(), dec)}</div><div className="text-[12.5px] text-mute">{t('remaining')}</div></div></div>61            <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-elev" role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label={`${sym} ${nameOf(net)}`}><div className="h-full rounded-full bg-blue" style={{ width: `${Math.min(100, pct)}%` }} /></div>62          </li>; })}</ul>63        {keys.length > 6 && <button className="mt-2 min-h-11 text-[14px] font-medium text-blue" aria-expanded={allLimits} onClick={() => { setAllLimits(!allLimits); setLq(''); }}>{allLimits ? t('showLess') : `${t('showAll')} (${keys.length})`}</button>}64        <p className="mt-4 text-[12.5px] text-mute">{t('limitsNote')}</p></Card>}6566      <Card><H>{t('devices')}</H>67        <ul className="space-y-3">{ov.data.devices.map((d: any) => <li key={d.id} className="flex items-center gap-3"><span className="grid h-11 w-11 place-items-center rounded-full bg-elev"><Icon n="key" /></span>68          <div className="min-w-0 flex-1"><div className="font-medium">{d.label} <span className="font-mono text-xs text-mute">{d.id}…</span></div>69            <div className="text-xs text-mute">{d.backedUp ? t('synced') : t('deviceBound')} · {t('lastUsed')} {d.lastUsedAt ? when(d.lastUsedAt, locale) : t('never')}</div></div>70          <span className={`text-xs ${d.enrolledOnSigner ? 'text-emerald' : 'text-warn'}`}><Icon n={d.enrolledOnSigner ? 'check' : 'warn'} className="inline h-4 w-4" /></span></li>)}</ul>71        <Btn kind="soft" full className="mt-4" busy={busy === 'device'} onClick={addDevice}><Icon n="key" />{t('addDevice')}</Btn>72        <p className="mt-2 text-xs text-mute">{t('addDeviceNote')}</p></Card>7374      <Card><H>{t('recovery')}</H><p className="text-[13.5px] text-mute">{t('recoveryBody')}</p>75        <pre className="mt-2 overflow-x-auto rounded-2xl bg-elev/70 p-3 font-mono text-[12px]">node ~/apps/sp-wallet-signer/signer.mjs export-recovery</pre>76        <div className="mt-3"><Notice tone="warn">{t('recoveryWarn')}</Notice></div></Card>7778      <Card><H>{t('sessions')}</H>79        <ul className="space-y-2 text-[13px]">{ov.data.sessions.map((x: any, k: number) => <li key={k} className="flex justify-between gap-3"><span className="truncate text-mute">{x.ua.replace(/Mozilla\/5.0 \(([^)]+)\).*/, '$1').slice(0, 40)}</span><span className="shrink-0">{when(x.last_seen, locale)}</span></li>)}</ul>80        <Btn kind="ghost" full className="mt-4" busy={busy === 'revoke'} onClick={() => act('revoke', () => post('/security/sessions/revoke-all'))}>{t('revokeAll')}</Btn></Card>8182      <Card><H right={au.data && <span className={`inline-flex items-center gap-1 text-xs ${au.data.intact ? 'text-emerald' : 'text-danger'}`}><Icon n={au.data.intact ? 'check' : 'warn'} className="h-3.5 w-3.5" />{au.data.intact ? t('auditIntact') : `${t('auditBroken')} ${au.data.brokenAt}`}</span>}>{t('audit')}</H>83        <button className="min-h-11 text-[14px] font-medium text-blue" aria-expanded={showAudit} onClick={() => setShowAudit(!showAudit)}>{showAudit ? t('hideAudit') : `${t('seeAudit')} (${au.data?.total ?? 0})`}</button>84        {showAudit && <ul className="mt-3 max-h-96 space-y-2 overflow-y-auto text-[12.5px]">{(au.data?.events ?? []).map((e: any) => <li key={e.seq} className="rounded-xl bg-elev/70 px-3 py-2">85          <div className="flex justify-between gap-2"><span className="font-mono font-medium">{e.type}</span><span className="shrink-0 text-mute">#{e.seq} · {when(e.ts, locale)}</span></div>86          <div className="truncate font-mono text-[11px] text-mute">{Object.entries(e.data).filter(([, v]) => v !== null && v !== undefined).map(([k, v]) => `${k}=${String(v).slice(0, 24)}`).join(' ')}</div></li>)}</ul>}</Card>87    </div>88  );89}90
Files main
Menu

Theme