feat(admin): add safe account reset
This commit is contained in:
@@ -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<ResetRow>(
|
||||
"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);
|
||||
}
|
||||
}
|
||||
@@ -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<AdminIdentity>();
|
||||
const [revealed, setRevealed] = useState<Record<string, RevealedBirthData>>({});
|
||||
const [revealingId, setRevealingId] = useState<string | null>(null);
|
||||
const [resetTarget, setResetTarget] = useState<UserRecord | null>(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<UserRecord> = [
|
||||
{ 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 ? <Tag color="red">已禁用</Tag> : <Tag color="green">正常</Tag> },
|
||||
{ title: "注册时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
|
||||
...(canReset ? [{
|
||||
title: "操作",
|
||||
render: (_: unknown, item: UserRecord) => <Button danger size="small" onClick={() => setResetTarget(item)}>
|
||||
重置资料与会话
|
||||
</Button>,
|
||||
}] : []),
|
||||
];
|
||||
|
||||
return <ResourceTable<UserRecord> resource="customers" title="用户资料(列表始终脱敏)" columns={columns} />;
|
||||
return <>
|
||||
<ResourceTable<UserRecord> resource="customers" title="用户资料(列表始终脱敏)" columns={columns} />
|
||||
<ReasonActionModal
|
||||
open={Boolean(resetTarget)}
|
||||
title={`确认重置 ${resetTarget?.email ?? "该账号"} 的资料与会话?登录身份、管理员角色、积分及账务审计记录会保留。`}
|
||||
okText="确认重置"
|
||||
danger
|
||||
confirmLoading={resetting}
|
||||
reauthPermission="admin.users.manage_roles"
|
||||
onCancel={() => setResetTarget(null)}
|
||||
onSubmit={resetAccount}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user