SPB Git

Repository Public

settings-security.tsx 13.0 KB 203 lines tsx

1"use client";2import { useEffect, useState } from "react";3import { browserSupportsWebAuthn, startRegistration } from "@simplewebauthn/browser";4import { useLocale, useT } from "@/i18n";5import { adminConfig } from "@/i18n/admin-config";6import { api, ApiClientError } from "@/lib/client-api";7import { fmtDateTime } from "@/lib/format";8import { Badge, Button, Empty, errorText, Field, Notice, toast } from "@/components/ui";9import { Loadable, useAdmin, useLoad } from "@/components/admin/shell";10import { Confirm, CopyButton, Section } from "./common";1112interface Session { id: string; current: boolean; method: string; userAgent: string | null; ip: string | null; createdAt: string; lastSeenAt: string; expiresAt: string }13interface Passkey { id: string; name: string; deviceType: string | null; backedUp: boolean; createdAt: string; lastUsedAt: string | null }14interface Data { sessions: Session[]; passkeys: Passkey[]; recoveryCodesLeft: number }1516/** Short, readable device description from a User-Agent string. */17function device(ua: string | null): string {18  if (!ua) return "—";19  const os = /iPhone/.test(ua) ? "iPhone" : /iPad/.test(ua) ? "iPad" : /Android/.test(ua) ? "Android" : /Mac OS X/.test(ua) ? "Mac" : /Windows/.test(ua) ? "Windows" : /Linux/.test(ua) ? "Linux" : "";20  const br = /Edg\//.test(ua) ? "Edge" : /Firefox\//.test(ua) ? "Firefox" : /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari" : "";21  return [br, os].filter(Boolean).join(" · ") || ua.slice(0, 60);22}2324export function SecurityTab() {25  const t = useT(adminConfig);26  const locale = useLocale();27  const { timezone } = useAdmin();28  const state = useLoad<Data>("/api/v1/admin/security");29  const [name, setName] = useState("");30  const [busy, setBusy] = useState<string | null>(null);31  const [addError, setAddError] = useState<string | null>(null);32  const [supported, setSupported] = useState(true);33  const [toRemove, setToRemove] = useState<Passkey | null>(null);34  const [removeError, setRemoveError] = useState<string | null>(null);35  const [codes, setCodes] = useState<string[] | null>(null);36  const [confirmCodes, setConfirmCodes] = useState(false);37  const [confirmOthers, setConfirmOthers] = useState(false);38  useEffect(() => { setSupported(browserSupportsWebAuthn()); }, []);39  const d = state.data;40  const date = (iso: string) => fmtDateTime(iso, timezone, locale);4142  const addPasskey = async (ev: React.FormEvent) => {43    ev.preventDefault(); setBusy("add"); setAddError(null);44    try {45      const { challengeId, options } = await api<{ challengeId: string; options: never }>("/api/v1/auth/register/options", { body: {} });46      const response = await startRegistration({ optionsJSON: options });47      await api("/api/v1/auth/register/verify", { body: { challengeId, response, name: name.trim() || undefined } });48      toast.ok(t("scPasskeyAdded")); setName(""); await state.reload();49    } catch (e) {50      const n = e instanceof Error ? e.name : "";51      setAddError(n === "NotAllowedError" || n === "AbortError" ? t("scPasskeyCancelled") : n === "InvalidStateError" ? t("scPasskeyExists") : errorText(e, locale));52    } finally { setBusy(null); }53  };54  const removePasskey = async () => {55    if (!toRemove) return;56    setBusy("remove"); setRemoveError(null);57    try { await api(`/api/v1/admin/security/passkeys/${toRemove.id}`, { method: "DELETE" }); toast.ok(t("scPasskeyRemoved")); setToRemove(null); await state.reload(); }58    catch (e) { setRemoveError(e instanceof ApiClientError && e.code === "last_passkey" ? t("scLastPasskey") : errorText(e, locale)); } finally { setBusy(null); }59  };60  const revoke = async (s: Session) => {61    setBusy(s.id);62    try { await api(`/api/v1/admin/security/sessions/${s.id}`, { method: "DELETE" }); if (s.current) { window.location.assign("/admin/login"); return; } toast.ok(t("scSessionRevoked")); await state.reload(); }63    catch (e) { toast.error(errorText(e, locale)); } finally { setBusy(null); }64  };65  const revokeOthers = async () => {66    setBusy("others");67    try { const r = await api<{ revoked: number }>("/api/v1/admin/security/sessions/revoke-others", { method: "POST", body: {} }); toast.ok(t("scOthersRevoked", { n: r.revoked })); setConfirmOthers(false); await state.reload(); }68    catch (e) { toast.error(errorText(e, locale)); } finally { setBusy(null); }69  };70  const regenerate = async () => {71    setBusy("codes");72    try { const r = await api<{ codes: string[] }>("/api/v1/admin/security/recovery-codes", { method: "POST", body: {} }); setCodes(r.codes); setConfirmCodes(false); await state.reload(); }73    catch (e) { toast.error(errorText(e, locale)); } finally { setBusy(null); }74  };75  const method = (m: string) => (m === "passkey" ? t("scMethodPasskey") : m === "recovery_code" ? t("scMethodRecovery") : m === "setup" ? t("scMethodSetup") : m);7677  return (78    <Loadable state={state}>79      {d ? (80        <div className="grid gap-5">81          <Section title={t("scPasskeysTitle")} lead={t("scPasskeysLead")}>82            {d.passkeys.length === 0 ? <Notice tone="warn">{t("scNoPasskey")}</Notice> : (83              <ul>84                {d.passkeys.map((k) => (85                  <li key={k.id} className="py-3 border-t border-line first:border-t-0 flex items-start justify-between gap-3">86                    <div className="min-w-0">87                      <p className="font-medium break-words">{k.name}</p>88                      <p className="t-mute text-sm">{t("scKeyCreated", { date: date(k.createdAt) })} · {k.lastUsedAt ? t("scKeyUsed", { date: date(k.lastUsedAt) }) : t("scKeyNeverUsed")}</p>89                      <div className="flex flex-wrap gap-2 mt-1.5">90                        <Badge tone={k.backedUp ? "info" : "mute"}>{k.backedUp ? t("scKeySynced") : t("scKeyDeviceOnly")}</Badge>91                      </div>92                    </div>93                    <Button small variant="ghost" icon="trash" aria-label={`${t("cDelete")} — ${k.name}`} onClick={() => { setRemoveError(null); setToRemove(k); }} />94                  </li>95                ))}96              </ul>97            )}98            {d.passkeys.length === 1 ? <div className="mt-3"><Notice tone="info">{t("scSingleKey")}</Notice></div> : null}99            <form onSubmit={addPasskey} className="hairline mt-4 pt-4 flex flex-wrap items-end gap-3">100              <div className="flex-1 min-w-[200px] max-w-xs"><Field label={t("scKeyName")} optional={t("cOptional")}>{(p) => <input {...p} className="input" value={name} maxLength={60} placeholder="MacBook, iPhone…" onChange={(e) => setName(e.target.value)} />}</Field></div>101              <Button type="submit" variant="primary" icon="key" busy={busy === "add"} disabled={!supported}>{t("scAddPasskey")}</Button>102            </form>103            {!supported ? <div className="mt-3"><Notice tone="warn">{t("scUnsupported")}</Notice></div> : null}104            {addError ? <div className="mt-3"><Notice tone="danger">{addError}</Notice></div> : null}105          </Section>106107          <Section title={t("scCodesTitle")} lead={t("scCodesLead")}>108            <div className="grid gap-3">109              <p><Badge tone={d.recoveryCodesLeft > 2 ? "ok" : d.recoveryCodesLeft > 0 ? "pending" : "danger"}>{t("scCodesLeft", { n: d.recoveryCodesLeft })}</Badge></p>110              {codes ? (111                <Notice tone="warn">112                  <p className="font-semibold">{t("scCodesOnce")}</p>113                  <ul className="grid grid-cols-2 gap-2 font-mono text-[14.5px] my-3 text-ink" aria-label={t("scCodesTitle")}>{codes.map((c) => <li key={c} className="bg-surface rounded-xl px-3 py-2 text-center select-all">{c}</li>)}</ul>114                  <CopyButton text={codes.join("\n")} label={t("cCopy")} copied={t("cCopied")} />115                </Notice>116              ) : null}117              <div><Button small busy={busy === "codes"} onClick={() => setConfirmCodes(true)}>{t("scCodesRegenerate")}</Button></div>118            </div>119          </Section>120121          <Section title={t("scSessionsTitle")} lead={t("scSessionsLead")} actions={d.sessions.length > 1 ? <Button small onClick={() => setConfirmOthers(true)}>{t("scRevokeOthers")}</Button> : null}>122            <ul>123              {d.sessions.map((s) => (124                <li key={s.id} className="py-3 border-t border-line first:border-t-0 flex items-start justify-between gap-3">125                  <div className="min-w-0">126                    <p className="font-medium flex flex-wrap items-center gap-2">{device(s.userAgent)}{s.current ? <Badge tone="ok">{t("scCurrent")}</Badge> : null}</p>127                    <p className="t-mute text-sm break-words">{method(s.method)} · {s.ip ?? "—"}</p>128                    <p className="t-mute text-[13px]">{t("scSessionSeen", { date: date(s.lastSeenAt) })} · {t("scSessionExpires", { date: date(s.expiresAt) })}</p>129                  </div>130                  <Button small variant={s.current ? "ghost" : "secondary"} busy={busy === s.id} onClick={() => revoke(s)}>{s.current ? t("scSignOut") : t("scRevoke")}</Button>131                </li>132              ))}133            </ul>134          </Section>135136          <Confirm open={!!toRemove} onClose={() => setToRemove(null)} title={t("scRemoveKeyTitle")} confirmLabel={t("cDelete")} cancelLabel={t("cCancel")} onConfirm={removePasskey} busy={busy === "remove"}>137            <p>{t("scRemoveKeyLead", { name: toRemove?.name ?? "" })}</p>138            {removeError ? <Notice tone="danger">{removeError}</Notice> : null}139          </Confirm>140          <Confirm open={confirmCodes} onClose={() => setConfirmCodes(false)} title={t("scCodesRegenerate")} confirmLabel={t("scCodesRegenerate")} cancelLabel={t("cCancel")} onConfirm={regenerate} busy={busy === "codes"} danger={false}>141            <p>{t("scCodesConfirm")}</p>142          </Confirm>143          <Confirm open={confirmOthers} onClose={() => setConfirmOthers(false)} title={t("scRevokeOthers")} confirmLabel={t("scRevokeOthers")} cancelLabel={t("cCancel")} onConfirm={revokeOthers} busy={busy === "others"}>144            <p>{t("scRevokeOthersLead")}</p>145          </Confirm>146        </div>147      ) : null}148    </Loadable>149  );150}151152interface AuditEvent { id: string; actor: "admin" | "visitor" | "system"; action: string; entity: string; entityId: string; meta: Record<string, unknown> | null; ip: string | null; at: string }153154export function AuditTab() {155  const t = useT(adminConfig);156  const locale = useLocale();157  const { timezone } = useAdmin();158  const state = useLoad<{ events: AuditEvent[] }>("/api/v1/admin/audit");159  const [more, setMore] = useState<AuditEvent[]>([]);160  const [loadingMore, setLoadingMore] = useState(false);161  const [done, setDone] = useState(false);162  const first = state.data?.events;163  useEffect(() => { setMore([]); setDone((first?.length ?? 0) < 100); }, [first]);164  const events = [...(first ?? []), ...more];165166  const loadMore = async () => {167    const last = events[events.length - 1];168    if (!last) return;169    setLoadingMore(true);170    try { const r = await api<{ events: AuditEvent[] }>(`/api/v1/admin/audit?before=${encodeURIComponent(last.id)}`); setMore((m) => [...m, ...r.events]); if (r.events.length < 100) setDone(true); }171    catch (e) { toast.error(errorText(e, locale)); } finally { setLoadingMore(false); }172  };173  const actor = (a: AuditEvent["actor"]) => (a === "admin" ? t("auActorAdmin") : a === "visitor" ? t("auActorVisitor") : t("auActorSystem"));174  const metaText = (m: AuditEvent["meta"]) => (m && Object.keys(m).length ? Object.entries(m).map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : String(v)}`).join(" · ") : "");175176  return (177    <Section title={t("auTitle")} lead={t("auLead")} actions={<Button small icon="refresh" busy={state.loading} onClick={() => state.reload()}>{t("cRefresh")}</Button>}>178      <Loadable state={state}>179        {events.length === 0 ? <Empty icon="shield" title={t("auEmpty")} /> : (180          <>181            <ul>182              {events.map((e) => (183                <li key={e.id} className="py-2.5 border-t border-line first:border-t-0 grid gap-1 sm:grid-cols-[170px_1fr] sm:gap-4">184                  <span className="t-mute text-[13px] sm:pt-0.5">{fmtDateTime(e.at, timezone, locale)}</span>185                  <span className="min-w-0">186                    <span className="flex flex-wrap items-center gap-2"><span className="font-mono text-[13.5px] font-semibold break-all">{e.action}</span><Badge tone={e.actor === "admin" ? "info" : e.actor === "visitor" ? "pending" : "mute"}>{actor(e.actor)}</Badge></span>187                    {e.entity ? <span className="block t-mute text-[12.5px] font-mono break-all">{e.entity} {e.entityId}</span> : null}188                    {metaText(e.meta) ? <span className="block t-mute text-[12.5px] break-words">{metaText(e.meta)}</span> : null}189                    {e.ip ? <span className="block t-mute text-[12.5px]">IP {e.ip}</span> : null}190                  </span>191                </li>192              ))}193            </ul>194            <div className="hairline pt-4 mt-2 text-center">195              {done ? <p className="t-mute text-sm">{t("auEnd")}</p> : <Button small busy={loadingMore} onClick={loadMore}>{t("auMore")}</Button>}196            </div>197          </>198        )}199      </Loadable>200    </Section>201  );202}203
Files main
Menu

Theme