diff --git a/frontend/src/app/api/admin/customers/reset/route.ts b/frontend/src/app/api/admin/customers/reset/route.ts new file mode 100644 index 00000000..da448706 --- /dev/null +++ b/frontend/src/app/api/admin/customers/reset/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { isPostgresError, queryAdminRows } from "@/lib/admin/database"; +import { + adminErrorResponse, + invalidQueryResponse, + requestId, + requireHighRiskAdminMutation, +} from "@/lib/admin/http"; + +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 = { + user_id: string; + email: string; + credits: number; + chat_sessions_deleted: number; + chart_profiles_deleted: number; + synastry_reports_deleted: number; +}; + +export async function POST(request: Request) { + try { + const session = await requireHighRiskAdminMutation( + request, + "admin.users.manage_roles", + ); + const parsed = resetSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + + const rows = await queryAdminRows( + "select * from public.admin_reset_customer_account($1, $2, $3, $4)", + [ + session.user.id, + parsed.data.userId, + parsed.data.reason, + requestId(request), + ], + ); + const row = rows[0]; + if (!row) return NextResponse.json({ error: "用户不存在" }, { status: 404 }); + + return NextResponse.json({ + data: { + userId: row.user_id, + email: row.email, + credits: row.credits, + deleted: { + chatSessions: row.chat_sessions_deleted, + chartProfiles: row.chart_profiles_deleted, + synastryReports: row.synastry_reports_deleted, + }, + }, + }); + } catch (error) { + if ( + isPostgresError(error) + && error.code === "P0002" + && error instanceof Error + && error.message.includes("admin_customer_not_found_or_identity_bridge_mismatch") + ) { + return NextResponse.json({ error: "用户不存在或账号数据不完整" }, { status: 404 }); + } + return adminErrorResponse(error); + } +} diff --git a/frontend/src/components/admin/users-resource.tsx b/frontend/src/components/admin/users-resource.tsx index 9e0124e1..e82b262b 100644 --- a/frontend/src/components/admin/users-resource.tsx +++ b/frontend/src/components/admin/users-resource.tsx @@ -4,6 +4,8 @@ import { useGetIdentity } from "@refinedev/core"; import { App, Button, Space, Tag, Typography, type TableColumnsType } from "antd"; import { useState } from "react"; +import { ReasonActionModal } from "./reason-action-modal"; + import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; @@ -36,6 +38,9 @@ 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")); async function revealBirthData(userId: string) { @@ -53,6 +58,36 @@ export default function UsersPage() { } } + async function resetAccount(reason: string) { + if (!resetTarget) return; + setResetting(true); + try { + await adminRequestJson<{ data: { credits: number } }>( + "/api/admin/customers/reset", + { + method: "POST", + body: JSON.stringify({ + userId: resetTarget.id, + confirmation: "RESET", + reason, + }), + }, + ); + message.success(`已重置 ${resetTarget.email},登录身份、管理员角色和积分保持不变`); + setRevealed((current) => { + const next = { ...current }; + delete next[resetTarget.id]; + return next; + }); + setResetTarget(null); + } catch (error) { + message.error(error instanceof Error ? error.message : "重置账号失败"); + throw error; + } finally { + setResetting(false); + } + } + const maskedValue = (value: string | null | undefined) => value || "未填写"; const columns: TableColumnsType = [ { title: "邮箱", dataIndex: "email", sorter: true }, @@ -82,7 +117,25 @@ export default function UsersPage() { { title: "邮箱验证", dataIndex: "emailVerified", render: (value) => value ? "已验证" : "未验证" }, { title: "状态", dataIndex: "banned", render: (value) => value ? 已禁用 : 正常 }, { title: "注册时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, + ...(canReset ? [{ + title: "操作", + render: (_: unknown, item: UserRecord) => , + }] : []), ]; - return resource="customers" title="用户资料(列表始终脱敏)" columns={columns} />; + return <> + resource="customers" title="用户资料(列表始终脱敏)" columns={columns} /> + setResetTarget(null)} + onSubmit={resetAccount} + /> + ; } diff --git a/frontend/supabase/migrations/20260811020000_admin_customer_account_reset.sql b/frontend/supabase/migrations/20260811020000_admin_customer_account_reset.sql new file mode 100644 index 00000000..c83cee60 --- /dev/null +++ b/frontend/supabase/migrations/20260811020000_admin_customer_account_reset.sql @@ -0,0 +1,227 @@ +begin; + +create or replace function public.admin_reset_customer_account( + p_actor_user_id uuid, + p_target_user_id uuid, + p_reason text, + p_request_id text +) +returns table ( + user_id uuid, + email text, + credits integer, + chat_sessions_deleted integer, + chart_profiles_deleted integer, + synastry_reports_deleted integer +) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_actor_email text; + v_target_email text; + v_profile_email text; + v_credits integer; + v_identity_accounts bigint; + v_identity_sessions bigint; + v_admin_user_roles bigint; + v_credit_transactions bigint; + v_credit_cancellations bigint; + v_consultation_requests bigint; + v_rectification_billing bigint; + v_action_receipts bigint; + v_redeemed_codes bigint; + v_admin_audit_logs bigint; + v_chat_sessions_deleted integer; + v_chart_profiles_deleted integer; + v_synastry_reports_deleted integer; +begin + if not public.admin_has_permission(p_actor_user_id, 'admin.users.manage_roles') then + raise exception 'admin_permission_denied' using errcode = '42501'; + end if; + if p_target_user_id is null then + raise exception 'admin_customer_scope_invalid' using errcode = '22023'; + 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; + if char_length(btrim(coalesce(p_request_id, ''))) not between 1 and 200 then + raise exception 'admin_request_id_invalid' using errcode = '22023'; + end if; + + select lower(btrim(value.email)) + into v_actor_email + from identity.users value + where value.id = p_actor_user_id; + if v_actor_email is null then + raise exception 'admin_user_not_found' using errcode = '22023'; + end if; + + select + identity_user.email, + profile.email, + profile.credits, + (select count(*) from identity.accounts value where value.user_id = identity_user.id), + (select count(*) from identity.sessions value where value.user_id = identity_user.id), + (select count(*) from public.admin_user_roles value where value.admin_user_id = identity_user.id), + (select count(*) from public.credit_transactions value where value.user_id = identity_user.id), + (select count(*) from public.credit_request_cancellations value where value.user_id = identity_user.id), + (select count(*) from public.consultation_requests value where value.user_id = identity_user.id), + (select count(*) from public.birth_time_rectification_billing value where value.user_id = identity_user.id), + (select count(*) from public.birth_time_rectification_action_receipts value where value.user_id = identity_user.id), + (select count(*) from public.redemption_codes value where value.redeemed_by = identity_user.id), + (select count(*) from audit.admin_audit_logs value where value.actor_user_id = identity_user.id) + into + v_target_email, + v_profile_email, + v_credits, + v_identity_accounts, + v_identity_sessions, + v_admin_user_roles, + v_credit_transactions, + v_credit_cancellations, + v_consultation_requests, + v_rectification_billing, + v_action_receipts, + v_redeemed_codes, + v_admin_audit_logs + from identity.users identity_user + join auth.users auth_user on auth_user.id = identity_user.id + join public.profiles profile on profile.id = identity_user.id + where identity_user.id = p_target_user_id + and lower(btrim(auth_user.email)) = lower(btrim(identity_user.email)) + for update of identity_user, auth_user, profile; + + if not found then + raise exception 'admin_customer_not_found_or_identity_bridge_mismatch' using errcode = 'P0002'; + end if; + + update public.profiles + set name = null, + birth_date = null, + birth_time = null, + country_code = null, + province_code = null, + city_code = null, + district_code = null, + onboarding_payload = null, + onboarding_version = null, + onboarding_generated_at = null, + latitude = null, + longitude = null, + timezone_offset = null, + reported_birth_time = null, + active_birth_time = null, + birth_time_source = null, + birth_time_period = null, + birth_time_clue = null, + uncertainty_before_minutes = null, + uncertainty_after_minutes = null, + birth_time_status = null, + rectification_confidence = null, + rectification_case_id = null, + birth_place_label = null, + birth_place_type = null, + birth_place_provider = null, + birth_place_provider_id = null, + timezone_id = null, + timezone_source = null, + updated_at = pg_catalog.now() + where id = p_target_user_id; + + delete from public.chat_sessions value where value.user_id = p_target_user_id; + get diagnostics v_chat_sessions_deleted = row_count; + delete from public.chart_profiles value where value.user_id = p_target_user_id; + get diagnostics v_chart_profiles_deleted = row_count; + delete from public.synastry_reports value where value.user_id = p_target_user_id; + get diagnostics v_synastry_reports_deleted = row_count; + + if exists ( + select 1 + from identity.users identity_user + join auth.users auth_user on auth_user.id = identity_user.id + join public.profiles profile on profile.id = identity_user.id + where identity_user.id = p_target_user_id + and ( + identity_user.email is distinct from v_target_email + or auth_user.email is distinct from v_target_email + or profile.email is distinct from v_profile_email + or profile.credits is distinct from v_credits + or (select count(*) from identity.accounts value where value.user_id = identity_user.id) <> v_identity_accounts + or (select count(*) from identity.sessions value where value.user_id = identity_user.id) <> v_identity_sessions + or (select count(*) from public.admin_user_roles value where value.admin_user_id = identity_user.id) <> v_admin_user_roles + or (select count(*) from public.credit_transactions value where value.user_id = identity_user.id) <> v_credit_transactions + or (select count(*) from public.credit_request_cancellations value where value.user_id = identity_user.id) <> v_credit_cancellations + or (select count(*) from public.consultation_requests value where value.user_id = identity_user.id) <> v_consultation_requests + or (select count(*) from public.birth_time_rectification_billing value where value.user_id = identity_user.id) <> v_rectification_billing + or (select count(*) from public.birth_time_rectification_action_receipts value where value.user_id = identity_user.id) <> v_action_receipts + or (select count(*) from public.redemption_codes value where value.redeemed_by = identity_user.id) <> v_redeemed_codes + or (select count(*) from audit.admin_audit_logs value where value.actor_user_id = identity_user.id) <> v_admin_audit_logs + ) + ) then + raise exception 'admin_customer_preserved_state_changed' using errcode = 'P0001'; + end if; + + if exists (select 1 from public.chat_sessions value where value.user_id = p_target_user_id) + or exists (select 1 from public.chart_profiles value where value.user_id = p_target_user_id) + or exists (select 1 from public.synastry_reports value where value.user_id = p_target_user_id) + or exists ( + select 1 + from public.profiles profile + where profile.id = p_target_user_id + and ( + profile.name is not null or profile.birth_date is not null or profile.birth_time is not null + or profile.country_code is not null or profile.province_code is not null or profile.city_code is not null or profile.district_code is not null + or profile.onboarding_payload is not null or profile.onboarding_version is not null or profile.onboarding_generated_at is not null + or profile.latitude is not null or profile.longitude is not null or profile.timezone_offset is not null + or profile.reported_birth_time is not null or profile.active_birth_time is not null or profile.birth_time_source is not null + or profile.birth_time_period is not null or profile.birth_time_clue is not null + or profile.uncertainty_before_minutes is not null or profile.uncertainty_after_minutes is not null + or profile.birth_time_status is not null or profile.rectification_confidence is not null or profile.rectification_case_id is not null + or profile.birth_place_label is not null or profile.birth_place_type is not null or profile.birth_place_provider is not null + or profile.birth_place_provider_id is not null or profile.timezone_id is not null or profile.timezone_source is not null + ) + ) then + raise exception 'admin_customer_reset_state_not_empty' using errcode = 'P0001'; + end if; + + insert into audit.admin_audit_logs ( + actor_user_id, actor_email, actor_role, action, target_type, target_id, + after_value, request_id, permission_used, reason + ) values ( + p_actor_user_id, v_actor_email, 'admin', 'admin.customer.account.reset', + 'customer', p_target_user_id, + jsonb_build_object( + 'email', lower(btrim(v_target_email)), + 'creditsPreserved', v_credits, + 'chatSessionsDeleted', v_chat_sessions_deleted, + 'chartProfilesDeleted', v_chart_profiles_deleted, + 'synastryReportsDeleted', v_synastry_reports_deleted + ), + btrim(p_request_id), 'admin.users.manage_roles', btrim(p_reason) + ) on conflict do nothing; + + return query select + p_target_user_id, + lower(btrim(v_target_email)), + v_credits, + v_chat_sessions_deleted, + v_chart_profiles_deleted, + v_synastry_reports_deleted; +end; +$$; + +revoke all on function public.admin_reset_customer_account(uuid, uuid, text, 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_reset_customer_account(uuid, uuid, text, text) + to admin_runtime; + end if; +end; +$$; + +commit; diff --git a/frontend/tests/admin-account-reset-contract.test.ts b/frontend/tests/admin-account-reset-contract.test.ts new file mode 100644 index 00000000..74c965a2 --- /dev/null +++ b/frontend/tests/admin-account-reset-contract.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const root = new URL("../", import.meta.url); +const route = readFileSync(new URL("src/app/api/admin/customers/reset/route.ts", root), "utf8"); +const users = readFileSync(new URL("src/components/admin/users-resource.tsx", root), "utf8"); +const migration = readFileSync(new URL("supabase/migrations/20260811020000_admin_customer_account_reset.sql", root), "utf8"); + +test("admin account reset stays owner-only, explicit, and high risk", () => { + assert.match(route, /requireHighRiskAdminMutation\(\s*request,\s*"admin\.users\.manage_roles",?\s*\)/); + assert.match(route, /confirmation:\s*z\.literal\("RESET"\)/); + assert.match(route, /admin_reset_customer_account\(\$1, \$2, \$3, \$4\)/); + assert.match(users, /permissions\.includes\("admin\.users\.manage_roles"\)/); + assert.match(users, /reauthPermission="admin\.users\.manage_roles"/); + assert.match(users, /登录身份、管理员角色、积分及账务审计记录会保留/); +}); + +test("database reset mirrors the existing staging reset boundary and audits it", () => { + assert.match(migration, /update public\.profiles[\s\S]*name = null[\s\S]*timezone_source = null/); + assert.match(migration, /delete from public\.chat_sessions/); + assert.match(migration, /delete from public\.chart_profiles/); + assert.match(migration, /delete from public\.synastry_reports/); + assert.match(migration, /profile\.credits is distinct from v_credits/); + assert.match(migration, /identity\.accounts/); + assert.match(migration, /identity\.sessions/); + assert.match(migration, /public\.admin_user_roles/); + assert.match(migration, /public\.credit_transactions/); + assert.match(migration, /public\.consultation_requests/); + assert.match(migration, /audit\.admin_audit_logs/); + assert.match(migration, /'admin\.customer\.account\.reset'/); + assert.doesNotMatch(migration, /delete from (identity|auth)\.users/); +}); diff --git a/frontend/tests/database-admin-account-reset.test.ts b/frontend/tests/database-admin-account-reset.test.ts new file mode 100644 index 00000000..23b27a25 --- /dev/null +++ b/frontend/tests/database-admin-account-reset.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { Client } from "pg"; + +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); +const ownerId = "81000000-0000-4000-8000-000000000001"; +const targetId = "81000000-0000-4000-8000-000000000002"; + +function postgresCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; +} + +test("admin customer reset clears only rebuildable application state", async () => { + const fixture = startPostgresFixture(); + const adminRuntime = new Client({ + connectionString: fixture.connectionUrl("admin_runtime", "admin-runtime-test-password"), + }); + + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { + ...process.env, + SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"), + }, + }); + assert.equal(migration.status, 0, `${migration.stdout}${migration.stderr}`); + + fixture.psqlAs("identity_runtime", "identity-runtime-test-password", ` + insert into identity.users (id, name, email, email_verified, email_verified_at, role) values + ('${ownerId}', 'Owner', 'reset-owner@example.com', true, now(), 'admin'), + ('${targetId}', 'Target Admin', 'reset-target@example.com', true, now(), 'admin'); + insert into identity.accounts (id, account_id, provider_id, user_id, password) + values ('82000000-0000-4000-8000-000000000001', 'reset-target@example.com', 'credential', '${targetId}', 'password-hash'); + insert into identity.sessions (id, token, user_id, expires_at) + values ('82000000-0000-4000-8000-000000000002', 'reset-session-token', '${targetId}', now() + interval '1 day'); + `); + + fixture.psql(` + insert into public.admin_users (user_id, created_by) values + ('${ownerId}', '${ownerId}'), ('${targetId}', '${ownerId}'); + insert into public.admin_user_roles (admin_user_id, role_id, assigned_by) + select values.user_id, roles.id, '${ownerId}'::uuid + from (values + ('${ownerId}'::uuid, 'owner'), + ('${targetId}'::uuid, 'support') + ) values(user_id, role_code) + join public.admin_roles roles on roles.code = values.role_code; + + update public.profiles set + credits = 73, + name = 'Reset Me', + birth_date = '1990-01-02', + birth_time = '03:04', + country_code = 'CN', + province_code = '11', + city_code = '1101', + district_code = '110101', + onboarding_payload = '{"ready":true}'::jsonb, + onboarding_version = 'test-v1', + onboarding_generated_at = now(), + latitude = 39.9, + longitude = 116.4, + timezone_offset = 8, + reported_birth_time = '03:04', + active_birth_time = '03:04', + birth_time_source = 'legacy_import', + birth_time_status = 'confirmed', + birth_place_label = 'Test Place', + birth_place_type = 'city', + birth_place_provider = 'geonames', + birth_place_provider_id = 'test-place', + timezone_id = 'Asia/Shanghai', + timezone_source = 'iana_historical' + where id = '${targetId}'; + + insert into public.chat_sessions (user_id, title, theme, messages) + values ('${targetId}', 'Reset Chat', 'general', '[]'::jsonb); + insert into public.chart_profiles (user_id, role, profile) + values ('${targetId}', 'self', '{}'::jsonb); + insert into public.synastry_reports (user_id, partner_name, report) + values ('${targetId}', 'Partner', '{}'::jsonb); + insert into public.credit_transactions (user_id, transaction_type, amount, balance_after, request_id) + values ('${targetId}', 'redeem', 73, 73, 'reset-preserved-credit'); + insert into audit.admin_audit_logs ( + actor_user_id, actor_email, actor_role, action, target_type, target_id, + after_value, request_id, permission_used, reason + ) values ( + '${targetId}', 'reset-target@example.com', 'admin', 'admin.customer.birth_data.read', + 'customer', '${targetId}', '{}'::jsonb, 'reset-preserved-audit', + 'admin.customers.birth_data.read', 'existing audit history' + ); + `); + + await adminRuntime.connect(); + await assert.rejects( + adminRuntime.query( + "select * from public.admin_reset_customer_account($1, $2, $3, $4)", + [targetId, targetId, "unauthorized self reset", "reset-denied"], + ), + (error) => postgresCode(error) === "42501", + ); + + const reset = await adminRuntime.query<{ + user_id: string; + email: string; + credits: number; + chat_sessions_deleted: number; + chart_profiles_deleted: number; + synastry_reports_deleted: number; + }>( + "select * from public.admin_reset_customer_account($1, $2, $3, $4)", + [ownerId, targetId, "prepare account for a fresh onboarding test", "reset-success"], + ); + assert.deepEqual(reset.rows, [{ + user_id: targetId, + email: "reset-target@example.com", + credits: 73, + chat_sessions_deleted: 1, + chart_profiles_deleted: 1, + synastry_reports_deleted: 1, + }]); + + const state = JSON.parse(fixture.psql(` + select jsonb_build_object( + 'identityUsers', (select count(*) from identity.users where id = '${targetId}'), + 'authUsers', (select count(*) from auth.users where id = '${targetId}'), + 'identityAccounts', (select count(*) from identity.accounts where user_id = '${targetId}'), + 'identitySessions', (select count(*) from identity.sessions where user_id = '${targetId}'), + 'credits', (select credits from public.profiles where id = '${targetId}'), + 'profileReset', (select name is null and birth_date is null and birth_time is null and onboarding_payload is null and reported_birth_time is null and active_birth_time is null and birth_place_label is null from public.profiles where id = '${targetId}'), + 'chatSessions', (select count(*) from public.chat_sessions where user_id = '${targetId}'), + 'chartProfiles', (select count(*) from public.chart_profiles where user_id = '${targetId}'), + 'synastryReports', (select count(*) from public.synastry_reports where user_id = '${targetId}'), + 'creditTransactions', (select count(*) from public.credit_transactions where user_id = '${targetId}'), + 'adminRoles', (select count(*) from public.admin_user_roles where admin_user_id = '${targetId}'), + 'existingAudit', (select count(*) from audit.admin_audit_logs where actor_user_id = '${targetId}' and request_id = 'reset-preserved-audit'), + 'resetAudit', (select count(*) from audit.admin_audit_logs where actor_user_id = '${ownerId}' and request_id = 'reset-success' and action = 'admin.customer.account.reset') + ) + `)) as Record; + + assert.deepEqual(state, { + identityUsers: 1, + authUsers: 1, + identityAccounts: 1, + identitySessions: 1, + credits: 73, + profileReset: true, + chatSessions: 0, + chartProfiles: 0, + synastryReports: 0, + creditTransactions: 1, + adminRoles: 1, + existingAudit: 1, + resetAudit: 1, + }); + } finally { + await adminRuntime.end().catch(() => undefined); + fixture.stop(); + } +});