feat(account): self-service deletion with a 7-day cooling-off period
Request signs out everywhere and freezes paid routes (423 + DB triggers); signing in within 7 days shows the pending gate with 撤销注销. A periodic idempotent worker purges personal content afterwards and keeps finance rows against a tombstoned identity. Read-only admin list. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3c8123d912
commit
96adbce3a9
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
formatAccountDeletionDate,
|
||||
parseAccountDeletionStatus,
|
||||
type AccountDeletionStatus,
|
||||
} from "@/lib/account-deletion";
|
||||
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
|
||||
import { selfHostedOtpActions } from "@/modules/identity/client";
|
||||
|
||||
/**
|
||||
* 「账号注销中」 (2026-09-30). An account with a pending deletion that signs in
|
||||
* again sees only this: the date it becomes permanent, 撤销注销 (restores the
|
||||
* account) or 退出登录. Paid routes refuse the account server-side meanwhile,
|
||||
* so the screen is the way back, not the lock.
|
||||
*/
|
||||
export function AccountDeletionGate({ signedIn }: Readonly<{ signedIn: boolean }>) {
|
||||
const [status, setStatus] = useState<AccountDeletionStatus>({ status: "none" });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!signedIn) return;
|
||||
let cancelled = false;
|
||||
fetch("/api/account/deletion", { credentials: "same-origin", headers: { Accept: "application/json" } })
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((json) => { if (!cancelled) setStatus(parseAccountDeletionStatus(json)); })
|
||||
.catch(() => { /* No status is no screen: never lock anyone out on a read error. */ });
|
||||
return () => { cancelled = true; };
|
||||
}, [signedIn]);
|
||||
|
||||
if (status.status !== "pending") return null;
|
||||
|
||||
async function restore() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/account/deletion", { method: "DELETE", credentials: "same-origin", headers: { Accept: "application/json" } });
|
||||
if (!response.ok) {
|
||||
const json = await response.json().catch(() => null) as { error?: unknown } | null;
|
||||
setError(typeof json?.error === "string" ? json.error : "撤销暂时没能完成,请稍后再试。");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
clearHomeWarmSnapshot();
|
||||
window.location.reload();
|
||||
} catch {
|
||||
setError("网络异常,请检查连接后重试。");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function leave() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
clearHomeWarmSnapshot();
|
||||
try {
|
||||
await selfHostedOtpActions.signOut();
|
||||
} finally {
|
||||
window.location.assign("/login");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="account-deletion-gate" role="alertdialog" aria-modal="true" aria-labelledby="account-deletion-gate-title" aria-describedby="account-deletion-gate-body">
|
||||
<section>
|
||||
<h2 id="account-deletion-gate-title">账号注销中</h2>
|
||||
<p id="account-deletion-gate-body">
|
||||
这个账号将在 {formatAccountDeletionDate(status.scheduledFor)} 永久删除。在那之前,你可以撤销注销,恢复全部内容。
|
||||
</p>
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
<div className="dialog-actions">
|
||||
<button className="button-secondary" type="button" onClick={() => void leave()} disabled={busy}>退出登录</button>
|
||||
<button className="button-primary" type="button" onClick={() => void restore()} disabled={busy}>{busy ? "正在处理" : "撤销注销"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
ACCOUNT_DELETION_CONFIRM_WORD,
|
||||
ACCOUNT_DELETION_DELETED_ITEMS,
|
||||
ACCOUNT_DELETION_GRACE_DAYS,
|
||||
isAccountDeletionConfirmation,
|
||||
} from "@/lib/account-deletion";
|
||||
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
|
||||
import { LEGAL_ENTITY } from "@/lib/legal-entity";
|
||||
|
||||
/**
|
||||
* 「注销账号」 at the bottom of 通用设置 (2026-09-30). The confirmation opens in
|
||||
* place rather than as a second dialog on top of the settings dialog: what is
|
||||
* deleted, what is kept, that credits are forfeited, the 7-day window, and a
|
||||
* typed 「注销」. On success every session has already been ended server-side,
|
||||
* so the page goes to the login screen.
|
||||
*/
|
||||
export function AccountDeletionSection() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function submit() {
|
||||
if (submitting || !isAccountDeletionConfirmation(confirm)) return;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/account/deletion", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ confirm: confirm.trim() }),
|
||||
});
|
||||
const json = await response.json().catch(() => null) as { error?: unknown } | null;
|
||||
if (!response.ok) {
|
||||
setError(typeof json?.error === "string" ? json.error : "注销申请暂时没能提交,请稍后再试。");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
clearHomeWarmSnapshot();
|
||||
window.location.assign("/login");
|
||||
} catch {
|
||||
setError("网络异常,请检查连接后重试。");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="account-deletion-section" aria-labelledby="account-deletion-title">
|
||||
<div className="section-heading">
|
||||
<b id="account-deletion-title">注销账号</b>
|
||||
<small>{ACCOUNT_DELETION_GRACE_DAYS} 天内可以撤销,之后永久删除</small>
|
||||
</div>
|
||||
{!open ? (
|
||||
<button type="button" className="account-deletion-open" onClick={() => setOpen(true)}>
|
||||
注销账号…
|
||||
</button>
|
||||
) : (
|
||||
<div className="account-deletion-confirm">
|
||||
<p>提交后你会立刻退出登录,账号暂停使用。{ACCOUNT_DELETION_GRACE_DAYS} 天内重新登录可以撤销;{ACCOUNT_DELETION_GRACE_DAYS} 天后以下内容会被永久删除,无法恢复:</p>
|
||||
<ul>
|
||||
{ACCOUNT_DELETION_DELETED_ITEMS.map((item) => <li key={item}>{item}</li>)}
|
||||
</ul>
|
||||
<p>剩余点数和会员权益会一并作废,不退款。订单和点数流水按法规保留,但不再与你的身份关联。</p>
|
||||
<p className="account-deletion-contact">有疑问可以先联系我们:{LEGAL_ENTITY.contactEmail}</p>
|
||||
<label className="account-deletion-input">
|
||||
<span>输入「{ACCOUNT_DELETION_CONFIRM_WORD}」确认</span>
|
||||
<input
|
||||
value={confirm}
|
||||
onChange={(event) => setConfirm(event.target.value)}
|
||||
autoComplete="off"
|
||||
aria-describedby={error ? "account-deletion-error" : undefined}
|
||||
/>
|
||||
</label>
|
||||
{error ? <p id="account-deletion-error" className="form-error" role="alert">{error}</p> : null}
|
||||
<div className="dialog-actions">
|
||||
<button className="button-secondary" type="button" onClick={() => { setOpen(false); setConfirm(""); setError(""); }} disabled={submitting}>取消</button>
|
||||
<button
|
||||
className="button-primary danger-primary"
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={submitting || !isAccountDeletionConfirmation(confirm)}
|
||||
>
|
||||
{submitting ? "正在提交" : "确认注销"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Descriptions, type TableColumnsType } from "antd";
|
||||
|
||||
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
|
||||
|
||||
type DeletionRecord = {
|
||||
id: string;
|
||||
userId: string;
|
||||
status: string;
|
||||
requestedAt: string;
|
||||
scheduledFor: string;
|
||||
cancelledAt: string | null;
|
||||
completedAt: string | null;
|
||||
attemptCount: number;
|
||||
errorCode: string | null;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = { pending: "冷静期中", cancelled: "已撤销", completed: "已删除" };
|
||||
|
||||
const columns: TableColumnsType<DeletionRecord> = [
|
||||
{ title: "状态", dataIndex: "status", sorter: true, render: (value: string) => STATUS_LABELS[value] ?? value },
|
||||
{ title: "用户 ID", dataIndex: "userId" },
|
||||
{ title: "申请时间", dataIndex: "requestedAt", sorter: true, render: formatAdminDate },
|
||||
{ title: "计划删除", dataIndex: "scheduledFor", sorter: true, render: formatAdminDate },
|
||||
{ title: "撤销时间", dataIndex: "cancelledAt", render: (value: string | null) => (value ? formatAdminDate(value) : "—") },
|
||||
{ title: "完成时间", dataIndex: "completedAt", render: (value: string | null) => (value ? formatAdminDate(value) : "—") },
|
||||
{ title: "失败次数", dataIndex: "attemptCount" },
|
||||
{ title: "最近错误", dataIndex: "errorCode", render: (value: string | null) => value ?? "—" },
|
||||
];
|
||||
|
||||
export default function AccountDeletionsPage() {
|
||||
return <ResourceTable<DeletionRecord>
|
||||
resource="account-deletions"
|
||||
title="注销申请(只读)"
|
||||
columns={columns}
|
||||
statusOptions={[
|
||||
{ label: "冷静期中", value: "pending" },
|
||||
{ label: "已撤销", value: "cancelled" },
|
||||
{ label: "已删除", value: "completed" },
|
||||
]}
|
||||
extra={<Descriptions size="small" items={[{ key: "policy", label: "说明", children: "7 天冷静期后自动删除个人内容;订单与点数流水保留但不再关联身份。本表不存邮箱与昵称。" }]} />}
|
||||
/>;
|
||||
}
|
||||
@@ -104,6 +104,7 @@ export function AdminApp({ children }: { children: ReactNode }) {
|
||||
{ name: "administrators", list: "/admin/administrators", meta: { label: "管理员", icon: <SafetyCertificateOutlined /> } },
|
||||
{ name: "roles", list: "/admin/roles", meta: { label: "角色权限", icon: <TeamOutlined /> } },
|
||||
{ name: "customers", list: "/admin/customers", meta: { label: "用户资料", icon: <UserOutlined /> } },
|
||||
{ name: "account-deletions", list: "/admin/account-deletions", meta: { label: "注销申请", icon: <UserOutlined /> } },
|
||||
{ name: "products", list: "/admin/products", meta: { label: "商品权益", icon: <ShoppingOutlined /> } },
|
||||
{ name: "subscriptions", list: "/admin/subscriptions", meta: { label: "订阅", icon: <CreditCardOutlined /> } },
|
||||
{ name: "orders", list: "/admin/orders", meta: { label: "订单", icon: <DatabaseOutlined /> } },
|
||||
|
||||
Reference in New Issue
Block a user