Files
Jyotisha/frontend/src/components/account-deletion-section.tsx
T
Jesse_ChenandClaude Opus 5.5 96adbce3a9 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
2026-09-30 09:42:11 +08:00

96 lines
4.0 KiB
TypeScript

"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>
);
}