feat(admin): add operations resources and audit UI

This commit is contained in:
Jesse_Chen
2026-08-06 20:15:08 +08:00
parent 28e04857fa
commit 5622ab448a
39 changed files with 3770 additions and 293 deletions
@@ -0,0 +1,279 @@
"use client";
import {
Alert,
Button,
Card,
Descriptions,
Divider,
Form,
Input,
List,
Radio,
Space,
Spin,
Tag,
Typography,
} from "antd";
import { useEffect, useState } from "react";
const { Paragraph, Text, Title } = Typography;
type MfaStatus = {
required: boolean;
enrolled: boolean;
verified: boolean;
highRiskWritesEnabled: boolean;
expiresIn?: number;
};
type Enrollment = {
totpUri: string;
backupCodes: string[];
verificationRequired: true;
};
type Factor = "totp" | "backup";
async function mfaRequest<T>(body?: object): Promise<T> {
const response = await fetch("/api/admin/mfa", {
method: body ? "POST" : "GET",
cache: "no-store",
credentials: "same-origin",
headers: body ? { "content-type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const payload = await response.json().catch(() => null) as { data?: T; error?: unknown } | null;
if (!response.ok || !payload?.data) {
throw new Error(typeof payload?.error === "string" ? payload.error : "MFA 请求失败,请稍后再试");
}
return payload.data;
}
export default function MfaSecurity() {
const [status, setStatus] = useState<MfaStatus>();
const [enrollment, setEnrollment] = useState<Enrollment>();
const [backupCodes, setBackupCodes] = useState<string[]>();
const [factor, setFactor] = useState<Factor>("totp");
const [loading, setLoading] = useState(true);
const [action, setAction] = useState<string>();
const [error, setError] = useState<string>();
const [notice, setNotice] = useState<string>();
useEffect(() => {
let cancelled = false;
void mfaRequest<MfaStatus>()
.then((value) => {
if (!cancelled) setStatus(value);
})
.catch((cause: unknown) => {
if (!cancelled) {
setError(cause instanceof Error ? cause.message : "无法读取 MFA 状态");
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
async function run<T>(name: string, operation: () => Promise<T>): Promise<T | undefined> {
setAction(name);
setError(undefined);
setNotice(undefined);
try {
return await operation();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "操作失败,请稍后再试");
} finally {
setAction(undefined);
}
}
async function enroll({ password }: { password: string }) {
const data = await run("enroll", () => mfaRequest<Enrollment>({ action: "enroll", password }));
if (!data) return;
setEnrollment(data);
setBackupCodes(data.backupCodes);
setNotice("请先保存恢复码,再使用认证器生成的 6 位验证码完成 enrollment。未验证前 MFA 不会启用。");
}
async function verify({ code }: { code: string }) {
const data = await run("verify", () => mfaRequest<MfaStatus>({ action: "verify", code }));
if (!data) return;
setStatus(data);
setEnrollment(undefined);
setNotice("当前管理员 session 已完成真实第二因素验证。高风险操作仍需随后完成权限范围内的邮箱验证码。");
}
async function recover({ code }: { code: string }) {
const data = await run("recover", () => mfaRequest<MfaStatus>({ action: "recover", code }));
if (!data) return;
setStatus(data);
setNotice("恢复码已消费,当前 session 已完成 MFA 验证。请在恢复访问后重新生成恢复码。");
}
async function regenerate({ password }: { password: string }) {
const data = await run("regenerate", () => mfaRequest<{ backupCodes: string[] }>({
action: "regenerate",
password,
}));
if (!data) return;
setBackupCodes(data.backupCodes);
setNotice("新的恢复码已生成,旧恢复码已全部失效。请立即离线保存。");
}
async function disable({ password }: { password: string }) {
const data = await run("disable", () => mfaRequest<MfaStatus>({ action: "disable", password }));
if (!data) return;
setStatus(data);
setEnrollment(undefined);
setBackupCodes(undefined);
setNotice(data.required
? "MFA 已禁用。该角色要求 MFA,因此高风险写入现已关闭,重新 enrollment 后才能恢复。"
: "MFA 已禁用,当前 MFA 与邮箱重认证证明均已撤销。");
}
if (loading && !status) {
return <Card><Space><Spin /><Text>正在读取 MFA 状态</Text></Space></Card>;
}
return <Space direction="vertical" size="large" style={{ width: "100%" }}>
<div>
<Title level={2}>安全验证</Title>
<Paragraph type="secondary">
管理员 MFA 使用 Better Auth 的真实 TOTP 与一次性恢复码。MFA 证明只绑定当前 server session,短时有效且可随 session 撤销。
</Paragraph>
</div>
{error ? <Alert type="error" showIcon message={error} /> : null}
{notice ? <Alert type="success" showIcon message={notice} /> : null}
{status?.required && !status.enrolled ? <Alert
type="warning"
showIcon
message="当前角色强制要求 MFA"
description="在 enrollment 完成前,所有高风险写入都会 fail closed。"
/> : null}
<Card title="当前状态">
<Descriptions column={{ xs: 1, sm: 2 }}>
<Descriptions.Item label="角色要求">
<Tag color={status?.required ? "red" : "default"}>{status?.required ? "必须 MFA" : "可选 MFA"}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Enrollment">
<Tag color={status?.enrolled ? "green" : "orange"}>{status?.enrolled ? "已启用" : "未启用"}</Tag>
</Descriptions.Item>
<Descriptions.Item label="当前 session">
<Tag color={status?.verified ? "green" : "default"}>{status?.verified ? "已验证" : "未验证"}</Tag>
</Descriptions.Item>
<Descriptions.Item label="高风险写入前置能力">
<Tag color={status?.highRiskWritesEnabled ? "green" : "red"}>
{status?.highRiskWritesEnabled ? "可发起邮箱重认证" : "已关闭"}
</Tag>
</Descriptions.Item>
</Descriptions>
</Card>
{!status?.enrolled && !enrollment ? <Card title="启用 TOTP MFA">
<Paragraph>输入当前账户密码后生成认证器 URI 与一次性恢复码。服务器只保存加密后的 seed 与恢复码。</Paragraph>
<Form layout="vertical" onFinish={enroll} style={{ maxWidth: 520 }}>
<Form.Item name="password" label="当前密码" rules={[{ required: true, message: "请输入当前密码" }]}>
<Input.Password autoComplete="current-password" maxLength={128} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={action === "enroll"}>开始 enrollment</Button>
</Form>
</Card> : null}
{enrollment ? <Card title="完成 enrollment">
<Alert
type="info"
showIcon
message="先保存恢复码,再验证 TOTP"
description="刷新页面不会再次显示这些明文恢复码。不要把 URI、seed 或恢复码粘贴到日志、工单或聊天中。"
/>
<Divider orientation="left">认证器 URI</Divider>
<Paragraph copyable={{ text: enrollment.totpUri }} code style={{ overflowWrap: "anywhere" }}>
{enrollment.totpUri}
</Paragraph>
<Divider orientation="left">一次性恢复码</Divider>
<BackupCodeList codes={backupCodes ?? enrollment.backupCodes} />
<Divider />
<Form layout="vertical" onFinish={verify} style={{ maxWidth: 520 }}>
<Form.Item name="code" label="认证器验证码" rules={[{ required: true }, { pattern: /^\d{6}$/, message: "请输入 6 位验证码" }]}>
<Input inputMode="numeric" autoComplete="one-time-code" maxLength={6} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={action === "verify"}>验证并启用 MFA</Button>
</Form>
</Card> : null}
{status?.enrolled && !status.verified ? <Card title="验证当前 session">
<Paragraph>高权限操作前,先用认证器验证码或一次性恢复码完成真实第二因素。</Paragraph>
<Radio.Group value={factor} onChange={(event) => setFactor(event.target.value as Factor)}>
<Radio.Button value="totp">认证器验证码</Radio.Button>
<Radio.Button value="backup">恢复码</Radio.Button>
</Radio.Group>
<Form
key={factor}
layout="vertical"
onFinish={factor === "totp" ? verify : recover}
style={{ maxWidth: 520, marginTop: 16 }}
>
<Form.Item
name="code"
label={factor === "totp" ? "6 位验证码" : "一次性恢复码"}
rules={factor === "totp"
? [{ required: true }, { pattern: /^\d{6}$/, message: "请输入 6 位验证码" }]
: [{ required: true, whitespace: true }]}
>
<Input
inputMode={factor === "totp" ? "numeric" : "text"}
autoComplete="one-time-code"
maxLength={factor === "totp" ? 6 : 128}
/>
</Form.Item>
<Button type="primary" htmlType="submit" loading={action === "verify" || action === "recover"}>
验证当前 session
</Button>
</Form>
</Card> : null}
{status?.enrolled && status.verified ? <Card title="恢复与禁用">
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<div>
<Title level={4}>重新生成恢复码</Title>
<Paragraph type="secondary">生成后旧恢复码立即失效,明文只在本次响应显示。</Paragraph>
<Form layout="inline" onFinish={regenerate}>
<Form.Item name="password" rules={[{ required: true, message: "请输入当前密码" }]}>
<Input.Password placeholder="当前密码" autoComplete="current-password" maxLength={128} />
</Form.Item>
<Button htmlType="submit" loading={action === "regenerate"}>重新生成</Button>
</Form>
</div>
{backupCodes?.length ? <BackupCodeList codes={backupCodes} /> : null}
<Divider />
<div>
<Title level={4}>禁用 MFA</Title>
<Paragraph type="secondary">禁用会轮换 Better Auth session,并撤销当前 MFA 与邮箱重认证证明。</Paragraph>
<Form layout="inline" onFinish={disable}>
<Form.Item name="password" rules={[{ required: true, message: "请输入当前密码" }]}>
<Input.Password placeholder="当前密码" autoComplete="current-password" maxLength={128} />
</Form.Item>
<Button danger htmlType="submit" loading={action === "disable"}>禁用 MFA</Button>
</Form>
</div>
</Space>
</Card> : null}
</Space>;
}
function BackupCodeList({ codes }: { codes: string[] }) {
return <List
bordered
size="small"
dataSource={codes}
grid={{ gutter: 8, xs: 1, sm: 2, md: 3 }}
renderItem={(code) => <List.Item><Text code>{code}</Text></List.Item>}
/>;
}