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