diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index d965b0bb..1c936a74 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3466,32 +3466,33 @@ - 相关记录:BUG-177、BUG-198、BUG-199 - 修复版本:本地未提交候选 -## BUG-207 | 管理端业务操作重复要求邮箱验证码与手工原因 +## BUG-207 | 管理端业务操作重复要求邮箱验证码、手工原因与二次确认弹窗 - 状态:resolved(补充修复已完成,待提交与发布) - 首次发现:2026-08-16 - 最近更新:2026-08-16 -- 影响面:管理端兑换码生成/编辑/撤销、管理员角色变更、账务与订阅调整、商品保存/发布、功能开关发布、易支付设置等写操作。 -- 用户现象:管理员已经登录后台并具备对应权限,执行批量生成兑换码等日常营销操作时仍需发送并等待邮箱验证码;首轮修复删除邮箱验证后,业务确认弹窗仍强制填写“操作原因”,日常营销操作仍被无必要的二次输入打断。 -- 根因:多个管理写入被统一接入 operation-level `requireHighRiskAdminMutation`,公共 `ReasonActionModal` 又内置权限级邮箱 OTP challenge/proof,导致账户登录安全与每次业务操作二次验证被错误叠加到所有管理业务。 -- 修复:所有管理业务写入统一改用 `requireAdminMutation`,继续强制管理员会话、细粒度权限与可信 Origin;删除 `/api/admin/reauth`、high-risk challenge/proof cookie 及相关 UI 参数。公共业务确认弹窗改为无需填写任何内容的 `ConfirmActionModal`,兑换码、管理员角色、订单/订阅调整、商品、功能开关、易支付与用户重置请求体不再要求客户端 `reason`;服务端按动作注入固定审计标识并继续传给原数据库 RPC 的非空 reason 参数。 -- 安全边界:保留 request ID、数据库权限检查、领域 RPC、append-only 审计、最后一位 Owner 保护;保留账户级 Better Auth TOTP MFA 和一次性恢复码;普通用户登录、注册与找回密码的邮箱 OTP 不受影响。 -- 验证:管理端权限、业务确认、兑换码/账务合同、账户级 MFA 等 5 个聚焦测试文件共 47/47 通过;`tsc --noEmit`、17 个目标源文件 ESLint 与 `git diff --check` 通过。首轮邮箱复核移除提交已发布到 staging,但“无需手工原因”的补充修复尚未发布。 -- 防复发:新增管理业务时只能在登录、权限、Origin、服务端审计标识和数据库审计边界内扩展;不得把邮箱 OTP 或手工原因输入放回公共业务确认弹窗。账户级 MFA 与普通用户身份验证必须保持独立。 -- 相关记录:BUG-155、BUG-156 +- 影响面:管理端兑换码生成/编辑/撤销、管理员角色变更、账务与订阅调整、商品保存/发布、功能开关发布、模型发布、易支付设置等写操作。 +- 用户现象:管理员已经登录后台并具备对应权限,执行批量生成兑换码等日常操作时仍需发送邮箱验证码;首轮删除邮箱验证后,又先填写“操作原因”,提交真实业务表单后还出现额外的“确定”弹窗,形成连续二次确认。 +- 根因:多个管理写入被统一接入 operation-level `requireHighRiskAdminMutation`,公共 `ReasonActionModal` 内置权限级邮箱 OTP challenge/proof;后续只把它替换成 `ConfirmActionModal`,仍保留了多余的操作级确认层,没有让真实的数据录入表单直接执行。 +- 修复:所有管理业务写入统一使用 `requireAdminMutation`,继续强制管理员会话、对应权限与可信 Origin;删除 `/api/admin/reauth`、high-risk challenge/proof cookie、`ReasonActionModal`、`ConfirmActionModal`、相关 `Popconfirm` / `Modal.confirm` 与 pending-confirm 状态。真实的数据录入 Modal 继续保留,但点击表单的“生成 / 保存 / 发布”等主按钮即直接执行;无额外参数的撤销、重试与开关动作由原按钮直接执行。客户端不再提交手工 `reason`,服务端按动作注入固定审计标识并继续传给数据库 RPC 的非空审计字段。 +- 安全边界:保留 request ID、数据库 actor 身份校验、领域 RPC、细粒度权限、可信 Origin、append-only 审计和最后一位 Owner 保护;保留账户级 Better Auth TOTP MFA 与一次性恢复码;普通用户登录、注册和找回密码的邮箱 OTP 不受影响。 +- 验证:全局源码扫描确认管理业务中不存在 `ConfirmActionModal`、`Popconfirm`、`Modal.confirm`、`reason-action-modal` 或“操作原因”;管理端权限、业务直提交流程、兑换码/账务合同、账户级 MFA 等 6 个聚焦测试文件共 52/52 通过;`tsc --noEmit`、改动 TS/TSX 文件 ESLint 与 `git diff --check` 通过。 +- 防复发:新增管理业务时只能在登录、权限、Origin、服务端审计标识和数据库审计边界内扩展;不得把邮箱 OTP、手工原因或通用二次确认弹窗放回日常管理操作。只有真实的数据录入/选择表单可以使用 Modal,账户级 MFA 与普通用户身份验证必须保持独立。 +- 相关记录:BUG-155、BUG-156、BUG-209 - 修复版本:本地未提交候选(首轮邮箱复核移除:`4f6cf5782d3871a28e531a4dff9bcc6a2633ce09`) -## BUG-208 | self-hosted 管理端生成兑换码返回通用 500 +## BUG-209 | self-hosted 管理端生成兑换码先返回通用 500,随后合法管理员被权限链拒绝 -- 状态:resolved(本地修复,待提交与发布) +- 状态:resolved(本地修复,待提交、迁移与发布) - 首次发现:2026-08-16 - 最近更新:2026-08-16 -- 影响面:self-hosted runtime 的 `POST /api/admin/codes` 批量生成兑换码;列表读取、普通用户兑换与 production 未在本次诊断中验证。 -- 用户现象:具备权限的管理员提交合法点数、数量、到期时间和备注后,接口返回 `500 {"error":"后台服务暂时不可用"}`,无法生成兑换码。 -- 根因:`runCodeRpc()` 将 JavaScript 对象数组直接作为 `$5::jsonb` 参数交给 `node-postgres`。实际 `pg` serializer 会把该值编码成 PostgreSQL array 文本(例如 `{"{\\"codeHash\\":...}"}`),而不是 JSON 数组;RPC 的 `jsonb` 入参因此无法按预期解析,底层错误又被通用管理错误映射收敛为 500。staging 容器原始 SQLSTATE 未取得,因此不记录未经验证的具体错误码。 -- 修复:调用 `public.admin_create_redemption_codes` 前显式执行 `JSON.stringify(input.p_codes)`;RPC 签名、权限、request ID、明文码仅单次返回和 append-only 审计保持不变。同时由服务端注入 `admin_console_create_redemption_codes` 审计标识,不再依赖客户端原因。 -- 验证:本地使用项目实际 `pg` serializer 对比确认:对象数组会被编码为 PostgreSQL array 文本,显式序列化后保持合法 JSON 数组文本;新增源码合同锁定 `JSON.stringify(input.p_codes)`、服务端审计标识和无客户端 reason,相关 5 个聚焦测试文件共 47/47 通过;`tsc --noEmit`、目标 ESLint 与 `git diff --check` 通过。 -- 防复发:self-hosted `pg` 的 `jsonb` 参数必须显式 JSON 序列化;数据库/源码合同测试需要锁定 `JSON.stringify(input.p_codes)`,不能只依赖 Supabase RPC 测试覆盖参数编码。 +- 影响面:self-hosted runtime 的兑换码列表、批量生成、编辑与撤销;普通用户兑换和 production 未在本次修复中验证。 +- 用户现象:第一次提交合法点数、数量、到期时间和备注时,`POST /api/admin/codes` 返回 `500 {"error":"后台服务暂时不可用"}`;修正参数序列化后,已登录且能进入后台的管理员再次提交无 `reason` 请求,返回 `{"error":"无权执行此操作"}`。 +- 根因:存在两个独立问题。第一,`runCodeRpc()` 将 JavaScript 对象数组直接作为 `$5::jsonb` 参数交给 `node-postgres`,被编码成 PostgreSQL array 文本而不是 JSON 数组。第二,兑换码页面、Refine access-control、API 与 PostgreSQL wrapper 使用了不一致且过窄的 `billing.adjustments.write`;部分合法后台角色只有所有管理员共有的 `admin.access`,因此请求在 UI、API 或数据库任一层都可能被拒绝。 +- 修复:调用 `public.admin_create_redemption_codes` 前显式执行 `JSON.stringify(input.p_codes)`。兑换码列表、创建、编辑、撤销的 UI 可写判断、Refine 资源读写权限、GET/POST/PATCH/DELETE 路由及三个 PostgreSQL wrapper 全部统一为 `admin.access`。新增向前迁移重建 wrapper 与审计 trigger,把兑换码审计行的 `permission_used` 统一写成 `admin.access`;保留服务端固定审计标识、原 RPC 签名、request ID、明文码仅单次返回和数据库 actor 身份校验。 +- 验证:本地使用项目实际 `pg` serializer 对比确认显式序列化后保持合法 JSON 数组文本;源码合同锁定兑换码 UI、Refine、四个 API 方法、三个数据库 wrapper 与审计权限一致,且不再依赖客户端 `reason` 或操作确认弹窗。相关 6 个聚焦测试文件共 52/52 通过;`tsc --noEmit`、改动 TS/TSX 文件 ESLint 与 `git diff --check` 通过。 +- 发布要求:必须先把 `20260816010000_admin_redemption_admin_access.sql` 应用到 staging 数据库,再部署同一精确 SHA;只部署应用代码仍会被旧 PostgreSQL wrapper 按 `billing.adjustments.write` 拒绝。 +- 防复发:self-hosted `pg` 的 `jsonb` 参数必须显式 JSON 序列化;一个管理资源的列表、UI access-control、API guard、数据库 permission check 与审计 `permission_used` 必须使用同一权限语义,不能只改前端或 API。 - 相关记录:BUG-155、BUG-207 - 修复版本:本地未提交候选 diff --git a/frontend/src/app/api/admin/codes/[id]/route.ts b/frontend/src/app/api/admin/codes/[id]/route.ts index 7d63f23a..e800a2ec 100644 --- a/frontend/src/app/api/admin/codes/[id]/route.ts +++ b/frontend/src/app/api/admin/codes/[id]/route.ts @@ -28,7 +28,7 @@ export async function PATCH( try { const session = await requireAdminMutation( request, - "billing.adjustments.write", + "admin.access", ); const parsedParams = paramsSchema.safeParse(await context.params); const parsedBody = updateCodeSchema.safeParse( @@ -64,7 +64,7 @@ export async function DELETE( try { const session = await requireAdminMutation( request, - "billing.adjustments.write", + "admin.access", ); const parsed = paramsSchema.safeParse(await context.params); if (!parsed.success) return invalidQueryResponse(); diff --git a/frontend/src/app/api/admin/codes/route.ts b/frontend/src/app/api/admin/codes/route.ts index 3d5d4969..97625831 100644 --- a/frontend/src/app/api/admin/codes/route.ts +++ b/frontend/src/app/api/admin/codes/route.ts @@ -64,7 +64,7 @@ function serializedCodeRow(row: CodeRow) { export async function GET(request: Request) { try { - await requirePermission("billing.orders.read"); + await requirePermission("admin.access"); const parsed = parseListQuery(request); if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); const { page, pageSize, sort, order, q, status } = parsed.data; @@ -124,7 +124,7 @@ export async function POST(request: Request) { try { const session = await requireAdminMutation( request, - "billing.adjustments.write", + "admin.access", ); const parsed = createCodesSchema.safeParse( await request.json().catch(() => null), diff --git a/frontend/src/components/admin/administrators-resource.tsx b/frontend/src/components/admin/administrators-resource.tsx index a9d43e27..d1501d73 100644 --- a/frontend/src/components/admin/administrators-resource.tsx +++ b/frontend/src/components/admin/administrators-resource.tsx @@ -7,7 +7,6 @@ import { App, Button, Card, Form, Input, Modal, Select, Space, Table, Tag, Typog import { useState } from "react"; import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; -import { ConfirmActionModal } from "./confirm-action-modal"; import { formatAdminDate } from "./resource-table"; const roleOptions = [ @@ -30,12 +29,11 @@ interface Administrator { } type AssignmentForm = { email: string; roleCode: RoleCode }; -type PendingRoleAction = { +type RoleAction = { action: "assign" | "revoke"; roleCode: RoleCode; userId?: string; email?: string; - label: string; }; export default function AdministratorsResource() { @@ -50,7 +48,6 @@ export default function AdministratorsResource() { const [assignmentForm] = Form.useForm(); const [assignmentOpen, setAssignmentOpen] = useState(false); const [assignmentUser, setAssignmentUser] = useState(null); - const [pendingAction, setPendingAction] = useState(null); const [saving, setSaving] = useState(false); const canManage = Boolean(identity?.permissions.includes("admin.users.manage_roles")); @@ -60,34 +57,32 @@ export default function AdministratorsResource() { setAssignmentOpen(true); } - function prepareAssignment(values: AssignmentForm) { - const role = roleOptions.find((item) => item.value === values.roleCode)!; - setPendingAction({ + async function assignRole(values: AssignmentForm) { + await submitRoleAction({ action: "assign", roleCode: values.roleCode, ...(assignmentUser ? { userId: assignmentUser.id } : { email: values.email.trim() }), - label: `${assignmentUser?.email ?? values.email.trim()} · ${role.label}`, }); } - async function submitRoleAction() { - if (!pendingAction) return; + async function submitRoleAction(action: RoleAction) { setSaving(true); try { await adminRequestJson("/api/admin/administrators", { - method: pendingAction.action === "assign" ? "POST" : "DELETE", + method: action.action === "assign" ? "POST" : "DELETE", body: JSON.stringify({ - userId: pendingAction.userId, - email: pendingAction.email, - roleCode: pendingAction.roleCode, + userId: action.userId, + email: action.email, + roleCode: action.roleCode, }), }); - message.success(pendingAction.action === "assign" ? "管理员角色已分配" : "管理员角色已撤销"); - setPendingAction(null); + message.success(action.action === "assign" ? "管理员角色已分配" : "管理员角色已撤销"); setAssignmentOpen(false); setAssignmentUser(null); assignmentForm.resetFields(); await tableQuery.refetch(); + } catch (error) { + message.error(error instanceof Error ? error.message : "管理员角色操作失败"); } finally { setSaving(false); } @@ -121,7 +116,7 @@ export default function AdministratorsResource() { closable={canManage} onClose={(event) => { event.preventDefault(); - setPendingAction({ action: "revoke", roleCode: role, userId: item.id, label: `${item.email} · ${role}` }); + void submitRoleAction({ action: "revoke", roleCode: role, userId: item.id }); }} >{role})}, }, @@ -132,13 +127,14 @@ export default function AdministratorsResource() { assignmentForm.submit()} onCancel={() => setAssignmentOpen(false)} destroyOnHidden > - form={assignmentForm} layout="vertical" onFinish={prepareAssignment}> + form={assignmentForm} layout="vertical" onFinish={(values) => void assignRole(values)}> @@ -150,15 +146,6 @@ export default function AdministratorsResource() { - setPendingAction(null)} - onConfirm={submitRoleAction} - /> ); } diff --git a/frontend/src/components/admin/billing-operations-resources.tsx b/frontend/src/components/admin/billing-operations-resources.tsx index 010458e8..e69b0372 100644 --- a/frontend/src/components/admin/billing-operations-resources.tsx +++ b/frontend/src/components/admin/billing-operations-resources.tsx @@ -18,7 +18,6 @@ import { import { useState } from "react"; import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; -import { ConfirmActionModal } from "./confirm-action-modal"; import { formatAdminDate, ResourceTable } from "./resource-table"; const { Text } = Typography; @@ -48,8 +47,6 @@ export function SubscriptionsResource() { const [selected, setSelected] = useState(null); const [saving, setSaving] = useState(false); const [revokingId, setRevokingId] = useState(null); - const [revokeTarget, setRevokeTarget] = useState(null); - const [extendDays, setExtendDays] = useState(null); const canAdjust = Boolean( identity?.permissions.includes("billing.adjustments.write"), ); @@ -71,19 +68,16 @@ export function SubscriptionsResource() { await table.tableQuery.refetch(); } - function prepareExtend(values: AdjustmentForm) { - setExtendDays(values.days); - } - - async function extend() { - if (!selected || extendDays === null) return; + async function extend(values: AdjustmentForm) { + if (!selected) return; setSaving(true); try { - await adjust(selected, "extend", extendDays); + await adjust(selected, "extend", values.days); message.success("订阅已延长"); - setExtendDays(null); setSelected(null); form.resetFields(); + } catch (error) { + message.error(error instanceof Error ? error.message : "延长订阅失败"); } finally { setSaving(false); } @@ -94,7 +88,8 @@ export function SubscriptionsResource() { try { await adjust(item, "revoke", undefined); message.success("订阅已撤销"); - setRevokeTarget(null); + } catch (error) { + message.error(error instanceof Error ? error.message : "撤销订阅失败"); } finally { setRevokingId(null); } @@ -146,7 +141,6 @@ export function SubscriptionsResource() { disabled={item.status !== "active"} onClick={() => { setSelected(item); - setExtendDays(null); form.setFieldsValue({ days: 30 }); }} > @@ -159,7 +153,7 @@ export function SubscriptionsResource() { danger disabled={item.status !== "active"} loading={revokingId === item.id} - onClick={() => setRevokeTarget(item)} + onClick={() => void revoke(item)} > 撤销 @@ -179,9 +173,10 @@ export function SubscriptionsResource() { /> form.submit()} onCancel={() => setSelected(null)} destroyOnHidden @@ -189,7 +184,7 @@ export function SubscriptionsResource() { form={form} layout="vertical" - onFinish={prepareExtend} + onFinish={extend} > - setExtendDays(null)} - onConfirm={extend} - /> - setRevokeTarget(null)} - onConfirm={() => revoke(revokeTarget!)} - /> ); } @@ -248,17 +226,18 @@ export function OrdersResource() { const { data: identity } = useGetIdentity(); const invalidate = useInvalidate(); const [target, setTarget] = useState<{ - order: Order; + orderId: string; action: "retry_grant" | "compensate" | "record_refund"; } | null>(null); - const [saving, setSaving] = useState(false); const canAdjust = Boolean( identity?.permissions.includes("billing.adjustments.write"), ); - async function adjust() { - if (!target) return; - setSaving(true); + async function adjust( + order: Order, + action: "retry_grant" | "compensate" | "record_refund", + ) { + setTarget({ orderId: order.id, action }); try { const result = await adminRequestJson<{ data: { actionSuccess: boolean }; @@ -266,26 +245,25 @@ export function OrdersResource() { method: "POST", headers: { "x-request-id": crypto.randomUUID() }, body: JSON.stringify({ - id: target.order.id, - action: target.action, - expectedVersion: target.order.adjustmentVersion, + id: order.id, + action, + expectedVersion: order.adjustmentVersion, }), }); await invalidate({ resource: "orders", invalidates: ["list"] }); - if (target.action === "retry_grant" && !result.data.actionSuccess) { + if (action === "retry_grant" && !result.data.actionSuccess) { message.warning("已执行重试,但权益发放仍失败,请查看最新错误"); } else { message.success( - target.action === "record_refund" + action === "record_refund" ? "已记录账务全额退款状态" : "订单权益操作已完成", ); } - setTarget(null); } catch (error) { message.error(error instanceof Error ? error.message : "订单操作失败"); } finally { - setSaving(false); + setTarget(null); } } @@ -369,7 +347,8 @@ export function OrdersResource() { @@ -378,7 +357,8 @@ export function OrdersResource() { disabled={ item.grantStatus !== "failed" || item.grantType !== "credits" } - onClick={() => setTarget({ order: item, action: "compensate" })} + loading={target?.orderId === item.id && target.action === "compensate"} + onClick={() => void adjust(item, "compensate")} > 人工补偿 @@ -390,7 +370,8 @@ export function OrdersResource() { item.paidAt == null || item.status === "refunded" } - onClick={() => setTarget({ order: item, action: "record_refund" })} + loading={target?.orderId === item.id && target.action === "record_refund"} + onClick={() => void adjust(item, "record_refund")} > 记录全额退款 @@ -398,14 +379,11 @@ export function OrdersResource() { ) : null, }, ]; - const modalTitle = - target?.action === "retry_grant" - ? "重试失败的权益发放" - : target?.action === "compensate" - ? "人工补偿失败的积分权益" - : "仅记录账务全额退款(不会调用支付网关)"; return ( <> + + “记录全额退款”仅记录账务状态,不调用支付网关。 + resource="orders" title="支付订单与权益发放" @@ -419,15 +397,6 @@ export function OrdersResource() { "recorded", ].map((value) => ({ value, label: value }))} /> - setTarget(null)} - onConfirm={adjust} - /> ); } diff --git a/frontend/src/components/admin/codes-resource.tsx b/frontend/src/components/admin/codes-resource.tsx index f5737897..30579697 100644 --- a/frontend/src/components/admin/codes-resource.tsx +++ b/frontend/src/components/admin/codes-resource.tsx @@ -22,7 +22,6 @@ import { import dayjs from "dayjs"; import { useState } from "react"; -import { ConfirmActionModal } from "@/components/admin/confirm-action-modal"; import { formatAdminDate, ResourceTable, @@ -72,52 +71,54 @@ export default function CodesPage() { const { mutateAsync: updateCode, mutation: updateMutation } = useUpdate(); const [createOpen, setCreateOpen] = useState(false); - const [pendingCreate, setPendingCreate] = useState(null); const [editRecord, setEditRecord] = useState(null); - const [pendingEdit, setPendingEdit] = useState(null); const [generated, setGenerated] = useState([]); - const [revokeRecord, setRevokeRecord] = useState(null); - const [revoking, setRevoking] = useState(false); + const [revokingId, setRevokingId] = useState(null); const [createForm] = Form.useForm(); const [editForm] = Form.useForm(); - const writable = Boolean( - identity?.permissions.includes("billing.adjustments.write"), - ); + const writable = Boolean(identity?.permissions.includes("admin.access")); - async function submitCreate() { - if (!pendingCreate) return; - const result = await createCodes({ - resource: "codes", - values: { - credits: pendingCreate.credits, - count: pendingCreate.count, - expiresAt: pendingCreate.expiresAt?.toISOString() ?? null, - note: pendingCreate.note?.trim() || null, - }, - successNotification: false, - }); - setGenerated(result.data.generated); - setPendingCreate(null); - setCreateOpen(false); - createForm.resetFields(); + async function submitCreate(values: CreateValues) { + try { + const result = await createCodes({ + resource: "codes", + values: { + credits: values.credits, + count: values.count, + expiresAt: values.expiresAt?.toISOString() ?? null, + note: values.note?.trim() || null, + }, + successNotification: false, + }); + setGenerated(result.data.generated); + setCreateOpen(false); + createForm.resetFields(); + } catch (error) { + message.error(error instanceof Error ? error.message : "生成兑换码失败"); + } } - async function submitEdit() { - if (!editRecord || !pendingEdit) return; - await updateCode({ - resource: "codes", - id: editRecord.id, - values: { - note: pendingEdit.note?.trim() || null, - expiresAt: pendingEdit.expiresAt?.toISOString() ?? null, - }, - }); - setPendingEdit(null); - setEditRecord(null); + async function submitEdit(values: EditValues) { + if (!editRecord) return; + try { + await updateCode({ + resource: "codes", + id: editRecord.id, + values: { + note: values.note?.trim() || null, + expiresAt: values.expiresAt?.toISOString() ?? null, + }, + successNotification: false, + }); + setEditRecord(null); + message.success("兑换码已保存"); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存兑换码失败"); + } } async function revoke(record: CodeRecord) { - setRevoking(true); + setRevokingId(record.id); try { await adminRequestJson(`/api/admin/codes/${record.id}`, { method: "DELETE", @@ -125,11 +126,10 @@ export default function CodesPage() { }); await invalidate({ resource: "codes", invalidates: ["list"] }); message.success("兑换码已撤销"); - setRevokeRecord(null); } catch (error) { message.error(error instanceof Error ? error.message : "撤销失败"); } finally { - setRevoking(false); + setRevokingId(null); } } @@ -175,7 +175,6 @@ export default function CodesPage() { @@ -216,10 +215,7 @@ export default function CodesPage() { writable ? ( @@ -230,10 +226,7 @@ export default function CodesPage() { { - setPendingCreate(null); - setCreateOpen(false); - }} + onCancel={() => setCreateOpen(false)} footer={null} destroyOnHidden > @@ -241,7 +234,7 @@ export default function CodesPage() { form={createForm} layout="vertical" initialValues={{ credits: 10, count: 1 }} - onFinish={setPendingCreate} + onFinish={(values) => void submitCreate(values)} > { - setPendingEdit(null); - setEditRecord(null); - }} + onCancel={() => setEditRecord(null)} footer={null} destroyOnHidden > -
+ void submitEdit(values)} + > @@ -313,31 +307,6 @@ export default function CodesPage() {
- setPendingCreate(null)} - onConfirm={submitCreate} - /> - setPendingEdit(null)} - onConfirm={submitEdit} - /> - setRevokeRecord(null)} - onConfirm={() => revoke(revokeRecord!)} - /> ); } diff --git a/frontend/src/components/admin/confirm-action-modal.tsx b/frontend/src/components/admin/confirm-action-modal.tsx deleted file mode 100644 index caaa8d17..00000000 --- a/frontend/src/components/admin/confirm-action-modal.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import { Alert, Modal, Typography } from "antd"; -import { useState } from "react"; - -type ConfirmActionModalProps = { - open: boolean; - title: string; - okText: string; - danger?: boolean; - confirmLoading?: boolean; - onCancel: () => void; - onConfirm: () => Promise | void; -}; - -export function ConfirmActionModal({ - open, - title, - okText, - danger = false, - confirmLoading = false, - onCancel, - onConfirm, -}: ConfirmActionModalProps) { - const [actionLoading, setActionLoading] = useState(false); - const [actionError, setActionError] = useState(); - - async function confirm() { - setActionError(undefined); - setActionLoading(true); - try { - await onConfirm(); - } catch (error) { - setActionError(error instanceof Error ? error.message : "操作失败,请稍后再试"); - } finally { - setActionLoading(false); - } - } - - return ( - void confirm()} - afterOpenChange={() => setActionError(undefined)} - destroyOnHidden - > - - 确认后将立即执行该操作,系统会自动记录管理员、动作和请求 ID。 - - {actionError ? : null} - - ); -} diff --git a/frontend/src/components/admin/feature-flags-management.tsx b/frontend/src/components/admin/feature-flags-management.tsx index 8279b58d..1c87ab99 100644 --- a/frontend/src/components/admin/feature-flags-management.tsx +++ b/frontend/src/components/admin/feature-flags-management.tsx @@ -8,7 +8,6 @@ import { App, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Ta import { useState } from "react"; import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; -import { ConfirmActionModal } from "./confirm-action-modal"; import { formatAdminDate } from "./resource-table"; const { Text } = Typography; @@ -51,7 +50,6 @@ export default function FeatureFlagsManagement() { const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [publishingId, setPublishingId] = useState(null); - const [publishTarget, setPublishTarget] = useState(null); const canWrite = Boolean(identity?.permissions.includes("ops.flags.write")); function edit(item?: FeatureFlag) { @@ -110,7 +108,8 @@ export default function FeatureFlagsManagement() { }); message.success("功能开关已发布"); await table.tableQuery.refetch(); - setPublishTarget(null); + } catch (error) { + message.error(error instanceof Error ? error.message : "发布失败"); } finally { setPublishingId(null); } @@ -125,7 +124,7 @@ export default function FeatureFlagsManagement() { { title: "操作", fixed: "right", - render: (_, item) => {canWrite && }{canWrite && item.status !== "published" && }, + render: (_, item) => {canWrite && }{canWrite && item.status !== "published" && }, }, ]; @@ -152,13 +151,5 @@ export default function FeatureFlagsManagement() { - setPublishTarget(null)} - onConfirm={() => publish(publishTarget!)} - /> ; } diff --git a/frontend/src/components/admin/model-management.tsx b/frontend/src/components/admin/model-management.tsx index 760e3c40..1cc71cc1 100644 --- a/frontend/src/components/admin/model-management.tsx +++ b/frontend/src/components/admin/model-management.tsx @@ -78,7 +78,6 @@ type ModelsPayload = { data: ModelVersion[]; total: number; providers: Provider[ type DiscoveredModel = { id: string; label?: string }; type DiscoveredModelsPayload = { data: DiscoveredModel[] }; type ModelFilters = { q?: string; status?: string }; -type VersionAction = { action: "publish" | "rollback"; model: ModelVersion }; const providerTypeLabels: Record = { openai: "OpenAI 官方", @@ -114,7 +113,6 @@ export default function ModelManagement() { const [discovering, setDiscovering] = useState(false); const [discoveredModels, setDiscoveredModels] = useState([]); const [actingId, setActingId] = useState(null); - const [versionAction, setVersionAction] = useState(null); const [filters, setFilters] = useState({}); const canWrite = Boolean(identity?.permissions.includes("models.write")); const canTest = Boolean(identity?.permissions.includes("models.test")); @@ -271,15 +269,12 @@ export default function ModelManagement() { } } - async function submitVersionAction() { - if (!versionAction) return; - const { action, model } = versionAction; + async function runVersionAction(action: "publish" | "rollback", model: ModelVersion) { try { await act(action === "publish" ? { action, versionId: model.id } : { action, configId: model.configId, targetVersion: model.version }, action === "publish" ? "模型已发布" : "模型已回滚", model.id); - setVersionAction(null); } catch (error) { message.error(error instanceof Error ? error.message : action === "publish" ? "发布失败" : "回滚失败"); } @@ -318,8 +313,8 @@ export default function ModelManagement() { render: (_, item) => {canWrite && } {canTest && } - {canPublish && item.status !== "published" && } - {canRollback && item.status !== "draft" && } + {canPublish && item.status !== "published" && } + {canRollback && item.status !== "draft" && } , }, ]; @@ -412,21 +407,5 @@ export default function ModelManagement() { - setVersionAction(null)} - onOk={() => void submitVersionAction()} - destroyOnHidden - > - - {versionAction?.action === "rollback" - ? "确认将此历史版本恢复为新的已发布版本?" - : "确认发布此模型版本?"} - - ; } diff --git a/frontend/src/components/admin/package-management.tsx b/frontend/src/components/admin/package-management.tsx index 0cf93c09..e91cc0d4 100644 --- a/frontend/src/components/admin/package-management.tsx +++ b/frontend/src/components/admin/package-management.tsx @@ -2,7 +2,7 @@ import { PlusOutlined } from "@ant-design/icons"; import { List } from "@refinedev/antd"; -import { Alert, App, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Row, Col, Space, Switch, Table, Typography, type TableColumnsType } from "antd"; +import { Alert, App, Button, Card, Form, Input, InputNumber, Modal, Row, Col, Space, Switch, Table, Typography, type TableColumnsType } from "antd"; import { useCallback, useEffect, useState } from "react"; const { Text } = Typography; @@ -135,7 +135,7 @@ export function PackageManagement() { { title: "点数", dataIndex: "credits", align: "right" }, { title: "排序", dataIndex: "sortOrder", align: "right" }, { title: "状态", dataIndex: "enabled", render: (enabled) => {enabled ? "启用" : "停用"} }, - { title: "操作", key: "actions", fixed: "right", render: (_, item) => {item.enabled && disablePackage(item.id)}>} }, + { title: "操作", key: "actions", fixed: "right", render: (_, item) => {item.enabled && } }, ]; return ( diff --git a/frontend/src/components/admin/payment-management.tsx b/frontend/src/components/admin/payment-management.tsx index 4dd07f32..28b79904 100644 --- a/frontend/src/components/admin/payment-management.tsx +++ b/frontend/src/components/admin/payment-management.tsx @@ -25,7 +25,6 @@ import { import type { Dayjs } from "dayjs"; import { useCallback, useEffect, useState } from "react"; -import { ConfirmActionModal } from "@/components/admin/confirm-action-modal"; import type { AdminIdentity } from "@/lib/admin/providers"; const { Text } = Typography; @@ -109,7 +108,6 @@ export default function PaymentManagement() { const [epaySaving, setEpaySaving] = useState(false); const [epayTesting, setEpayTesting] = useState(false); const [epayError, setEpayError] = useState(""); - const [pendingEpaySettings, setPendingEpaySettings] = useState(null); const canAdjustBilling = Boolean(identity?.permissions.includes("billing.adjustments.write")); const loadPayments = useCallback(async () => { @@ -175,23 +173,19 @@ export default function PaymentManagement() { } } - function prepareEpaySettings(values: EpaySettingsForm) { - setPendingEpaySettings(values); - } - - async function saveEpaySettings() { - if (!pendingEpaySettings) return; + async function saveEpaySettings(values: EpaySettingsForm) { setEpaySaving(true); try { await responsePayload(await fetch("/api/admin/epay-settings", { method: "PUT", headers: { "content-type": "application/json" }, - body: JSON.stringify({ ...pendingEpaySettings, newKey: pendingEpaySettings.newKey || undefined }), + body: JSON.stringify({ ...values, newKey: values.newKey || undefined }), })); epayForm.setFieldValue("newKey", ""); message.success("Z-Pay(易支付)配置已保存"); await loadEpaySettings(); - setPendingEpaySettings(null); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存易支付配置失败"); } finally { setEpaySaving(false); } @@ -237,7 +231,7 @@ export default function PaymentManagement() { {epaySettings && 来源:{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]} · {epaySettings.complete ? "配置完整" : "配置不完整"} · {epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"} · {epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}} {epayError && void loadEpaySettings()}>重试} />} - form={epayForm} layout="vertical" onFinish={prepareEpaySettings} requiredMark="optional"> + form={epayForm} layout="vertical" onFinish={saveEpaySettings} requiredMark="optional"> @@ -277,14 +271,6 @@ export default function PaymentManagement() { - setPendingEpaySettings(null)} - onConfirm={saveEpaySettings} - /> ); } diff --git a/frontend/src/components/admin/product-management.tsx b/frontend/src/components/admin/product-management.tsx index 7d4f54e8..fe475bea 100644 --- a/frontend/src/components/admin/product-management.tsx +++ b/frontend/src/components/admin/product-management.tsx @@ -23,7 +23,6 @@ import { import { useState } from "react"; import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; -import { ConfirmActionModal } from "./confirm-action-modal"; import { formatAdminDate } from "./resource-table"; const { Text } = Typography; @@ -91,7 +90,6 @@ type ProductForm = Omit; type ProductFilters = { q?: string; status?: string }; const defaultEntitlements: Entitlement[] = [{ @@ -121,8 +119,6 @@ export default function ProductManagement() { const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [publishingId, setPublishingId] = useState(null); - const [publishTarget, setPublishTarget] = useState(null); - const [pendingSave, setPendingSave] = useState(null); const canWrite = Boolean(identity?.permissions.includes("billing.products.write")); const canPublish = Boolean(identity?.permissions.includes("billing.products.publish")); @@ -159,7 +155,7 @@ export default function ProductManagement() { setOpen(true); } - function prepareSave(values: ProductForm) { + async function save(values: ProductForm) { let entitlements: unknown; try { entitlements = JSON.parse(values.entitlementsJson); @@ -167,37 +163,33 @@ export default function ProductManagement() { message.error("权益 JSON 格式不正确"); return; } - setPendingSave({ - action: "save", - id: editing?.id ?? null, - code: values.code.trim(), - name: values.name.trim(), - description: values.description?.trim() ?? "", - productType: values.productType, - billingPeriod: values.billingPeriod, - intervalCount: values.intervalCount, - priceCents: Math.round(values.priceYuan * 100), - currency: values.currency.toUpperCase(), - enabled: values.enabled, - sortOrder: values.sortOrder, - oneTimePerUser: values.oneTimePerUser, - entitlements, - }); - } - - async function save() { - if (!pendingSave) return; setSaving(true); try { await adminRequestJson("/api/admin/products", { method: "POST", - body: JSON.stringify(pendingSave), + body: JSON.stringify({ + action: "save", + id: editing?.id ?? null, + code: values.code.trim(), + name: values.name.trim(), + description: values.description?.trim() ?? "", + productType: values.productType, + billingPeriod: values.billingPeriod, + intervalCount: values.intervalCount, + priceCents: Math.round(values.priceYuan * 100), + currency: values.currency.toUpperCase(), + enabled: values.enabled, + sortOrder: values.sortOrder, + oneTimePerUser: values.oneTimePerUser, + entitlements, + }), }); message.success("商品草稿已保存"); - setPendingSave(null); setOpen(false); form.resetFields(); await table.tableQuery.refetch(); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存商品草稿失败"); } finally { setSaving(false); } @@ -212,7 +204,8 @@ export default function ProductManagement() { }); message.success("商品已发布"); await table.tableQuery.refetch(); - setPublishTarget(null); + } catch (error) { + message.error(error instanceof Error ? error.message : "发布商品失败"); } finally { setPublishingId(null); } @@ -235,7 +228,7 @@ export default function ProductManagement() { fixed: "right", render: (_, item) => {canWrite && } - {canPublish && item.status !== "published" && } + {canPublish && item.status !== "published" && } , }, ]; @@ -256,7 +249,7 @@ export default function ProductManagement() { form.submit()} onCancel={() => setOpen(false)} destroyOnHidden> - form={form} layout="vertical" onFinish={prepareSave} requiredMark="optional"> + form={form} layout="vertical" onFinish={save} requiredMark="optional"> @@ -277,21 +270,5 @@ export default function ProductManagement() { - setPendingSave(null)} - onConfirm={save} - /> - setPublishTarget(null)} - onConfirm={() => publish(publishTarget!)} - /> ; } diff --git a/frontend/src/components/admin/users-resource.tsx b/frontend/src/components/admin/users-resource.tsx index db345713..e471c793 100644 --- a/frontend/src/components/admin/users-resource.tsx +++ b/frontend/src/components/admin/users-resource.tsx @@ -4,8 +4,6 @@ import { useGetIdentity } from "@refinedev/core"; import { App, Button, Space, Typography, type TableColumnsType } from "antd"; import { useState } from "react"; -import { ConfirmActionModal } from "./confirm-action-modal"; - import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; @@ -38,7 +36,6 @@ export default function UsersPage() { const { data: identity } = useGetIdentity(); const [revealed, setRevealed] = useState>({}); const [revealingId, setRevealingId] = useState(null); - const [resetTarget, setResetTarget] = useState(null); const [resetting, setResetting] = useState(false); const canReset = Boolean(identity?.permissions.includes("admin.users.manage_roles")); const canReveal = Boolean(identity?.permissions.includes("admin.customers.birth_data.read")); @@ -58,8 +55,7 @@ export default function UsersPage() { } } - async function resetAccount() { - if (!resetTarget) return; + async function resetAccount(user: UserRecord) { setResetting(true); try { await adminRequestJson<{ data: { credits: number } }>( @@ -67,21 +63,19 @@ export default function UsersPage() { { method: "POST", body: JSON.stringify({ - userId: resetTarget.id, + userId: user.id, confirmation: "RESET", }), }, ); - message.success(`已重置 ${resetTarget.email},登录身份、管理员角色和积分保持不变`); + message.success(`已重置 ${user.email},登录身份、管理员角色和积分保持不变`); setRevealed((current) => { const next = { ...current }; - delete next[resetTarget.id]; + delete next[user.id]; return next; }); - setResetTarget(null); } catch (error) { message.error(error instanceof Error ? error.message : "重置账号失败"); - throw error; } finally { setResetting(false); } @@ -118,22 +112,11 @@ export default function UsersPage() { { title: "注册时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, ...(canReset ? [{ title: "操作", - render: (_: unknown, item: UserRecord) => , }] : []), ]; - return <> - resource="customers" title="用户资料(列表始终脱敏)" columns={columns} /> - setResetTarget(null)} - onConfirm={resetAccount} - /> - ; + return resource="customers" title="用户资料(列表始终脱敏)" columns={columns} />; } diff --git a/frontend/src/lib/admin/providers.ts b/frontend/src/lib/admin/providers.ts index c972c989..b8ff1653 100644 --- a/frontend/src/lib/admin/providers.ts +++ b/frontend/src/lib/admin/providers.ts @@ -187,7 +187,7 @@ const resourcePermissions: Record = { }, roles: { read: "admin.users.read" }, customers: { read: "admin.customers.read" }, - codes: { read: "billing.orders.read", write: "billing.adjustments.write" }, + codes: { read: "admin.access", write: "admin.access" }, "credit-transactions": { read: "billing.orders.read" }, consultations: { read: "billing.orders.read" }, "audit-logs": { read: "audit.read" }, diff --git a/frontend/supabase/migrations/20260816010000_admin_redemption_admin_access.sql b/frontend/supabase/migrations/20260816010000_admin_redemption_admin_access.sql new file mode 100644 index 00000000..abcf6f83 --- /dev/null +++ b/frontend/supabase/migrations/20260816010000_admin_redemption_admin_access.sql @@ -0,0 +1,106 @@ +-- Redemption-code management is an ordinary admin-console capability. +-- Keep actor verification, trusted request IDs and server-owned audit markers, +-- but do not reuse the narrower billing adjustment permission. + +create or replace function public.require_admin_redemption_reason() +returns trigger +language plpgsql +set search_path = '' +as $$ +declare + v_reason text := nullif(btrim(current_setting('app.admin_redemption_reason', true)), ''); +begin + if new.action in ('redemption_code.create','redemption_code.update','redemption_code.revoke') then + if v_reason is null or char_length(v_reason) > 500 then + raise exception 'admin_reason_required' using errcode='22023'; + end if; + new.permission_used := 'admin.access'; + new.reason := v_reason; + end if; + return new; +end; +$$; + +revoke all on function public.require_admin_redemption_reason() + from public, anon, authenticated, service_role; + +create or replace function public.admin_create_redemption_codes( + p_actor_user_id uuid,p_actor_email text,p_actor_role text,p_request_id text,p_codes jsonb,p_reason text +) +returns table(id uuid,code_mask text,credits integer,expires_at timestamptz,note text,created_at timestamptz, + redeemed_by uuid,redeemed_email text,redeemed_at timestamptz,revoked_by uuid,revoked_at timestamptz) +language plpgsql security definer set search_path = '' +as $$ +begin + if not public.admin_has_permission(p_actor_user_id,'admin.access') then + raise exception 'admin_permission_denied' using errcode='42501'; + end if; + if char_length(btrim(coalesce(p_reason,''))) not between 1 and 500 then + raise exception 'admin_reason_required' using errcode='22023'; + end if; + perform set_config('app.admin_redemption_reason',btrim(p_reason),true); + return query select * from public.admin_create_redemption_codes( + p_actor_user_id,p_actor_email,p_actor_role,p_request_id,p_codes + ); +end; +$$; + +create or replace function public.admin_update_redemption_code( + p_actor_user_id uuid,p_actor_email text,p_actor_role text,p_request_id text,p_code_id uuid, + p_set_note boolean,p_note text,p_set_expires_at boolean,p_expires_at timestamptz,p_reason text +) +returns table(id uuid,code_mask text,credits integer,expires_at timestamptz,note text,created_at timestamptz, + redeemed_by uuid,redeemed_email text,redeemed_at timestamptz,revoked_by uuid,revoked_at timestamptz) +language plpgsql security definer set search_path = '' +as $$ +begin + if not public.admin_has_permission(p_actor_user_id,'admin.access') then + raise exception 'admin_permission_denied' using errcode='42501'; + end if; + if char_length(btrim(coalesce(p_reason,''))) not between 1 and 500 then + raise exception 'admin_reason_required' using errcode='22023'; + end if; + perform set_config('app.admin_redemption_reason',btrim(p_reason),true); + return query select * from public.admin_update_redemption_code( + p_actor_user_id,p_actor_email,p_actor_role,p_request_id,p_code_id, + p_set_note,p_note,p_set_expires_at,p_expires_at + ); +end; +$$; + +create or replace function public.admin_revoke_redemption_code( + p_actor_user_id uuid,p_actor_email text,p_actor_role text,p_request_id text,p_code_id uuid,p_reason text +) +returns table(id uuid,code_mask text,credits integer,expires_at timestamptz,note text,created_at timestamptz, + redeemed_by uuid,redeemed_email text,redeemed_at timestamptz,revoked_by uuid,revoked_at timestamptz) +language plpgsql security definer set search_path = '' +as $$ +begin + if not public.admin_has_permission(p_actor_user_id,'admin.access') then + raise exception 'admin_permission_denied' using errcode='42501'; + end if; + if char_length(btrim(coalesce(p_reason,''))) not between 1 and 500 then + raise exception 'admin_reason_required' using errcode='22023'; + end if; + perform set_config('app.admin_redemption_reason',btrim(p_reason),true); + return query select * from public.admin_revoke_redemption_code( + p_actor_user_id,p_actor_email,p_actor_role,p_request_id,p_code_id + ); +end; +$$; + +revoke all on function public.admin_create_redemption_codes(uuid,text,text,text,jsonb,text), + public.admin_update_redemption_code(uuid,text,text,text,uuid,boolean,text,boolean,timestamptz,text), + public.admin_revoke_redemption_code(uuid,text,text,text,uuid,text) + from public, anon, authenticated, service_role; + +do $$ +begin + if exists(select 1 from pg_roles where rolname='admin_runtime') then + grant execute on function public.admin_create_redemption_codes(uuid,text,text,text,jsonb,text), + public.admin_update_redemption_code(uuid,text,text,text,uuid,boolean,text,boolean,timestamptz,text), + public.admin_revoke_redemption_code(uuid,text,text,text,uuid,text) + to admin_runtime; + end if; +end +$$; diff --git a/frontend/tests/admin-contracts.test.ts b/frontend/tests/admin-contracts.test.ts index 68382ef4..09dd8afb 100644 --- a/frontend/tests/admin-contracts.test.ts +++ b/frontend/tests/admin-contracts.test.ts @@ -25,7 +25,6 @@ const compatibilityRoles = readFileSync(new URL("../../deploy/postgres/002-ensur const administratorsRoute = readFileSync(new URL("../src/app/api/admin/administrators/route.ts", import.meta.url), "utf8"); const mfaRoute = readFileSync(new URL("../src/app/api/admin/mfa/route.ts", import.meta.url), "utf8"); const mfaSecurity = readFileSync(new URL("../src/components/admin/mfa-security.tsx", import.meta.url), "utf8"); -const confirmActionModal = readFileSync(new URL("../src/components/admin/confirm-action-modal.tsx", import.meta.url), "utf8"); const customersRoute = readFileSync(new URL("../src/app/api/admin/customers/route.ts", import.meta.url), "utf8"); const adminUser = readFileSync(new URL("../src/lib/supabase/admin.ts", import.meta.url), "utf8"); const codesRoute = readFileSync(new URL("../src/app/api/admin/codes/route.ts", import.meta.url), "utf8"); @@ -50,7 +49,7 @@ test("admin APIs use persisted Better Auth roles with admin-only boundaries", () assert.doesNotMatch(auth, /ADMIN_EMAILS|isAdminEmail/); assert.match(authBoundary, /readAuthProvider\(\)\?\.trim\(\) !== "self-hosted"/); assert.match(authBoundary, /后台服务暂时不可用", 503/); - assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/); + assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"admin\.access",?\s*\)/); assert.equal((codeRoute.match(/requireAdminMutation\(/g) ?? []).length, 2); assert.doesNotMatch(codesRoute, /requireHighRiskAdminMutation/); assert.doesNotMatch(codeRoute, /requireHighRiskAdminMutation/); @@ -237,9 +236,7 @@ test("administrator writes keep permission and origin checks without operation-l assert.doesNotMatch(adminHttp, /requireHighRiskAdminMutation|verifyHighRiskAdminProof/); assert.doesNotMatch(administratorsRoute, /requireHighRiskAdminMutation/); assert.equal(existsSync(new URL("../src/app/api/admin/reauth/route.ts", import.meta.url)), false); - assert.doesNotMatch(confirmActionModal, /操作原因|name="reason"|TextArea/); - assert.match(confirmActionModal, /await onConfirm\(\)/); - assert.doesNotMatch(confirmActionModal, /邮箱验证码|\/api\/admin\/reauth|reauthPermission/); + assert.equal(existsSync(new URL("../src/components/admin/confirm-action-modal.tsx", import.meta.url)), false); assert.match(authFactory, /twoFactor\(/); assert.match(authFactory, /schema: identityModelMapping\.twoFactor/); diff --git a/frontend/tests/admin-mfa.test.ts b/frontend/tests/admin-mfa.test.ts index 0db521cf..77aa0344 100644 --- a/frontend/tests/admin-mfa.test.ts +++ b/frontend/tests/admin-mfa.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import test from "node:test"; import { @@ -75,13 +75,10 @@ test("MFA API keeps native Better Auth enrollment, recovery, rotation, and proof assert.match(migration, /grant select, insert, update, delete on table identity\.two_factors[\s\S]*identity_runtime/); }); -test("business confirmation UI has no per-operation input while account MFA remains separate", () => { - const modal = readFileSync(new URL("../src/components/admin/confirm-action-modal.tsx", import.meta.url), "utf8"); +test("business operation confirmation UI is removed while account MFA remains separate", () => { const http = readFileSync(new URL("../src/lib/admin/http.ts", import.meta.url), "utf8"); - assert.doesNotMatch(modal, /操作原因|name="reason"|TextArea/); - assert.match(modal, /await onConfirm\(\)/); - assert.doesNotMatch(modal, /邮箱验证码|\/api\/admin\/reauth|reauthPermission/); + assert.equal(existsSync(new URL("../src/components/admin/confirm-action-modal.tsx", import.meta.url)), false); assert.match(http, /requireAdminMutation/); assert.doesNotMatch(http, /requireHighRiskAdminMutation|verifyHighRiskAdminProof/); }); diff --git a/frontend/tests/admin-operation-reauth-removal.test.ts b/frontend/tests/admin-operation-reauth-removal.test.ts index 80fb8b7f..dc1a9e24 100644 --- a/frontend/tests/admin-operation-reauth-removal.test.ts +++ b/frontend/tests/admin-operation-reauth-removal.test.ts @@ -43,20 +43,19 @@ test("admin mutations retain session, permission, trusted-origin, server audit, const codesRoute = source("src/app/api/admin/codes/route.ts"); const codesHelper = source("src/lib/admin/codes.ts"); const codesUi = source("src/components/admin/codes-resource.tsx"); - const modal = source("src/components/admin/confirm-action-modal.tsx"); + assert.equal(existsSync(new URL("src/components/admin/confirm-action-modal.tsx", root)), false); assert.match(http, /requirePermission\(permission, request\.headers\)/); assert.match(http, /isTrustedAdminMutationRequest\(request, process\.env\.ADMIN_USER_ORIGIN\)/); - assert.match(codesRoute, /requireAdminMutation\([\s\S]*"billing\.adjustments\.write"/); + assert.match(codesRoute, /requireAdminMutation\([\s\S]*"admin\.access"/); assert.doesNotMatch(codesRoute, /reason:\s*z\.string/); assert.match(codesRoute, /p_reason:\s*"admin_console_create_redemption_codes"/); assert.match(codesHelper, /JSON\.stringify\(input\.p_codes\)/); assert.match(codesRoute, /requestId\(request\)/); assert.match(codesRoute, /admin_create_redemption_codes/); - assert.match(codesUi, /permissions\.includes\("billing\.adjustments\.write"\)/); - assert.match(codesUi, /okText="确认生成"/); - assert.doesNotMatch(modal, /操作原因|TextArea|name="reason"/); - assert.match(modal, /await onConfirm\(\)/); + assert.match(codesUi, /permissions\.includes\("admin\.access"\)/); + assert.match(codesUi, /onFinish=\{\(values\) => void submitCreate\(values\)\}/); + assert.doesNotMatch(codesUi, /ConfirmActionModal|pendingCreate|pendingEdit|revokeRecord/); }); test("account-level TOTP MFA and customer login email OTP remain available", () => { diff --git a/frontend/tests/admin-ui-permission-contract.test.ts b/frontend/tests/admin-ui-permission-contract.test.ts index 810fe096..58d0ce0e 100644 --- a/frontend/tests/admin-ui-permission-contract.test.ts +++ b/frontend/tests/admin-ui-permission-contract.test.ts @@ -33,13 +33,13 @@ const mutationMappings = [ name: "兑换码创建", ui: "src/components/admin/codes-resource.tsx", api: "src/app/api/admin/codes/route.ts", - permission: "billing.adjustments.write", + permission: "admin.access", }, { name: "兑换码编辑与撤销", ui: "src/components/admin/codes-resource.tsx", api: "src/app/api/admin/codes/[id]/route.ts", - permission: "billing.adjustments.write", + permission: "admin.access", }, { name: "功能开关发布", @@ -66,7 +66,7 @@ test("admin mutation UI permissions match ordinary API guards without operation await t.test(mapping.name, () => { const ui = source(mapping.ui); const api = source(mapping.api); - assert.match(ui, /ConfirmActionModal/); + assert.doesNotMatch(ui, /ConfirmActionModal|Popconfirm|Modal\.confirm/); assert.doesNotMatch(ui, /reauthPermission|邮箱验证码|\/api\/admin\/reauth/); assert.match(api, new RegExp(String.raw`requireAdminMutation\(\s*request,\s*(?:permission|["']${mapping.permission.replaceAll(".", "\\.")}["']),?\s*\)`)); assert.doesNotMatch(api, /requireHighRiskAdminMutation/); @@ -75,6 +75,17 @@ test("admin mutation UI permissions match ordinary API guards without operation } }); +test("redemption-code list and mutations are available to every authenticated admin role", () => { + const providers = source("src/lib/admin/providers.ts"); + const route = source("src/app/api/admin/codes/route.ts"); + + assert.match( + providers, + /codes:\s*\{\s*read:\s*"admin\.access",\s*write:\s*"admin\.access"\s*\}/, + ); + assert.match(route, /await requirePermission\("admin\.access"\)/); +}); + test("product management localizes product, billing and entitlement enums", () => { const ui = source("src/components/admin/product-management.tsx"); @@ -115,18 +126,16 @@ test("model management uses ordinary admin mutation guards without reauth", () = assert.doesNotMatch(ui, /验证并(?:保存|获取)/); }); -test("admin request failures are real Error instances and confirmation modal keeps failures visible", () => { +test("admin request failures are real Error instances and operation confirmation UI is absent", () => { const providers = source("src/lib/admin/providers.ts"); - const modal = source("src/components/admin/confirm-action-modal.tsx"); assert.match( providers, /Object\.assign\(new Error\(message\),\s*\{\s*statusCode: response\.status,/, ); assert.doesNotMatch(providers, /throw \{ message, statusCode/); - assert.match(modal, /catch \(error\)[\s\S]*setActionError\(error instanceof Error \? error\.message/); - assert.doesNotMatch(modal, /操作原因|name="reason"|TextArea/); - assert.match(modal, /await onConfirm\(\)/); - assert.doesNotMatch(modal, /reauth|邮箱验证码|发送验证码/); + for (const path of mutationMappings.map((mapping) => mapping.ui)) { + assert.doesNotMatch(source(path), /ConfirmActionModal|Popconfirm|Modal\.confirm/); + } }); test("customer list is always masked and a single explicit reveal is audited each time", () => { @@ -154,7 +163,8 @@ test("administrator UI supports all six roles and exposes last-owner protection" for (const role of ["owner", "model_admin", "billing_admin", "operations", "support", "auditor"]) { assert.match(ui, new RegExp(`["']${role}["']`)); } - assert.match(ui, /method: pendingAction\.action === "assign" \? "POST" : "DELETE"/); + assert.match(ui, /method: action\.action === "assign" \? "POST" : "DELETE"/); + assert.doesNotMatch(ui, /pendingAction|ConfirmActionModal/); const mutationSection = route.slice(route.indexOf("async function mutate")); assert.match(mutationSection, /last_owner_protected/); assert.match(mutationSection, /不能撤销最后一位 Owner,请先分配另一位 Owner/); diff --git a/frontend/tests/admin-users-contract.test.ts b/frontend/tests/admin-users-contract.test.ts index 360eedcd..809c8cf7 100644 --- a/frontend/tests/admin-users-contract.test.ts +++ b/frontend/tests/admin-users-contract.test.ts @@ -22,7 +22,7 @@ test("admin surfaces await database-backed administrator checks", () => { assert.match(adminSource, /export async function isAdminUser/); assert.match(adminSource, /admin_has_permission\(\$1, 'admin\.access'\)/); assert.match(sessionSource, /await requirePermission\("admin\.access"\)/); - assert.match(codesSource, /await requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/); + assert.match(codesSource, /await requireAdminMutation\(\s*request,\s*"admin\.access",?\s*\)/); assert.doesNotMatch(codesSource, /requireHighRiskAdminMutation/); assert.match(accountSource, /const isAdmin = await isAdminUser\(user\)/); assert.match(accountSource, /isAdmin,/); diff --git a/frontend/tests/high-risk-billing-routes-contract.test.ts b/frontend/tests/high-risk-billing-routes-contract.test.ts index e11a7bde..54b1606c 100644 --- a/frontend/tests/high-risk-billing-routes-contract.test.ts +++ b/frontend/tests/high-risk-billing-routes-contract.test.ts @@ -20,6 +20,8 @@ const codeRoute = source("src/app/api/admin/codes/[id]/route.ts"); const codesHelper = source("src/lib/admin/codes.ts"); const billingOperationsUi = source("src/components/admin/billing-operations-resources.tsx"); const codesUi = source("src/components/admin/codes-resource.tsx"); +const redemptionAccessMigration = source("supabase/migrations/20260816010000_admin_redemption_admin_access.sql"); +const baseRedemptionMigration = source("supabase/migrations/20260805010000_reconcile_admin_redemption_audit.sql"); test("billing.subscriptions only gates new trial and subscription purchases", () => { assert.match(packagesRoute, /loadRuntimeFeatureFlags\(\["billing\.subscriptions"\]\)/); @@ -94,7 +96,8 @@ test("order adjustments require permission, version, server audit reason, reques }); test("redemption-code writes generate server audit reasons and encode JSONB for self-hosted pg", () => { - assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/); + assert.match(codesRoute, /await requirePermission\("admin\.access"\)/); + assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"admin\.access",?\s*\)/); assert.equal((codeRoute.match(/requireAdminMutation\(/g) ?? []).length, 2); assert.doesNotMatch(codeRoute, /requireHighRiskAdminMutation/); assert.doesNotMatch(codesRoute, /reason:\s*z\.string/); @@ -108,9 +111,15 @@ test("redemption-code writes generate server audit reasons and encode JSONB for assert.match(codesHelper, /public\.admin_update_redemption_code\(/); assert.match(codesHelper, /public\.admin_revoke_redemption_code\(/); assert.doesNotMatch(codesHelper, /createAdminSupabaseClient|\.rpc\(|\$\{functionName\}/); - assert.match(codesUi, /permissions\.includes\("billing\.adjustments\.write"\)/); + assert.match(codesUi, /permissions\.includes\("admin\.access"\)/); assert.doesNotMatch(codesUi, /reauthPermission|邮箱验证码|\/api\/admin\/reauth/); - assert.match(codesUi, /open=\{Boolean\(pendingCreate\)\}[\s\S]*onConfirm=\{submitCreate\}/); - assert.match(codesUi, /open=\{Boolean\(pendingEdit\)\}[\s\S]*onConfirm=\{submitEdit\}/); - assert.match(codesUi, / revoke\(revokeRecord!\)\}/); + assert.match(codesUi, /onFinish=\{\(values\) => void submitCreate\(values\)\}/); + assert.match(codesUi, /onFinish=\{\(values\) => void submitEdit\(values\)\}/); + assert.match(codesUi, /onClick=\{\(\) => void revoke\(record\)\}/); + assert.doesNotMatch(codesUi, /ConfirmActionModal|pendingCreate|pendingEdit|revokeRecord/); + assert.equal((redemptionAccessMigration.match(/admin_has_permission\(p_actor_user_id,'admin\.access'\)/g) ?? []).length, 3); + assert.match(redemptionAccessMigration, /new\.permission_used := 'admin\.access'/); + assert.match(baseRedemptionMigration, /admin_verified_actor_email/); + assert.match(redemptionAccessMigration, /grant execute on function public\.admin_create_redemption_codes/); + assert.doesNotMatch(redemptionAccessMigration, /billing\.adjustments\.write/); });