diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 42259233..795c5e69 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3466,19 +3466,33 @@ - 相关记录:BUG-177、BUG-198、BUG-199 - 修复版本:本地未提交候选 -## BUG-207 | 管理端业务操作重复要求邮箱验证码复核 +## BUG-207 | 管理端业务操作重复要求邮箱验证码与手工原因 -- 状态:resolved(本地修复,未提交、未发布) +- 状态: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 参数。兑换码批量生成改为“确认生成”。 +- 修复:所有管理业务写入统一改用 `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 不受影响。 -- 验证:新增全局合同扫描,锁定管理路由、组件和公共库中不得恢复 operation-level reauth;兑换码、角色、账务、商品、功能开关、易支付、MFA 与普通登录 OTP 聚焦测试 75/75 通过,`tsc --noEmit`、目标 ESLint 与 `git diff --check` 通过。 -- 防复发:新增管理业务时只能在登录、权限、Origin、原因和审计边界内扩展;不得把邮箱 OTP 放回公共业务确认弹窗或新建操作级 reauth API。账户级 MFA 与普通用户身份验证必须保持独立。 +- 验证:管理端权限、业务确认、兑换码/账务合同、账户级 MFA 等 5 个聚焦测试文件共 47/47 通过;`tsc --noEmit`、17 个目标源文件 ESLint 与 `git diff --check` 通过。首轮邮箱复核移除提交已发布到 staging,但“无需手工原因”的补充修复尚未发布。 +- 防复发:新增管理业务时只能在登录、权限、Origin、服务端审计标识和数据库审计边界内扩展;不得把邮箱 OTP 或手工原因输入放回公共业务确认弹窗。账户级 MFA 与普通用户身份验证必须保持独立。 - 相关记录:BUG-155、BUG-156 +- 修复版本:本地未提交候选(首轮邮箱复核移除:`4f6cf5782d3871a28e531a4dff9bcc6a2633ce09`) + +## BUG-208 | self-hosted 管理端生成兑换码返回通用 500 + +- 状态: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 测试覆盖参数编码。 +- 相关记录:BUG-155、BUG-207 - 修复版本:本地未提交候选 ## BUG-208 | 生时校正 opening 首步依赖模型主动加载 Skill,失败时只返回 run.started → run.failed diff --git a/frontend/src/app/api/admin/administrators/route.ts b/frontend/src/app/api/admin/administrators/route.ts index 94d8167b..46e89335 100644 --- a/frontend/src/app/api/admin/administrators/route.ts +++ b/frontend/src/app/api/admin/administrators/route.ts @@ -27,7 +27,6 @@ const mutationSchema = z.object({ email: z.string().trim().email().optional(), userId: z.string().uuid().optional(), roleCode: z.enum(["owner", "model_admin", "billing_admin", "operations", "support", "auditor"]), - reason: z.string().trim().min(1).max(500), }).refine((value) => Boolean(value.email || value.userId), { message: "email_or_user_id_required", }); @@ -90,7 +89,7 @@ async function mutate(request: Request, assign: boolean) { if (!targetUserId) return NextResponse.json({ error: "用户不存在" }, { status: 404 }); const rows = await queryAdminRows<{ user_id: string; role_code: string; assigned: boolean }>( "select * from public.admin_manage_role($1, $2, $3, $4, $5, $6)", - [session.user.id, targetUserId, parsed.data.roleCode, assign, parsed.data.reason, requestId(request)], + [session.user.id, targetUserId, parsed.data.roleCode, assign, "admin_console_manage_role", requestId(request)], ); return NextResponse.json({ data: rows[0] }); } catch (error) { diff --git a/frontend/src/app/api/admin/codes/[id]/route.ts b/frontend/src/app/api/admin/codes/[id]/route.ts index 12d896b2..7d63f23a 100644 --- a/frontend/src/app/api/admin/codes/[id]/route.ts +++ b/frontend/src/app/api/admin/codes/[id]/route.ts @@ -12,14 +12,10 @@ import { export const runtime = "nodejs"; const paramsSchema = z.object({ id: z.string().uuid() }); -const revokeCodeSchema = z.object({ - reason: z.string().trim().min(1).max(500), -}); const updateCodeSchema = z .object({ note: z.string().trim().max(500).nullable().optional(), expiresAt: z.string().datetime({ offset: true }).nullable().optional(), - reason: z.string().trim().min(1).max(500), }) .refine((value) => "note" in value || "expiresAt" in value, { message: "至少提供一个可修改字段", @@ -52,7 +48,7 @@ export async function PATCH( p_note: body.note ?? null, p_set_expires_at: "expiresAt" in body, p_expires_at: body.expiresAt ?? null, - p_reason: body.reason, + p_reason: "admin_console_update_redemption_code", }, ); return NextResponse.json({ data: rows[0] }); @@ -71,15 +67,12 @@ export async function DELETE( "billing.adjustments.write", ); const parsed = paramsSchema.safeParse(await context.params); - const parsedBody = revokeCodeSchema.safeParse( - await request.json().catch(() => null), - ); - if (!parsed.success || !parsedBody.success) return invalidQueryResponse(); + if (!parsed.success) return invalidQueryResponse(); const rows = await runCodeRpc( "admin_revoke_redemption_code", session, requestId(request), - { p_code_id: parsed.data.id, p_reason: parsedBody.data.reason }, + { p_code_id: parsed.data.id, p_reason: "admin_console_revoke_redemption_code" }, ); return NextResponse.json({ data: rows[0] }); } catch (error) { diff --git a/frontend/src/app/api/admin/codes/route.ts b/frontend/src/app/api/admin/codes/route.ts index 071dca80..3d5d4969 100644 --- a/frontend/src/app/api/admin/codes/route.ts +++ b/frontend/src/app/api/admin/codes/route.ts @@ -28,7 +28,6 @@ const createCodesSchema = z.object({ count: z.number().int().min(1).max(100), expiresAt: z.string().datetime({ offset: true }).nullable().optional(), note: z.string().trim().max(500).nullable().optional(), - reason: z.string().trim().min(1).max(500), }); type CodeRow = { @@ -147,7 +146,7 @@ export async function POST(request: Request) { "admin_create_redemption_codes", session, operationRequestId, - { p_codes: records, p_reason: parsed.data.reason }, + { p_codes: records, p_reason: "admin_console_create_redemption_codes" }, ); const byMask = new Map( stored.map((record) => [record.mask, record]), diff --git a/frontend/src/app/api/admin/customers/reset/route.ts b/frontend/src/app/api/admin/customers/reset/route.ts index 23af1a06..3846a994 100644 --- a/frontend/src/app/api/admin/customers/reset/route.ts +++ b/frontend/src/app/api/admin/customers/reset/route.ts @@ -14,7 +14,6 @@ export const runtime = "nodejs"; const resetSchema = z.object({ userId: z.string().uuid(), confirmation: z.literal("RESET"), - reason: z.string().trim().min(1).max(500), }); type ResetRow = { @@ -40,7 +39,7 @@ export async function POST(request: Request) { [ session.user.id, parsed.data.userId, - parsed.data.reason, + "admin_console_reset_customer", requestId(request), ], ); diff --git a/frontend/src/app/api/admin/feature-flags/route.ts b/frontend/src/app/api/admin/feature-flags/route.ts index 57e38ae4..b73ab10b 100644 --- a/frontend/src/app/api/admin/feature-flags/route.ts +++ b/frontend/src/app/api/admin/feature-flags/route.ts @@ -5,9 +5,9 @@ import { pageOffset, queryAdminRows } from "@/lib/admin/database"; import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireAdminMutation } from "@/lib/admin/http"; export const runtime="nodejs"; const schema=z.discriminatedUnion("action",[ - z.object({action:z.literal("save"),id:z.string().uuid().nullable().optional(),flagKey:z.string().regex(/^[a-z][a-z0-9._-]{1,99}$/),enabled:z.boolean(),rolloutPercentage:z.number().int().min(0).max(100),config:z.record(z.string(),z.unknown()).default({}),expectedVersion:z.number().int().positive().nullable().optional(),reason:z.string().trim().min(1).max(500)}).strict(), - z.object({action:z.literal("publish"),id:z.string().uuid(),expectedVersion:z.number().int().positive(),reason:z.string().trim().min(1).max(500)}).strict(), + z.object({action:z.literal("save"),id:z.string().uuid().nullable().optional(),flagKey:z.string().regex(/^[a-z][a-z0-9._-]{1,99}$/),enabled:z.boolean(),rolloutPercentage:z.number().int().min(0).max(100),config:z.record(z.string(),z.unknown()).default({}),expectedVersion:z.number().int().positive().nullable().optional()}).strict(), + z.object({action:z.literal("publish"),id:z.string().uuid(),expectedVersion:z.number().int().positive()}).strict(), ]); type Row={id:string;flag_key:string;version:number;enabled:boolean;rollout_percentage:number;config:Record;status:string;created_at:Date;published_at:Date|null;total_count:string}; export async function GET(request:Request){try{await requirePermission("admin.access");const p=parseListQuery(request);if(!p.success)return invalidQueryResponse(p.error.flatten());const q=p.data.q?`%${p.data.q}%`:null;const rows=await queryAdminRows(`select f.*,count(*) over()::text total_count from public.feature_flags f where ($1::text is null or f.flag_key ilike $1) and ($2::text is null or f.status=$2) order by f.created_at desc limit $3 offset $4`,[q,p.data.status??null,p.data.pageSize,pageOffset(p.data.page,p.data.pageSize)]);return NextResponse.json({data:rows.map(r=>({id:r.id,flagKey:r.flag_key,version:r.version,enabled:r.enabled,rolloutPercentage:r.rollout_percentage,config:r.config,status:r.status,createdAt:r.created_at.toISOString(),publishedAt:r.published_at?.toISOString()??null})),total:Number(rows[0]?.total_count??0)});}catch(e){return adminErrorResponse(e)}} -export async function POST(request:Request){try{const b=schema.safeParse(await request.json().catch(()=>null));if(!b.success)return invalidQueryResponse(b.error.flatten());const session=await requireAdminMutation(request,"ops.flags.write");const rid=requestId(request);if(b.data.action==="publish"){const rows=await queryAdminRows<{id:string}>("select public.admin_publish_feature_flag($1,$2,$3,$4,$5) id",[session.user.id,b.data.id,b.data.expectedVersion,b.data.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}const v=b.data;const rows=await queryAdminRows<{id:string}>("select public.admin_save_feature_flag($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9) id",[session.user.id,v.id??null,v.flagKey,v.enabled,v.rolloutPercentage,JSON.stringify(v.config),v.expectedVersion??null,v.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}catch(e){return adminErrorResponse(e)}} +export async function POST(request:Request){try{const b=schema.safeParse(await request.json().catch(()=>null));if(!b.success)return invalidQueryResponse(b.error.flatten());const session=await requireAdminMutation(request,"ops.flags.write");const rid=requestId(request);if(b.data.action==="publish"){const rows=await queryAdminRows<{id:string}>("select public.admin_publish_feature_flag($1,$2,$3,$4,$5) id",[session.user.id,b.data.id,b.data.expectedVersion,"admin_console_publish_feature_flag",rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}const v=b.data;const rows=await queryAdminRows<{id:string}>("select public.admin_save_feature_flag($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9) id",[session.user.id,v.id??null,v.flagKey,v.enabled,v.rolloutPercentage,JSON.stringify(v.config),v.expectedVersion??null,"admin_console_save_feature_flag",rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}catch(e){return adminErrorResponse(e)}} diff --git a/frontend/src/app/api/admin/orders/route.ts b/frontend/src/app/api/admin/orders/route.ts index ee8c0a2c..3f7a29cc 100644 --- a/frontend/src/app/api/admin/orders/route.ts +++ b/frontend/src/app/api/admin/orders/route.ts @@ -17,7 +17,6 @@ const adjustmentSchema = z.object({ id: z.string().uuid(), action: z.enum(["retry_grant", "compensate", "record_refund"]), expectedVersion: z.number().int().min(0), - reason: z.string().trim().min(1).max(500), }); type Row = { @@ -125,7 +124,7 @@ export async function POST(request: Request) { parsed.data.id, parsed.data.action, parsed.data.expectedVersion, - parsed.data.reason, + "admin_console_adjust_order", requestId(request), ], ); diff --git a/frontend/src/app/api/admin/products/route.ts b/frontend/src/app/api/admin/products/route.ts index 4e2748f7..c5810594 100644 --- a/frontend/src/app/api/admin/products/route.ts +++ b/frontend/src/app/api/admin/products/route.ts @@ -32,12 +32,10 @@ const saveSchema = z.object({ sortOrder: z.number().int(), oneTimePerUser: z.boolean(), entitlements: z.array(entitlementSchema).min(1), - reason: z.string().trim().min(1).max(500), }).strict(); const publishSchema = z.object({ action: z.literal("publish"), id: z.string().uuid(), - reason: z.string().trim().min(1).max(500), }).strict(); const mutationSchema = z.discriminatedUnion("action", [saveSchema, publishSchema]); @@ -91,7 +89,7 @@ export async function POST(request: Request) { const session = await requireAdminMutation(request, permission); const rid = requestId(request); if (body.data.action === "publish") { - const rows = await queryAdminRows<{ id: string }>("select public.admin_publish_product($1,$2,$3,$4) id", [session.user.id, body.data.id, body.data.reason, rid]); + const rows = await queryAdminRows<{ id: string }>("select public.admin_publish_product($1,$2,$3,$4) id", [session.user.id, body.data.id, "admin_console_publish_product", rid]); return NextResponse.json({ data: { id: rows[0]!.id, requestId: rid } }); } const value = body.data; @@ -99,7 +97,7 @@ export async function POST(request: Request) { select public.admin_save_product_draft($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16) id `, [session.user.id, value.id ?? null, value.code, value.name, value.description, value.productType, value.billingPeriod, value.intervalCount, value.priceCents, value.currency, value.enabled, value.sortOrder, - value.oneTimePerUser, JSON.stringify(value.entitlements), value.reason, rid]); + value.oneTimePerUser, JSON.stringify(value.entitlements), "admin_console_save_product", rid]); return NextResponse.json({ data: { id: rows[0]!.id, requestId: rid } }); } catch (error) { return adminErrorResponse(error); } } diff --git a/frontend/src/app/api/admin/subscriptions/route.ts b/frontend/src/app/api/admin/subscriptions/route.ts index b9d755ad..9ff19056 100644 --- a/frontend/src/app/api/admin/subscriptions/route.ts +++ b/frontend/src/app/api/admin/subscriptions/route.ts @@ -10,7 +10,6 @@ const mutationSchema = z.object({ action: z.enum(["extend", "revoke"]), days: z.number().int().min(1).max(3660).optional(), expectedEndsAt: z.string().datetime(), - reason: z.string().trim().min(1).max(500), }).strict(); type Row = { id:string; user_id:string; email:string|null; product_code:string; product_version:number; status:string; starts_at:Date; ends_at:Date; created_at:Date; total_count:string }; export async function GET(request: Request) { @@ -29,7 +28,7 @@ export async function POST(request: Request){ try{ const body=mutationSchema.safeParse(await request.json().catch(()=>null)); if(!body.success)return invalidQueryResponse(body.error.flatten()); const session=await requireAdminMutation(request,"billing.adjustments.write"); const rid=requestId(request); - const rows=await queryAdminRows<{id:string}>("select public.admin_adjust_subscription($1,$2,$3,$4,$5,$6,$7) id",[session.user.id,body.data.id,body.data.action,body.data.days??null,body.data.expectedEndsAt,body.data.reason,rid]); + const rows=await queryAdminRows<{id:string}>("select public.admin_adjust_subscription($1,$2,$3,$4,$5,$6,$7) id",[session.user.id,body.data.id,body.data.action,body.data.days??null,body.data.expectedEndsAt,"admin_console_adjust_subscription",rid]); return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}}); }catch(error){return adminErrorResponse(error);} } diff --git a/frontend/src/components/admin/administrators-resource.tsx b/frontend/src/components/admin/administrators-resource.tsx index 8ecb330e..a9d43e27 100644 --- a/frontend/src/components/admin/administrators-resource.tsx +++ b/frontend/src/components/admin/administrators-resource.tsx @@ -7,7 +7,7 @@ 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 { ReasonActionModal } from "./reason-action-modal"; +import { ConfirmActionModal } from "./confirm-action-modal"; import { formatAdminDate } from "./resource-table"; const roleOptions = [ @@ -70,7 +70,7 @@ export default function AdministratorsResource() { }); } - async function submitRoleAction(reason: string) { + async function submitRoleAction() { if (!pendingAction) return; setSaving(true); try { @@ -80,7 +80,6 @@ export default function AdministratorsResource() { userId: pendingAction.userId, email: pendingAction.email, roleCode: pendingAction.roleCode, - reason, }), }); message.success(pendingAction.action === "assign" ? "管理员角色已分配" : "管理员角色已撤销"); @@ -100,7 +99,7 @@ export default function AdministratorsResource() { extra={canManage ? : 只读权限} > - 六类系统角色可在此分配和撤销。每次变更都需要填写操作原因并写入审计日志;最后一位 Owner 受服务端保护,不能被撤销。 + 六类系统角色可在此分配和撤销。系统会自动记录管理员、动作和请求 ID;最后一位 Owner 受服务端保护,不能被撤销。
@@ -133,7 +132,7 @@ export default function AdministratorsResource() { assignmentForm.submit()} onCancel={() => setAssignmentOpen(false)} @@ -151,14 +150,14 @@ export default function AdministratorsResource() {
- setPendingAction(null)} - onSubmit={submitRoleAction} + onConfirm={submitRoleAction} /> ); diff --git a/frontend/src/components/admin/billing-operations-resources.tsx b/frontend/src/components/admin/billing-operations-resources.tsx index 6370dd8c..010458e8 100644 --- a/frontend/src/components/admin/billing-operations-resources.tsx +++ b/frontend/src/components/admin/billing-operations-resources.tsx @@ -18,7 +18,7 @@ import { import { useState } from "react"; import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; -import { ReasonActionModal } from "./reason-action-modal"; +import { ConfirmActionModal } from "./confirm-action-modal"; import { formatAdminDate, ResourceTable } from "./resource-table"; const { Text } = Typography; @@ -58,7 +58,6 @@ export function SubscriptionsResource() { item: Subscription, action: "extend" | "revoke", days: number | undefined, - reason: string, ) { await adminRequestJson("/api/admin/subscriptions", { method: "POST", @@ -67,7 +66,6 @@ export function SubscriptionsResource() { action, days, expectedEndsAt: item.endsAt, - reason, }), }); await table.tableQuery.refetch(); @@ -77,11 +75,11 @@ export function SubscriptionsResource() { setExtendDays(values.days); } - async function extend(reason: string) { + async function extend() { if (!selected || extendDays === null) return; setSaving(true); try { - await adjust(selected, "extend", extendDays, reason); + await adjust(selected, "extend", extendDays); message.success("订阅已延长"); setExtendDays(null); setSelected(null); @@ -91,10 +89,10 @@ export function SubscriptionsResource() { } } - async function revoke(item: Subscription, reason: string) { + async function revoke(item: Subscription) { setRevokingId(item.id); try { - await adjust(item, "revoke", undefined, reason); + await adjust(item, "revoke", undefined); message.success("订阅已撤销"); setRevokeTarget(null); } finally { @@ -182,7 +180,7 @@ export function SubscriptionsResource() { form.submit()} onCancel={() => setSelected(null)} @@ -203,22 +201,22 @@ export function SubscriptionsResource() { - setExtendDays(null)} - onSubmit={extend} + onConfirm={extend} /> - setRevokeTarget(null)} - onSubmit={(reason) => revoke(revokeTarget!, reason)} + onConfirm={() => revoke(revokeTarget!)} /> ); @@ -258,7 +256,7 @@ export function OrdersResource() { identity?.permissions.includes("billing.adjustments.write"), ); - async function adjust(reason: string) { + async function adjust() { if (!target) return; setSaving(true); try { @@ -271,7 +269,6 @@ export function OrdersResource() { id: target.order.id, action: target.action, expectedVersion: target.order.adjustmentVersion, - reason, }), }); await invalidate({ resource: "orders", invalidates: ["list"] }); @@ -422,14 +419,14 @@ export function OrdersResource() { "recorded", ].map((value) => ({ value, label: value }))} /> - setTarget(null)} - onSubmit={adjust} + onConfirm={adjust} /> ); diff --git a/frontend/src/components/admin/codes-resource.tsx b/frontend/src/components/admin/codes-resource.tsx index 62d90de4..f5737897 100644 --- a/frontend/src/components/admin/codes-resource.tsx +++ b/frontend/src/components/admin/codes-resource.tsx @@ -22,7 +22,7 @@ import { import dayjs from "dayjs"; import { useState } from "react"; -import { ReasonActionModal } from "@/components/admin/reason-action-modal"; +import { ConfirmActionModal } from "@/components/admin/confirm-action-modal"; import { formatAdminDate, ResourceTable, @@ -84,7 +84,7 @@ export default function CodesPage() { identity?.permissions.includes("billing.adjustments.write"), ); - async function submitCreate(reason: string) { + async function submitCreate() { if (!pendingCreate) return; const result = await createCodes({ resource: "codes", @@ -93,7 +93,6 @@ export default function CodesPage() { count: pendingCreate.count, expiresAt: pendingCreate.expiresAt?.toISOString() ?? null, note: pendingCreate.note?.trim() || null, - reason, }, successNotification: false, }); @@ -103,7 +102,7 @@ export default function CodesPage() { createForm.resetFields(); } - async function submitEdit(reason: string) { + async function submitEdit() { if (!editRecord || !pendingEdit) return; await updateCode({ resource: "codes", @@ -111,20 +110,18 @@ export default function CodesPage() { values: { note: pendingEdit.note?.trim() || null, expiresAt: pendingEdit.expiresAt?.toISOString() ?? null, - reason, }, }); setPendingEdit(null); setEditRecord(null); } - async function revoke(record: CodeRecord, reason: string) { + async function revoke(record: CodeRecord) { setRevoking(true); try { await adminRequestJson(`/api/admin/codes/${record.id}`, { method: "DELETE", headers: { "x-request-id": crypto.randomUUID() }, - body: JSON.stringify({ reason }), }); await invalidate({ resource: "codes", invalidates: ["list"] }); message.success("兑换码已撤销"); @@ -316,30 +313,30 @@ export default function CodesPage() { - setPendingCreate(null)} - onSubmit={submitCreate} + onConfirm={submitCreate} /> - setPendingEdit(null)} - onSubmit={submitEdit} + onConfirm={submitEdit} /> - setRevokeRecord(null)} - onSubmit={(reason) => revoke(revokeRecord!, reason)} + onConfirm={() => revoke(revokeRecord!)} /> ); diff --git a/frontend/src/components/admin/confirm-action-modal.tsx b/frontend/src/components/admin/confirm-action-modal.tsx new file mode 100644 index 00000000..caaa8d17 --- /dev/null +++ b/frontend/src/components/admin/confirm-action-modal.tsx @@ -0,0 +1,59 @@ +"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 37139034..8279b58d 100644 --- a/frontend/src/components/admin/feature-flags-management.tsx +++ b/frontend/src/components/admin/feature-flags-management.tsx @@ -8,7 +8,7 @@ import { App, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Ta import { useState } from "react"; import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; -import { ReasonActionModal } from "./reason-action-modal"; +import { ConfirmActionModal } from "./confirm-action-modal"; import { formatAdminDate } from "./resource-table"; const { Text } = Typography; @@ -32,7 +32,6 @@ type FlagForm = { enabled: boolean; rolloutPercentage: number; configJson: string; - reason: string; }; export default function FeatureFlagsManagement() { @@ -62,13 +61,11 @@ export default function FeatureFlagsManagement() { enabled: item.enabled, rolloutPercentage: item.rolloutPercentage, configJson: JSON.stringify(item.config, null, 2), - reason: "", } : { flagKey: "", enabled: false, rolloutPercentage: 0, configJson: "{}", - reason: "", }); setOpen(true); } @@ -92,7 +89,6 @@ export default function FeatureFlagsManagement() { rolloutPercentage: values.rolloutPercentage, config, expectedVersion: editing?.version ?? null, - reason: values.reason.trim(), }), }); message.success("功能开关草稿已保存"); @@ -105,12 +101,12 @@ export default function FeatureFlagsManagement() { } } - async function publish(item: FeatureFlag, reason: string) { + async function publish(item: FeatureFlag) { setPublishingId(item.id); try { await adminRequestJson("/api/admin/feature-flags", { method: "POST", - body: JSON.stringify({ action: "publish", id: item.id, expectedVersion: item.version, reason }), + body: JSON.stringify({ action: "publish", id: item.id, expectedVersion: item.version }), }); message.success("功能开关已发布"); await table.tableQuery.refetch(); @@ -154,16 +150,15 @@ export default function FeatureFlagsManagement() { - - setPublishTarget(null)} - onSubmit={(reason) => publish(publishTarget!, reason)} + onConfirm={() => publish(publishTarget!)} /> ; } diff --git a/frontend/src/components/admin/payment-management.tsx b/frontend/src/components/admin/payment-management.tsx index b801c0c9..4dd07f32 100644 --- a/frontend/src/components/admin/payment-management.tsx +++ b/frontend/src/components/admin/payment-management.tsx @@ -25,7 +25,7 @@ import { import type { Dayjs } from "dayjs"; import { useCallback, useEffect, useState } from "react"; -import { ReasonActionModal } from "@/components/admin/reason-action-modal"; +import { ConfirmActionModal } from "@/components/admin/confirm-action-modal"; import type { AdminIdentity } from "@/lib/admin/providers"; const { Text } = Typography; @@ -277,13 +277,13 @@ export default function PaymentManagement() { - setPendingEpaySettings(null)} - onSubmit={saveEpaySettings} + onConfirm={saveEpaySettings} /> ); diff --git a/frontend/src/components/admin/product-management.tsx b/frontend/src/components/admin/product-management.tsx index 9b8d6366..7d4f54e8 100644 --- a/frontend/src/components/admin/product-management.tsx +++ b/frontend/src/components/admin/product-management.tsx @@ -23,7 +23,7 @@ import { import { useState } from "react"; import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; -import { ReasonActionModal } from "./reason-action-modal"; +import { ConfirmActionModal } from "./confirm-action-modal"; import { formatAdminDate } from "./resource-table"; const { Text } = Typography; @@ -185,13 +185,13 @@ export default function ProductManagement() { }); } - async function save(reason: string) { + async function save() { if (!pendingSave) return; setSaving(true); try { await adminRequestJson("/api/admin/products", { method: "POST", - body: JSON.stringify({ ...pendingSave, reason }), + body: JSON.stringify(pendingSave), }); message.success("商品草稿已保存"); setPendingSave(null); @@ -203,12 +203,12 @@ export default function ProductManagement() { } } - async function publish(product: Product, reason: string) { + async function publish(product: Product) { setPublishingId(product.id); try { await adminRequestJson("/api/admin/products", { method: "POST", - body: JSON.stringify({ action: "publish", id: product.id, reason }), + body: JSON.stringify({ action: "publish", id: product.id }), }); message.success("商品已发布"); await table.tableQuery.refetch(); @@ -277,21 +277,21 @@ export default function ProductManagement() {
- setPendingSave(null)} - onSubmit={save} + onConfirm={save} /> - setPublishTarget(null)} - onSubmit={(reason) => publish(publishTarget!, reason)} + onConfirm={() => publish(publishTarget!)} /> ; } diff --git a/frontend/src/components/admin/reason-action-modal.tsx b/frontend/src/components/admin/reason-action-modal.tsx deleted file mode 100644 index b71f29d9..00000000 --- a/frontend/src/components/admin/reason-action-modal.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -import { Alert, Form, Input, Modal } from "antd"; -import { useState } from "react"; - -type ReasonActionModalProps = { - open: boolean; - title: string; - okText: string; - danger?: boolean; - confirmLoading?: boolean; - onCancel: () => void; - onSubmit: (reason: string) => Promise | void; -}; - -type ReasonFormValues = { - reason: string; -}; - -export function ReasonActionModal({ - open, - title, - okText, - danger = false, - confirmLoading = false, - onCancel, - onSubmit, -}: ReasonActionModalProps) { - const [form] = Form.useForm(); - const [actionLoading, setActionLoading] = useState(false); - const [actionError, setActionError] = useState(); - - async function submit(values: ReasonFormValues) { - setActionError(undefined); - setActionLoading(true); - try { - await onSubmit(values.reason.trim()); - } catch (error) { - setActionError(error instanceof Error ? error.message : "操作失败,请稍后再试"); - } finally { - setActionLoading(false); - } - } - - return ( - form.submit()} - afterOpenChange={(visible) => { - if (visible) form.resetFields(); - setActionError(undefined); - }} - destroyOnHidden - > -
- - - - {actionError ? : null} - -
- ); -} diff --git a/frontend/src/components/admin/users-resource.tsx b/frontend/src/components/admin/users-resource.tsx index 55d01131..db345713 100644 --- a/frontend/src/components/admin/users-resource.tsx +++ b/frontend/src/components/admin/users-resource.tsx @@ -4,7 +4,7 @@ import { useGetIdentity } from "@refinedev/core"; import { App, Button, Space, Typography, type TableColumnsType } from "antd"; import { useState } from "react"; -import { ReasonActionModal } from "./reason-action-modal"; +import { ConfirmActionModal } from "./confirm-action-modal"; import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; @@ -58,7 +58,7 @@ export default function UsersPage() { } } - async function resetAccount(reason: string) { + async function resetAccount() { if (!resetTarget) return; setResetting(true); try { @@ -69,7 +69,6 @@ export default function UsersPage() { body: JSON.stringify({ userId: resetTarget.id, confirmation: "RESET", - reason, }), }, ); @@ -127,14 +126,14 @@ export default function UsersPage() { return <> resource="customers" title="用户资料(列表始终脱敏)" columns={columns} /> - setResetTarget(null)} - onSubmit={resetAccount} + onConfirm={resetAccount} /> ; } diff --git a/frontend/src/lib/admin/codes.ts b/frontend/src/lib/admin/codes.ts index b782a43f..3fc448ef 100644 --- a/frontend/src/lib/admin/codes.ts +++ b/frontend/src/lib/admin/codes.ts @@ -110,7 +110,7 @@ export async function runCodeRpc( `select * from public.admin_create_redemption_codes( $1::uuid,$2::text,$3::text,$4::text,$5::jsonb,$6::text )`, - [...common, input.p_codes, input.p_reason], + [...common, JSON.stringify(input.p_codes), input.p_reason], ); break; } diff --git a/frontend/tests/admin-contracts.test.ts b/frontend/tests/admin-contracts.test.ts index 8bef5f57..68382ef4 100644 --- a/frontend/tests/admin-contracts.test.ts +++ b/frontend/tests/admin-contracts.test.ts @@ -25,7 +25,7 @@ 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 reasonActionModal = readFileSync(new URL("../src/components/admin/reason-action-modal.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"); @@ -237,9 +237,9 @@ 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.match(reasonActionModal, /label="操作原因"/); - assert.match(reasonActionModal, /await onSubmit\(values\.reason\.trim\(\)\)/); - assert.doesNotMatch(reasonActionModal, /邮箱验证码|\/api\/admin\/reauth|reauthPermission/); + assert.doesNotMatch(confirmActionModal, /操作原因|name="reason"|TextArea/); + assert.match(confirmActionModal, /await onConfirm\(\)/); + assert.doesNotMatch(confirmActionModal, /邮箱验证码|\/api\/admin\/reauth|reauthPermission/); 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 e0e73842..0db521cf 100644 --- a/frontend/tests/admin-mfa.test.ts +++ b/frontend/tests/admin-mfa.test.ts @@ -75,12 +75,12 @@ 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 requires only an audit reason while account MFA remains separate", () => { - const modal = readFileSync(new URL("../src/components/admin/reason-action-modal.tsx", import.meta.url), "utf8"); +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"); const http = readFileSync(new URL("../src/lib/admin/http.ts", import.meta.url), "utf8"); - assert.match(modal, /label="操作原因"/); - assert.match(modal, /await onSubmit\(values\.reason\.trim\(\)\)/); + assert.doesNotMatch(modal, /操作原因|name="reason"|TextArea/); + assert.match(modal, /await onConfirm\(\)/); assert.doesNotMatch(modal, /邮箱验证码|\/api\/admin\/reauth|reauthPermission/); 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 3d7ff304..80fb8b7f 100644 --- a/frontend/tests/admin-operation-reauth-removal.test.ts +++ b/frontend/tests/admin-operation-reauth-removal.test.ts @@ -38,22 +38,25 @@ test("operation-level admin email reauthentication is removed from routes and UI } }); -test("admin mutations retain session, permission, trusted-origin, reason, request-id, and audit boundaries", () => { +test("admin mutations retain session, permission, trusted-origin, server audit, request-id, and audit boundaries", () => { const http = source("src/lib/admin/http.ts"); 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/reason-action-modal.tsx"); + const modal = source("src/components/admin/confirm-action-modal.tsx"); 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, /reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/); + 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.match(modal, /label="操作原因"/); - assert.match(modal, /await onSubmit\(values\.reason\.trim\(\)\)/); + assert.doesNotMatch(modal, /操作原因|TextArea|name="reason"/); + assert.match(modal, /await onConfirm\(\)/); }); 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 659e9c5a..810fe096 100644 --- a/frontend/tests/admin-ui-permission-contract.test.ts +++ b/frontend/tests/admin-ui-permission-contract.test.ts @@ -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, /ReasonActionModal/); + assert.match(ui, /ConfirmActionModal/); 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/); @@ -115,17 +115,17 @@ test("model management uses ordinary admin mutation guards without reauth", () = assert.doesNotMatch(ui, /验证并(?:保存|获取)/); }); -test("admin request failures are real Error instances and reason modal keeps failures visible", () => { +test("admin request failures are real Error instances and confirmation modal keeps failures visible", () => { const providers = source("src/lib/admin/providers.ts"); - const modal = source("src/components/admin/reason-action-modal.tsx"); + 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.match(modal, /name="reason"[\s\S]*required: true, whitespace: true/); - assert.match(modal, /await onSubmit\(values\.reason\.trim\(\)\)/); + assert.doesNotMatch(modal, /操作原因|name="reason"|TextArea/); + assert.match(modal, /await onConfirm\(\)/); assert.doesNotMatch(modal, /reauth|邮箱验证码|发送验证码/); }); diff --git a/frontend/tests/high-risk-billing-routes-contract.test.ts b/frontend/tests/high-risk-billing-routes-contract.test.ts index b6b640d2..e11a7bde 100644 --- a/frontend/tests/high-risk-billing-routes-contract.test.ts +++ b/frontend/tests/high-risk-billing-routes-contract.test.ts @@ -40,13 +40,13 @@ test("existing payment orders remain queryable and settleable when subscriptions test("billing and operations writes use the shared ordinary mutation guard", () => { assert.match(productsRoute, /requireAdminMutation\(request, permission\)/); - assert.match(productsRoute, /body\.data\.reason[\s\S]*requestId/); + assert.match(productsRoute, /admin_console_(?:save|publish)_product/); assert.match(epaySettingsRoute, /requireAdminMutation\(request, "billing\.adjustments\.write"\)/); assert.match(epaySettingsRoute, /admin_save_epay_settings/); assert.match(subscriptionsRoute, /requireAdminMutation\(request,"billing\.adjustments\.write"\)/); - assert.match(subscriptionsRoute, /body\.data\.reason,rid/); + assert.match(subscriptionsRoute, /admin_console_adjust_subscription/); assert.match(featureFlagsRoute, /requireAdminMutation\(request,"ops\.flags\.write"\)/); - assert.match(featureFlagsRoute, /admin_publish_feature_flag[\s\S]*b\.data\.reason,rid/); + assert.match(featureFlagsRoute, /admin_console_publish_feature_flag/); for (const route of [productsRoute, epaySettingsRoute, subscriptionsRoute, featureFlagsRoute]) { assert.doesNotMatch(route, /requireHighRiskAdminMutation/); } @@ -74,13 +74,14 @@ test("self-hosted payment catalog uses simple queries and immutable product snap assert.match(createRoute, /product_snapshot:\s*productSnapshot/); }); -test("order adjustments require permission, reason, version, idempotency request id, and the domain RPC", () => { +test("order adjustments require permission, version, server audit reason, request id, and the domain RPC", () => { assert.match( ordersRoute, /requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/, ); assert.match(ordersRoute, /expectedVersion:\s*z\.number\(\)\.int\(\)\.min\(0\)/); - assert.match(ordersRoute, /reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/); + assert.doesNotMatch(ordersRoute, /reason:\s*z\.string/); + assert.match(ordersRoute, /admin_console_adjust_order/); assert.match(ordersRoute, /queryAdminRows/); assert.match(ordersRoute, /public\.admin_adjust_order\(/); assert.match(ordersRoute, /requestId\(request\)/); @@ -92,16 +93,16 @@ test("order adjustments require permission, reason, version, idempotency request assert.match(billingOperationsUi, /不调用支付网关|仅记录账务/); }); -test("every redemption-code write requires a reason and forwards it to the audited RPC", () => { +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.equal((codeRoute.match(/requireAdminMutation\(/g) ?? []).length, 2); assert.doesNotMatch(codeRoute, /requireHighRiskAdminMutation/); - assert.match(codesRoute, /reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/); - assert.match(codesRoute, /p_reason:\s*parsed\.data\.reason/); - assert.match(codeRoute, /const revokeCodeSchema[\s\S]*reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/); - assert.match(codeRoute, /const updateCodeSchema[\s\S]*reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/); - assert.match(codeRoute, /p_reason:\s*body\.reason/); - assert.match(codeRoute, /p_reason:\s*parsedBody\.data\.reason/); + assert.doesNotMatch(codesRoute, /reason:\s*z\.string/); + assert.doesNotMatch(codeRoute, /reason:\s*z\.string/); + assert.match(codesRoute, /p_reason:\s*"admin_console_create_redemption_codes"/); + assert.match(codeRoute, /p_reason:\s*"admin_console_update_redemption_code"/); + assert.match(codeRoute, /p_reason:\s*"admin_console_revoke_redemption_code"/); + assert.match(codesHelper, /JSON\.stringify\(input\.p_codes\)/); assert.match(codesHelper, /queryAdminRows/); assert.match(codesHelper, /public\.admin_create_redemption_codes\(/); assert.match(codesHelper, /public\.admin_update_redemption_code\(/); @@ -109,7 +110,7 @@ test("every redemption-code write requires a reason and forwards it to the audit assert.doesNotMatch(codesHelper, /createAdminSupabaseClient|\.rpc\(|\$\{functionName\}/); assert.match(codesUi, /permissions\.includes\("billing\.adjustments\.write"\)/); assert.doesNotMatch(codesUi, /reauthPermission|邮箱验证码|\/api\/admin\/reauth/); - assert.match(codesUi, /open=\{Boolean\(pendingCreate\)\}[\s\S]*onSubmit=\{submitCreate\}/); - assert.match(codesUi, /open=\{Boolean\(pendingEdit\)\}[\s\S]*onSubmit=\{submitEdit\}/); - assert.match(codesUi, / revoke\(revokeRecord!, reason\)\}/); + 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!\)\}/); });