From cf25c6643d46519a97b3cb5a0b90edd1c00bbea0 Mon Sep 17 00:00:00 2001 From: Aska Date: Tue, 8 Sep 2026 04:53:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=B3=BB=E7=BB=9F=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E6=8E=A5=E5=85=A5=E5=B7=B2=E5=AE=9E=E7=8E=B0=E7=9A=84?= =?UTF-8?q?=E5=90=8E=E7=AB=AF4=E7=AB=AF=E7=82=B9(=E5=90=8A=E9=94=80key/?= =?UTF-8?q?=E8=BD=AE=E6=8D=A2JWT/=E5=A4=87=E4=BB=BD/=E6=81=A2=E5=A4=8D)?= =?UTF-8?q?=EF=BC=8C=E6=9C=AA=E5=AE=9E=E7=8E=B0=E9=A1=B9=E8=AF=9A=E5=AE=9E?= =?UTF-8?q?=E6=A0=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/web/src/admin/modules/System.tsx | 379 +++++++++++++++++++++- frontend/web/src/lib/api.ts | 37 +++ frontend/web/src/lib/types.ts | 28 ++ 3 files changed, 433 insertions(+), 11 deletions(-) diff --git a/frontend/web/src/admin/modules/System.tsx b/frontend/web/src/admin/modules/System.tsx index 0dea8ee..317ca8d 100644 --- a/frontend/web/src/admin/modules/System.tsx +++ b/frontend/web/src/admin/modules/System.tsx @@ -1,19 +1,144 @@ -import { getStoredRole } from "../../lib/api"; +import { useState, type ReactNode } from "react"; +import { + adminSystemBackup, + adminSystemRestore, + adminSystemRevokeKeys, + adminSystemRotateJWT, + getStoredRole, +} from "../../lib/api"; +import type { + SystemBackupResp, + SystemRestoreResp, + SystemRevokeKeysResp, + SystemRotateJwtResp, +} from "../../lib/types"; +import { useAuditStore } from "../audit"; import { KeyValueRow, Notice, PageHeader } from "../components"; -import { Surface, Tag } from "../../components/primitives"; +import { Button, Surface, Tag } from "../../components/primitives"; import { Icon } from "../../components/Icon"; /** - * 系统设置 (design/10)。后端 M10+ 才提供真实的密钥轮换/备份/环境变量写入等 - * 系统级操作;在此之前只呈现**诚实的只读配置事实**,并把尚未实现的操作合并成 - * 一条「未实现」说明——不做满屏点击后 `alert` 的占位按钮。 + * 系统设置 (design/10 §3 模块14 + design/11 §4 敏感操作代审)。后端已实现 4 个 + * owner-only 且审计的**可写**系统操作:吊销全部 API key / 轮换 JWT 密钥 / 备份 / + * 恢复(见 internal/services/gateway/admin_system.go)。这里把它们做成真实可点的 + * Miuix 卡片,每步带确认弹窗防误触;只读事实卡保持不变。 + * 未实现(编辑环境变量 / 重启服务 / 检查更新)诚实标注为「后端未实现,不提供」。 */ export function SystemModule(): React.ReactNode { const isOwner = getStoredRole() === "owner"; + const log = useAuditStore((s) => s.log); + + // 结果反馈(成功 tone="ok" / 失败 tone="err")。 + const [msg, setMsg] = useState<{ text: string; tone: "ok" | "err" } | null>(null); + const [busy, setBusy] = useState(false); + + // 当前打开的确认弹窗(null 表示无)。rotate / restore 需要输入,故各自持有字段。 + const [pending, setPending] = useState<"revoke" | "rotate" | "backup" | "restore" | null>(null); + const [newSecret, setNewSecret] = useState(""); + const [secretErr, setSecretErr] = useState(null); + const [restorePath, setRestorePath] = useState(""); + const [restoreErr, setRestoreErr] = useState(null); + + const close = () => { + setPending(null); + setSecretErr(null); + setRestoreErr(null); + }; + + const done = (tone: "ok" | "err", text: string) => setMsg({ tone, text }); + + const doRevoke = (): void => { + setBusy(true); + setMsg(null); + adminSystemRevokeKeys() + .then((r: SystemRevokeKeysResp) => { + log("system.revoke_keys", "ok"); + done("ok", `已吊销 ${r.revoked} 个 API key;这些 key 立即失效。`); + close(); + }) + .catch((e: unknown) => { + log("system.revoke_keys", "error"); + done("err", errText(e, "吊销失败")); + }) + .finally(() => setBusy(false)); + }; + + const doRotate = (): void => { + const secret = newSecret.trim(); + if (secret.length < 32) { + setSecretErr("新密钥长度不足 32 位"); + return; + } + setBusy(true); + setMsg(null); + adminSystemRotateJWT(secret) + .then((r: SystemRotateJwtResp) => { + log("system.rotate_jwt", "ok"); + done("ok", `JWT 密钥已轮换。${r.note}——请返回登录页重新登录以继续。`); + setNewSecret(""); + close(); + }) + .catch((e: unknown) => { + log("system.rotate_jwt", "error"); + done("err", errText(e, "轮换失败")); + }) + .finally(() => setBusy(false)); + }; + + const doBackup = (): void => { + setBusy(true); + setMsg(null); + adminSystemBackup() + .then((r: SystemBackupResp) => { + log("system.backup", "ok"); + done("ok", `备份完成:${formatBytes(r.size)} → ${r.path}`); + close(); + }) + .catch((e: unknown) => { + log("system.backup", "error"); + done("err", errText(e, "备份失败")); + }) + .finally(() => setBusy(false)); + }; + + const doRestore = (): void => { + const path = restorePath.trim(); + if (!path) { + setRestoreErr("请填写要恢复的备份文件路径"); + return; + } + setBusy(true); + setMsg(null); + adminSystemRestore(path) + .then((r: SystemRestoreResp) => { + log("system.restore", "ok"); + done("ok", `已安排恢复:${r.note}(恢复前已备份到 ${r.backup})`); + setRestorePath(""); + close(); + }) + .catch((e: unknown) => { + log("system.restore", "error"); + done("err", errText(e, "恢复失败")); + }) + .finally(() => setBusy(false)); + }; + + const genSecret = (): void => { + let out = ""; + if (typeof crypto !== "undefined" && "getRandomValues" in crypto) { + const bytes = new Uint8Array(24); + crypto.getRandomValues(bytes); + out = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + } else { + for (let i = 0; i < 48; i += 1) out += Math.floor(Math.random() * 16).toString(16); + } + setNewSecret(out); + setSecretErr(null); + }; return (
- +
@@ -40,21 +165,170 @@ export function SystemModule(): React.ReactNode { } title="运行时" /> - +
+ {msg ? {msg.text} : null} + + +
+

可写系统操作

+ owner 专属 + 真实读写 · 已审计 · 每步需确认 +
+ +
+ } + title="吊销全部 API key" + desc="清空 key 表:所有已注册的 API key 立即失效,需重新签发。不可撤销。" + buttonText="吊销" + variant="danger" + disabled={!isOwner || busy} + onClick={() => setPending("revoke")} + /> + } + title="轮换 JWT 密钥" + desc="换用新的签名密钥(≥32 位)。现有 token 立即失效,需重新登录;新密钥不回显。" + buttonText="轮换" + variant="outline" + disabled={!isOwner || busy} + onClick={() => setPending("rotate")} + /> + } + title="备份数据库" + desc="用 SQLite VACUUM INTO 快照当前库(含已提交 WAL)到数据目录,返回路径与大小。" + buttonText="备份" + variant="primary" + disabled={!isOwner || busy} + onClick={() => setPending("backup")} + /> + } + title="恢复数据库" + desc="从数据目录内的备份文件恢复;恢复前自动备份当前库,替换需重启服务生效。" + buttonText="恢复" + variant="danger" + disabled={!isOwner || busy} + onClick={() => setPending("restore")} + /> +
+
+ - 以下系统操作后端暂未实现(M10+ 落地),当前不可用:轮换 JWT 密钥 · 吊销全部 API - key · 备份/恢复 · 编辑环境变量 · 重启服务 · 检查更新。均为真实读写,未实现前不做占位。 + 以下系统操作后端尚未实现,当前不提供:编辑环境变量 · 重启服务 · 检查更新。均为真实读写,未实现前不做占位按钮。 - {!isOwner ? 安全 / 密钥 / 系统设置类敏感操作仅 owner 可执行;当前角色 {getStoredRole()}。 : null} + {!isOwner ? ( + 安全 / 密钥 / 系统设置类敏感操作仅 owner 可执行;当前角色 {getStoredRole()}。 + ) : null} + + {pending === "revoke" ? ( + + 将吊销全部已注册的 API key;操作完成后所有使用这些 key 的客户端立即失效, + 需重新签发。此操作不可撤销,确定继续? +

+ } + onConfirm={doRevoke} + onClose={close} + /> + ) : null} + + {pending === "rotate" ? ( + +

+ 输入新的 JWT 签名密钥(≥32 位)。轮换后所有现有 token 立即失效,需重新登录; + 新密钥不会被后端回显,请在提交前保存好(提交后输入框即清空)。 +

+ + {secretErr ? {secretErr} : null} + + + } + onConfirm={doRotate} + onClose={close} + /> + ) : null} + + {pending === "backup" ? ( + + 用 SQLite VACUUM INTO 把当前数据库 + (含已提交 WAL 内容)快照到数据目录,返回备份文件的路径与大小。不会影响正在运行的服务。 +

+ } + onConfirm={doBackup} + onClose={close} + /> + ) : null} + + {pending === "restore" ? ( + +

+ 从数据目录内的备份文件恢复。恢复前会自动备份当前数据库;替换后需 + 重启服务 才生效,且会覆盖现有数据。 +

+ + {restoreErr ? {restoreErr} : null} + + } + onConfirm={doRestore} + onClose={close} + /> + ) : null}
); } -function SectionTitle({ icon, title, ownerOnly = false }: { icon: React.ReactNode; title: string; ownerOnly?: boolean }): React.ReactNode { +function SectionTitle({ icon, title, ownerOnly = false }: { icon: ReactNode; title: string; ownerOnly?: boolean }): ReactNode { return (

{icon} @@ -63,3 +337,86 @@ function SectionTitle({ icon, title, ownerOnly = false }: { icon: React.ReactNod

); } + +interface WriteActionProps { + icon: ReactNode; + title: string; + desc: string; + buttonText: string; + variant: "primary" | "soft" | "ghost" | "outline" | "danger"; + disabled: boolean; + onClick: () => void; +} + +/** One owner-only write action as a Miuix card; clicking opens the confirm dialog. */ +function WriteAction({ icon, title, desc, buttonText, variant, disabled, onClick }: WriteActionProps): ReactNode { + return ( + +
+ {icon} + {title} + 已实现 +
+

{desc}

+
+ +
+
+ ); +} + +interface ConfirmDialogProps { + title: string; + body: ReactNode; + confirmText: string; + danger?: boolean; + busy: boolean; + onConfirm: () => void; + onClose: () => void; +} + +/** A Miuix confirmation overlay (防误触). Blocks the action until the owner confirms. */ +function ConfirmDialog({ title, body, confirmText, danger = false, busy, onConfirm, onClose }: ConfirmDialogProps): ReactNode { + return ( +
+
e.stopPropagation()}> +
+ {title} + 确认操作 + {danger ? "危险" : "请确认"} +
+
{body}
+
+ + +
+
+
+ ); +} + +const inputCls = + "w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"; + +function errText(err: unknown, fallback: string): string { + return err instanceof Error ? err.message : fallback; +} + +/** Human-readable byte size (MiB default unit for a DB snapshot). */ +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KiB", "MiB", "GiB", "TiB"]; + let v = n; + let u = -1; + do { + v /= 1024; + u += 1; + } while (v >= 1024 && u < units.length - 1); + return `${v.toFixed(1)} ${units[u]}`; +} diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index 3ba4ebe..c7b7d78 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -38,6 +38,10 @@ import type { RagIndexRequest, Role, SessionMeta, + SystemBackupResp, + SystemRestoreResp, + SystemRevokeKeysResp, + SystemRotateJwtResp, WeixinLoginQRResponse, WeixinLoginStatusResponse, SessionRecord, @@ -713,3 +717,36 @@ function limitQuery(limit?: number): string { export function adminBackthink(limit?: number): Promise { return request(`/admin/backthink${limitQuery(limit)}`); } + +/* ---------------------------------------------------------------------------- + * System management admin writes (owner-only, audited). Reference: + * internal/services/gateway/admin_system.go + Docs/api-client.md §系统设置契约. + * All four are implemented (not 501) — they return real data. + * -------------------------------------------------------------------------- */ + +/** POST /api/v1/admin/system/revoke-keys — revokes every registered API key. */ +export function adminSystemRevokeKeys(): Promise { + return request("/admin/system/revoke-keys", { method: "POST" }); +} +/** POST /api/v1/admin/system/rotate-jwt — swaps the JWT signing secret (≥32 chars). + * The secret is provided by the owner and never echoed back; all existing tokens + * become invalid, so the client must re-login afterward. */ +export function adminSystemRotateJWT(newSecret: string): Promise { + return request("/admin/system/rotate-jwt", { + method: "POST", + body: JSON.stringify({ newSecret }), + }); +} +/** POST /api/v1/admin/system/backup — snapshots the SQLite DB (VACUUM INTO, incl. WAL). */ +export function adminSystemBackup(): Promise { + return request("/admin/system/backup", { method: "POST" }); +} +/** POST /api/v1/admin/system/restore — restores a data-dir backup; path must live + * inside the data directory (path-traversal guard). The backend pre-backups the + * current DB and reports that the swap needs a restart. */ +export function adminSystemRestore(path: string): Promise { + return request("/admin/system/restore", { + method: "POST", + body: JSON.stringify({ path }), + }); +} diff --git a/frontend/web/src/lib/types.ts b/frontend/web/src/lib/types.ts index 4b416b8..7b6641e 100644 --- a/frontend/web/src/lib/types.ts +++ b/frontend/web/src/lib/types.ts @@ -598,6 +598,34 @@ export interface RagIndexRequest { content: string; } +/* --- System management writes (design/11 §4 + design/10 §3 模块14; owner-only, audited). + * Field names mirror the gateway DTOs in internal/services/gateway/admin_system.go. --- */ + +/** POST /api/v1/admin/system/revoke-keys — how many API keys were revoked. */ +export interface SystemRevokeKeysResp { + revoked: number; +} + +/** POST /api/v1/admin/system/rotate-jwt — `note` warns that every existing token is now + * invalid. The new secret is never echoed back by the backend. */ +export interface SystemRotateJwtResp { + rotated: true; + note: string; +} + +/** POST /api/v1/admin/system/backup — the snapshot file path + byte size. */ +export interface SystemBackupResp { + path: string; + size: number; +} + +/** POST /api/v1/admin/system/restore — a pre-restore snapshot path + a restart notice. */ +export interface SystemRestoreResp { + restored: true; + backup: string; + note: string; +} + /* --- KOOK per-adapter panel data (design/17 §2; GET /admin/channels/kook/view). --- */ /** One friend / blocked user in GET /admin/channels/{platform}/friends. */