feat(admin): 系统设置接入已实现的后端4端点(吊销key/轮换JWT/备份/恢复),未实现项诚实标注
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
Aska committed 2026-09-08 04:53:22 +08:00
1 parent 5a7ee9d772
commit cf25c6643d
3 files changed
+433 -11

No files matched your search

+368 -11
View File
@@ -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<string | null>(null);
const [restorePath, setRestorePath] = useState("");
const [restoreErr, setRestoreErr] = useState<string | null>(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 (
<div className="space-y-5">
<PageHeader title="系统设置" subtitle="安全(密钥/JWT) + 运行时 + 存储 + 环境变量(只读);可写系统操作见末条说明" />
<PageHeader title="系统设置" subtitle="安全(密钥/JWT) + 运行时 + 存储 + 环境变量(只读);可写系统操作见「可写操作」" />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Surface className="space-y-1">
@@ -40,21 +165,170 @@ export function SystemModule(): React.ReactNode {
<Surface className="space-y-1">
<SectionTitle icon={<Icon name="refresh" size={18} />} title="运行时" />
<KeyValueRow label="部署形态" value="单二进制 · 单端口 · 零跨进程 IPC" />
<KeyValueRow label="日志级别" value="后端配置,面板暂不可改(M10+)" />
<KeyValueRow label="日志级别" value="后端配置,面板暂不可改" />
</Surface>
</div>
{msg ? <Notice tone={msg.tone}>{msg.text}</Notice> : null}
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">可写系统操作</h3>
<Tag tone="warn">owner 专属</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">真实读写 · 已审计 · 每步需确认</span>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<WriteAction
icon={<Icon name="lock" size={18} />}
title="吊销全部 API key"
desc="清空 key 表:所有已注册的 API key 立即失效,需重新签发。不可撤销。"
buttonText="吊销"
variant="danger"
disabled={!isOwner || busy}
onClick={() => setPending("revoke")}
/>
<WriteAction
icon={<Icon name="refresh" size={18} />}
title="轮换 JWT 密钥"
desc="换用新的签名密钥(≥32 位)。现有 token 立即失效,需重新登录;新密钥不回显。"
buttonText="轮换"
variant="outline"
disabled={!isOwner || busy}
onClick={() => setPending("rotate")}
/>
<WriteAction
icon={<Icon name="storage" size={18} />}
title="备份数据库"
desc="用 SQLite VACUUM INTO 快照当前库(含已提交 WAL)到数据目录,返回路径与大小。"
buttonText="备份"
variant="primary"
disabled={!isOwner || busy}
onClick={() => setPending("backup")}
/>
<WriteAction
icon={<Icon name="package" size={18} />}
title="恢复数据库"
desc="从数据目录内的备份文件恢复;恢复前自动备份当前库,替换需重启服务生效。"
buttonText="恢复"
variant="danger"
disabled={!isOwner || busy}
onClick={() => setPending("restore")}
/>
</div>
</Surface>
<Notice tone="warn">
以下系统操作后端暂未实现(M10+ 落地),当前不可用:轮换 JWT 密钥 · 吊销全部 API
key · 备份/恢复 · 编辑环境变量 · 重启服务 · 检查更新。均为真实读写,未实现前不做占位。
以下系统操作后端尚未实现,当前不提供:编辑环境变量 · 重启服务 · 检查更新。均为真实读写,未实现前不做占位按钮。
</Notice>
{!isOwner ? <Notice tone="warn">安全 / 密钥 / 系统设置类敏感操作仅 owner 可执行;当前角色 {getStoredRole()}。</Notice> : null}
{!isOwner ? (
<Notice tone="warn">安全 / 密钥 / 系统设置类敏感操作仅 owner 可执行;当前角色 {getStoredRole()}。</Notice>
) : null}
{pending === "revoke" ? (
<ConfirmDialog
danger
busy={busy}
title="吊销全部 API key"
confirmText="确认吊销"
body={
<p className="text-xs text-[var(--mu-text-mid)]">
将吊销<b>全部</b>已注册的 API key;操作完成后所有使用这些 key 的客户端立即失效,
需重新签发。此操作<b>不可撤销</b>,确定继续?
</p>
}
onConfirm={doRevoke}
onClose={close}
/>
) : null}
{pending === "rotate" ? (
<ConfirmDialog
busy={busy}
title="轮换 JWT 密钥"
confirmText="确认轮换"
body={
<>
<p className="text-xs text-[var(--mu-text-mid)]">
输入新的 JWT 签名密钥(≥32 位)。轮换后<b>所有现有 token 立即失效,需重新登录</b>;
新密钥<b>不会被后端回显</b>,请在提交前保存好(提交后输入框即清空)。
</p>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">新密钥(≥32 位)</span>
<input
value={newSecret}
onChange={(e) => {
setNewSecret(e.target.value);
setSecretErr(null);
}}
className={`${inputCls} font-mono`}
placeholder="至少 32 位,可点「随机生成」"
/>
</label>
{secretErr ? <span className="text-xs text-[var(--mu-err)]">{secretErr}</span> : null}
<Button size="sm" variant="ghost" disabled={busy} onClick={genSecret}>
随机生成
</Button>
</>
}
onConfirm={doRotate}
onClose={close}
/>
) : null}
{pending === "backup" ? (
<ConfirmDialog
busy={busy}
title="备份数据库"
confirmText="确认备份"
body={
<p className="text-xs text-[var(--mu-text-mid)]">
用 SQLite <span className="font-mono">VACUUM INTO</span> 把当前数据库
(含已提交 WAL 内容)快照到数据目录,返回备份文件的路径与大小。不会影响正在运行的服务。
</p>
}
onConfirm={doBackup}
onClose={close}
/>
) : null}
{pending === "restore" ? (
<ConfirmDialog
danger
busy={busy}
title="恢复数据库"
confirmText="确认恢复"
body={
<>
<p className="text-xs text-[var(--mu-text-mid)]">
从<b>数据目录内</b>的备份文件恢复。恢复前会自动备份当前数据库;替换后需
<b>重启服务</b> 才生效,且会<b>覆盖现有数据</b>。
</p>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">备份文件路径(须在数据目录内)</span>
<input
value={restorePath}
onChange={(e) => {
setRestorePath(e.target.value);
setRestoreErr(null);
}}
className={`${inputCls} font-mono`}
placeholder="如 backup-20260908T120000.000000000Z.db"
/>
</label>
{restoreErr ? <span className="text-xs text-[var(--mu-err)]">{restoreErr}</span> : null}
</>
}
onConfirm={doRestore}
onClose={close}
/>
) : null}
</div>
);
}
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 (
<h3 className="flex items-center gap-2 text-sm font-semibold">
<span className="text-[var(--mu-accent)]">{icon}</span>
@@ -63,3 +337,86 @@ function SectionTitle({ icon, title, ownerOnly = false }: { icon: React.ReactNod
</h3>
);
}
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 (
<Surface className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-[var(--mu-accent)]">{icon}</span>
<span className="text-sm font-semibold">{title}</span>
<Tag tone="ok" className="ml-auto">已实现</Tag>
</div>
<p className="text-xs text-[var(--mu-text-lo)]">{desc}</p>
<div className="mt-auto pt-1">
<Button variant={variant} size="sm" disabled={disabled} onClick={onClick}>
{buttonText}
</Button>
</div>
</Surface>
);
}
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 (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 backdrop-blur-sm" onClick={onClose}>
<div className="w-full max-w-lg rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-1)] shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 border-b border-[var(--mu-glass-border)] px-5 py-3">
<span className="text-sm font-semibold">{title}</span>
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">确认操作</span>
<Tag tone={danger ? "err" : "warn"}>{danger ? "危险" : "请确认"}</Tag>
</div>
<div className="space-y-3 px-5 py-4">{body}</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--mu-glass-border)] px-5 py-3">
<Button size="sm" variant="ghost" disabled={busy} onClick={onClose}>
取消
</Button>
<Button size="sm" variant={danger ? "danger" : "primary"} disabled={busy} onClick={onConfirm}>
{busy ? "处理中…" : confirmText}
</Button>
</div>
</div>
</div>
);
}
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]}`;
}
+37
View File
@@ -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<BackthinkItem[]> {
return request<BackthinkItem[]>(`/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<SystemRevokeKeysResp> {
return request<SystemRevokeKeysResp>("/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<SystemRotateJwtResp> {
return request<SystemRotateJwtResp>("/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<SystemBackupResp> {
return request<SystemBackupResp>("/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<SystemRestoreResp> {
return request<SystemRestoreResp>("/admin/system/restore", {
method: "POST",
body: JSON.stringify({ path }),
});
}
+28
View File
@@ -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. */