feat(admin): 系统设置接入已实现的后端4端点(吊销key/轮换JWT/备份/恢复),未实现项诚实标注
This commit is contained in:
1 parent
5a7ee9d772
commit
cf25c6643d
3 files changed
+433
-11
No files matched your search
@@ -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]}`;
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user