fix(admin): remove operation-level email reauthentication
This commit is contained in:
@@ -100,7 +100,7 @@ export default function AdministratorsResource() {
|
||||
extra={canManage ? <Button type="primary" icon={<PlusOutlined />} onClick={() => openAssignment()}>按邮箱分配角色</Button> : <Typography.Text type="secondary">只读权限</Typography.Text>}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
六类系统角色可在此分配和撤销。每次变更都需要邮箱验证码与操作原因;最后一位 Owner 受服务端保护,不能被撤销。
|
||||
六类系统角色可在此分配和撤销。每次变更都需要填写操作原因并写入审计日志;最后一位 Owner 受服务端保护,不能被撤销。
|
||||
</Typography.Paragraph>
|
||||
<Form {...searchFormProps} layout="inline" style={{ marginBottom: 16 }}>
|
||||
<Form.Item name="q" label="搜索">
|
||||
@@ -154,10 +154,9 @@ export default function AdministratorsResource() {
|
||||
<ReasonActionModal
|
||||
open={Boolean(pendingAction)}
|
||||
title={pendingAction?.action === "revoke" ? `撤销角色:${pendingAction.label}` : `分配角色:${pendingAction?.label ?? ""}`}
|
||||
okText={pendingAction?.action === "revoke" ? "验证并撤销" : "验证并分配"}
|
||||
okText={pendingAction?.action === "revoke" ? "确认撤销" : "确认分配"}
|
||||
danger={pendingAction?.action === "revoke"}
|
||||
confirmLoading={saving}
|
||||
reauthPermission="admin.users.manage_roles"
|
||||
onCancel={() => setPendingAction(null)}
|
||||
onSubmit={submitRoleAction}
|
||||
/>
|
||||
|
||||
@@ -206,9 +206,8 @@ export function SubscriptionsResource() {
|
||||
<ReasonActionModal
|
||||
open={Boolean(selected && extendDays !== null)}
|
||||
title="延长订阅权益"
|
||||
okText="验证并延长"
|
||||
okText="确认延长"
|
||||
confirmLoading={saving}
|
||||
reauthPermission="billing.adjustments.write"
|
||||
onCancel={() => setExtendDays(null)}
|
||||
onSubmit={extend}
|
||||
/>
|
||||
@@ -218,7 +217,6 @@ export function SubscriptionsResource() {
|
||||
okText="确认撤销"
|
||||
danger
|
||||
confirmLoading={Boolean(revokingId)}
|
||||
reauthPermission="billing.adjustments.write"
|
||||
onCancel={() => setRevokeTarget(null)}
|
||||
onSubmit={(reason) => revoke(revokeTarget!, reason)}
|
||||
/>
|
||||
@@ -430,7 +428,6 @@ export function OrdersResource() {
|
||||
okText="确认执行"
|
||||
danger={target?.action === "record_refund"}
|
||||
confirmLoading={saving}
|
||||
reauthPermission="billing.adjustments.write"
|
||||
onCancel={() => setTarget(null)}
|
||||
onSubmit={adjust}
|
||||
/>
|
||||
|
||||
@@ -319,18 +319,16 @@ export default function CodesPage() {
|
||||
<ReasonActionModal
|
||||
open={Boolean(pendingCreate)}
|
||||
title="生成兑换码"
|
||||
okText="验证并生成"
|
||||
okText="确认生成"
|
||||
confirmLoading={createMutation.isPending}
|
||||
reauthPermission="billing.adjustments.write"
|
||||
onCancel={() => setPendingCreate(null)}
|
||||
onSubmit={submitCreate}
|
||||
/>
|
||||
<ReasonActionModal
|
||||
open={Boolean(pendingEdit)}
|
||||
title="编辑未兑换码"
|
||||
okText="验证并保存"
|
||||
okText="确认保存"
|
||||
confirmLoading={updateMutation.isPending}
|
||||
reauthPermission="billing.adjustments.write"
|
||||
onCancel={() => setPendingEdit(null)}
|
||||
onSubmit={submitEdit}
|
||||
/>
|
||||
@@ -340,7 +338,6 @@ export default function CodesPage() {
|
||||
okText="确认撤销"
|
||||
danger
|
||||
confirmLoading={revoking}
|
||||
reauthPermission="billing.adjustments.write"
|
||||
onCancel={() => setRevokeRecord(null)}
|
||||
onSubmit={(reason) => revoke(revokeRecord!, reason)}
|
||||
/>
|
||||
|
||||
@@ -162,7 +162,6 @@ export default function FeatureFlagsManagement() {
|
||||
title={`发布功能开关${publishTarget ? `:${publishTarget.flagKey}` : ""}`}
|
||||
okText="确认发布"
|
||||
confirmLoading={Boolean(publishingId)}
|
||||
reauthPermission="ops.flags.write"
|
||||
onCancel={() => setPublishTarget(null)}
|
||||
onSubmit={(reason) => publish(publishTarget!, reason)}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,6 @@ type MfaStatus = {
|
||||
required: boolean;
|
||||
enrolled: boolean;
|
||||
verified: boolean;
|
||||
highRiskWritesEnabled: boolean;
|
||||
expiresIn?: number;
|
||||
};
|
||||
|
||||
@@ -104,7 +103,7 @@ export default function MfaSecurity() {
|
||||
if (!data) return;
|
||||
setStatus(data);
|
||||
setEnrollment(undefined);
|
||||
setNotice("当前管理员会话已完成真实第二因素验证。高风险操作仍需随后完成权限范围内的邮箱验证码。");
|
||||
setNotice("当前管理员会话已完成 MFA 验证。");
|
||||
}
|
||||
|
||||
async function recover({ code }: { code: string }) {
|
||||
@@ -131,8 +130,8 @@ export default function MfaSecurity() {
|
||||
setEnrollment(undefined);
|
||||
setBackupCodes(undefined);
|
||||
setNotice(data.required
|
||||
? "MFA 已禁用。该角色要求 MFA,因此高风险写入现已关闭,重新绑定后才能恢复。"
|
||||
: "MFA 已禁用,当前 MFA 与邮箱重认证证明均已撤销。");
|
||||
? "MFA 已禁用。当前角色要求启用 MFA,可随时重新绑定。"
|
||||
: "MFA 已禁用,当前会话的 MFA 证明已撤销。");
|
||||
}
|
||||
|
||||
if (loading && !status) {
|
||||
@@ -153,7 +152,7 @@ export default function MfaSecurity() {
|
||||
type="warning"
|
||||
showIcon
|
||||
message="当前角色强制要求 MFA"
|
||||
description="在 MFA 绑定完成前,所有高风险写入均默认拒绝。"
|
||||
description="这是账户级安全要求,不会再触发具体管理操作的邮箱验证码复核。"
|
||||
/> : null}
|
||||
|
||||
<Card title="当前状态">
|
||||
@@ -167,11 +166,6 @@ export default function MfaSecurity() {
|
||||
<Descriptions.Item label="当前会话">
|
||||
<Text type={status?.verified ? undefined : "secondary"}>{status?.verified ? "已验证" : "未验证"}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="高风险写入前置能力">
|
||||
<Text type={status?.highRiskWritesEnabled ? undefined : "danger"}>
|
||||
{status?.highRiskWritesEnabled ? "可发起邮箱重认证" : "已关闭"}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
@@ -208,7 +202,7 @@ export default function MfaSecurity() {
|
||||
</Card> : null}
|
||||
|
||||
{status?.enrolled && !status.verified ? <Card title="验证当前会话">
|
||||
<Paragraph>高权限操作前,先用认证器验证码或一次性恢复码完成真实第二因素。</Paragraph>
|
||||
<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>
|
||||
@@ -254,7 +248,7 @@ export default function MfaSecurity() {
|
||||
<Divider />
|
||||
<div>
|
||||
<Title level={4}>禁用 MFA</Title>
|
||||
<Paragraph type="secondary">禁用会轮换 Better Auth 会话,并撤销当前 MFA 与邮箱重认证证明。</Paragraph>
|
||||
<Paragraph type="secondary">禁用会轮换 Better Auth 会话,并撤销当前会话的 MFA 证明。</Paragraph>
|
||||
<Form layout="inline" onFinish={disable}>
|
||||
<Form.Item name="password" rules={[{ required: true, message: "请输入当前密码" }]}>
|
||||
<Input.Password placeholder="当前密码" autoComplete="current-password" maxLength={128} />
|
||||
|
||||
@@ -280,9 +280,8 @@ export default function PaymentManagement() {
|
||||
<ReasonActionModal
|
||||
open={Boolean(pendingEpaySettings)}
|
||||
title="保存易支付设置"
|
||||
okText="验证并保存"
|
||||
okText="确认保存"
|
||||
confirmLoading={epaySaving}
|
||||
reauthPermission="billing.adjustments.write"
|
||||
onCancel={() => setPendingEpaySettings(null)}
|
||||
onSubmit={saveEpaySettings}
|
||||
/>
|
||||
|
||||
@@ -280,9 +280,8 @@ export default function ProductManagement() {
|
||||
<ReasonActionModal
|
||||
open={Boolean(pendingSave)}
|
||||
title="保存商品草稿"
|
||||
okText="验证并保存"
|
||||
okText="确认保存"
|
||||
confirmLoading={saving}
|
||||
reauthPermission="billing.products.write"
|
||||
onCancel={() => setPendingSave(null)}
|
||||
onSubmit={save}
|
||||
/>
|
||||
@@ -291,7 +290,6 @@ export default function ProductManagement() {
|
||||
title={`发布商品${publishTarget ? `:${publishTarget.name}` : ""}`}
|
||||
okText="确认发布"
|
||||
confirmLoading={Boolean(publishingId)}
|
||||
reauthPermission="billing.products.publish"
|
||||
onCancel={() => setPublishTarget(null)}
|
||||
onSubmit={(reason) => publish(publishTarget!, reason)}
|
||||
/>
|
||||
|
||||
@@ -1,136 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, Form, Input, Modal, Space } from "antd";
|
||||
import { Alert, Form, Input, Modal } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { AdminPermission } from "@/lib/admin/auth-policy";
|
||||
|
||||
type ReasonActionModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
okText: string;
|
||||
confirmLoading?: boolean;
|
||||
danger?: boolean;
|
||||
reauthPermission?: AdminPermission;
|
||||
confirmLoading?: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (reason: string) => void | Promise<void>;
|
||||
onSubmit: (reason: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
type FormValues = { reason: string; otp?: string };
|
||||
|
||||
async function adminSecurityRequest<T>(url: string, body: object): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const value = await response.json().catch(() => null) as {
|
||||
data?: T;
|
||||
error?: unknown;
|
||||
} | null;
|
||||
if (response.ok && value?.data) return value.data;
|
||||
throw new Error(
|
||||
typeof value?.error === "string" ? value.error : "安全验证失败,请稍后再试",
|
||||
);
|
||||
}
|
||||
type ReasonFormValues = {
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function ReasonActionModal({
|
||||
open,
|
||||
title,
|
||||
okText,
|
||||
confirmLoading = false,
|
||||
danger = false,
|
||||
reauthPermission,
|
||||
confirmLoading = false,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ReasonActionModalProps) {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [otpSent, setOtpSent] = useState(false);
|
||||
const [reauthLoading, setReauthLoading] = useState(false);
|
||||
const [reauthError, setReauthError] = useState<string>();
|
||||
const [form] = Form.useForm<ReasonFormValues>();
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [actionError, setActionError] = useState<string>();
|
||||
|
||||
async function requestOtp() {
|
||||
if (!reauthPermission) return;
|
||||
setReauthLoading(true);
|
||||
setReauthError(undefined);
|
||||
async function submit(values: ReasonFormValues) {
|
||||
setActionError(undefined);
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminSecurityRequest("/api/admin/reauth", {
|
||||
action: "request",
|
||||
permission: reauthPermission,
|
||||
});
|
||||
setOtpSent(true);
|
||||
await onSubmit(values.reason.trim());
|
||||
} catch (error) {
|
||||
setReauthError(error instanceof Error ? error.message : "验证码发送失败");
|
||||
setActionError(error instanceof Error ? error.message : "操作失败,请稍后再试");
|
||||
} finally {
|
||||
setReauthLoading(false);
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit({ reason, otp }: FormValues) {
|
||||
setReauthError(undefined);
|
||||
setReauthLoading(true);
|
||||
try {
|
||||
if (reauthPermission) {
|
||||
await adminSecurityRequest("/api/admin/reauth", {
|
||||
action: "verify",
|
||||
permission: reauthPermission,
|
||||
otp,
|
||||
});
|
||||
}
|
||||
await onSubmit(reason.trim());
|
||||
} catch (error) {
|
||||
setReauthError(error instanceof Error ? error.message : "操作失败,请稍后再试");
|
||||
} finally {
|
||||
setReauthLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <Modal
|
||||
title={title}
|
||||
open={open}
|
||||
okText={okText}
|
||||
cancelText="取消"
|
||||
confirmLoading={confirmLoading || reauthLoading}
|
||||
okButtonProps={{
|
||||
danger,
|
||||
disabled: Boolean(reauthPermission && !otpSent),
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={onCancel}
|
||||
afterOpenChange={(visible) => {
|
||||
if (visible) form.resetFields();
|
||||
setOtpSent(false);
|
||||
setReauthError(undefined);
|
||||
}}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={submit}>
|
||||
<Form.Item name="reason" label="操作原因" rules={[{ required: true, whitespace: true }, { max: 500 }]}>
|
||||
<Input.TextArea rows={3} showCount maxLength={500} autoFocus />
|
||||
</Form.Item>
|
||||
|
||||
{reauthPermission ? <Form.Item
|
||||
label="邮箱验证码"
|
||||
extra="验证码发送到当前登录管理员邮箱,5 分钟内有效,且只授权本次操作权限。"
|
||||
required
|
||||
>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Form.Item
|
||||
name="otp"
|
||||
noStyle
|
||||
rules={[{ required: true }, { pattern: /^\d{6}$/, message: "请输入 6 位验证码" }]}
|
||||
>
|
||||
<Input inputMode="numeric" autoComplete="one-time-code" maxLength={6} placeholder="请输入 6 位验证码" />
|
||||
</Form.Item>
|
||||
<Button type="default" loading={reauthLoading} onClick={() => void requestOtp()}>
|
||||
{otpSent ? "重新发送" : "发送验证码"}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item> : null}
|
||||
|
||||
{reauthError ? <Alert type="error" showIcon message={reauthError} /> : null}
|
||||
</Form>
|
||||
</Modal>;
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
okText={okText}
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger }}
|
||||
confirmLoading={confirmLoading || actionLoading}
|
||||
onCancel={onCancel}
|
||||
onOk={() => form.submit()}
|
||||
afterOpenChange={(visible) => {
|
||||
if (visible) form.resetFields();
|
||||
setActionError(undefined);
|
||||
}}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" preserve={false} onFinish={submit}>
|
||||
<Form.Item
|
||||
label="操作原因"
|
||||
name="reason"
|
||||
rules={[
|
||||
{ required: true, whitespace: true, message: "请输入操作原因" },
|
||||
{ max: 500, message: "操作原因最多 500 字" },
|
||||
]}
|
||||
>
|
||||
<Input.TextArea rows={3} maxLength={500} showCount autoFocus />
|
||||
</Form.Item>
|
||||
{actionError ? <Alert type="error" showIcon message={actionError} /> : null}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user