feat: implement TASK-WP1-05 account deletion

This commit is contained in:
suyx
2026-07-28 19:07:11 +08:00
parent 03f1509de7
commit 2358f97e5c
20 changed files with 2565 additions and 13 deletions
+273
View File
@@ -0,0 +1,273 @@
import { type FormEvent, useEffect, useId, useState } from "react";
import "./account-settings.css";
interface SettingsPayload {
account: { email: string; status: "active" };
csrf_token: string;
local_data: {
backup_enabled: false;
capacity_status: "normal" | "warning" | "critical" | "full" | "unavailable";
hard_limit_bytes: number;
location: "configured_local_data_root";
managed_content_bytes: number;
migration_supported: false;
};
profile: { creator_name: string; social_id: string };
}
function formatBytes(bytes: number) {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${Math.max(0, bytes)} B`;
}
function capacityLabel(status: SettingsPayload["local_data"]["capacity_status"]) {
if (status === "full") return "本机容量已满";
if (status === "unavailable") return "本机数据暂不可写";
if (status === "critical") return "本机容量接近上限";
if (status === "warning") return "本机容量需要关注";
return "本机容量正常";
}
export function AccountSettingsPage() {
const creatorNameId = useId();
const socialId = useId();
const confirmationId = useId();
const deletionCodeId = useId();
const [settings, setSettings] = useState<SettingsPayload>();
const [creatorName, setCreatorName] = useState("");
const [socialHandle, setSocialHandle] = useState("");
const [loadingError, setLoadingError] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(false);
const [saved, setSaved] = useState(false);
const [deletionOpen, setDeletionOpen] = useState(false);
const [deletionId, setDeletionId] = useState<string>();
const [deletionCode, setDeletionCode] = useState("");
const [confirmation, setConfirmation] = useState("");
const [sendingCode, setSendingCode] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deletionError, setDeletionError] = useState(false);
useEffect(() => {
let active = true;
void fetch("/api/v1/account/settings", { credentials: "same-origin" })
.then(async (response) => {
if (!response.ok) throw new Error("settings_load_failed");
return response.json() as Promise<SettingsPayload>;
})
.then((payload) => {
if (!active) return;
setSettings(payload);
setCreatorName(payload.profile.creator_name);
setSocialHandle(payload.profile.social_id);
})
.catch(() => {
if (active) setLoadingError(true);
});
return () => { active = false; };
}, []);
async function saveProfile(event: FormEvent) {
event.preventDefault();
if (!settings || saving || !creatorName.trim() || !socialHandle.trim()) return;
setSaving(true);
setSaveError(false);
setSaved(false);
try {
const response = await fetch("/api/v1/account/settings/profile", {
body: JSON.stringify({ creator_name: creatorName, social_id: socialHandle }),
credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-CSRF-Token": settings.csrf_token },
method: "PUT",
});
if (!response.ok) throw new Error("profile_save_failed");
setSaved(true);
} catch {
setSaveError(true);
} finally {
setSaving(false);
}
}
async function sendDeletionCode() {
if (!settings || sendingCode || deleting) return;
setSendingCode(true);
setDeletionError(false);
try {
const response = await fetch("/api/v1/account/deletion/send", {
credentials: "same-origin",
headers: { "X-CSRF-Token": settings.csrf_token },
method: "POST",
});
const body = await response.json() as { deletion_id?: string };
if (!response.ok || !body.deletion_id) throw new Error("deletion_code_failed");
setDeletionId(body.deletion_id);
} catch {
setDeletionError(true);
} finally {
setSendingCode(false);
}
}
async function completeDeletion(event: FormEvent) {
event.preventDefault();
if (!settings || !deletionId || deletionCode.length !== 6 || confirmation !== "注销账号" || deleting) return;
setDeleting(true);
setDeletionError(false);
try {
const response = await fetch("/api/v1/account/deletion/complete", {
body: JSON.stringify({ confirmation, deletion_id: deletionId, verification_code: deletionCode }),
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""),
"X-CSRF-Token": settings.csrf_token,
},
method: "POST",
});
if (!response.ok) throw new Error("account_deletion_failed");
window.location.assign("/");
} catch {
setDeletionError(true);
setDeleting(false);
}
}
function closeDeletion() {
if (deleting) return;
setDeletionOpen(false);
setDeletionId(undefined);
setDeletionCode("");
setConfirmation("");
setDeletionError(false);
}
if (!settings && !loadingError) {
return <main className="settings-loading" aria-live="polite"></main>;
}
if (!settings) {
return (
<main className="settings-loading">
<p role="alert"></p>
<button onClick={() => window.location.reload()} type="button"></button>
</main>
);
}
const capacityPercent = Math.min(100, Math.round((settings.local_data.managed_content_bytes / settings.local_data.hard_limit_bytes) * 100));
return (
<main className="settings-page">
<header className="settings-header">
<a className="settings-brand" href="/app">DADA</a>
<nav aria-label="账号导航">
<a href="/app"></a>
<span aria-current="page"></span>
</nav>
</header>
<div className="settings-title">
<p>ACCOUNT / LOCAL DATA</p>
<h1></h1>
</div>
<section className="settings-section" aria-labelledby="profile-title">
<div className="settings-section-heading">
<h2 id="profile-title"></h2>
<p></p>
</div>
<form className="settings-form" onSubmit={saveProfile}>
<label htmlFor={creatorNameId}></label>
<input id={creatorNameId} maxLength={80} onChange={(event) => setCreatorName(event.target.value)} value={creatorName} />
<label htmlFor={socialId}> ID</label>
<input id={socialId} maxLength={80} onChange={(event) => setSocialHandle(event.target.value)} value={socialHandle} />
{saveError ? <p className="settings-error" role="alert"></p> : null}
{saved ? <p className="settings-saved" role="status"></p> : null}
<button className="settings-primary" disabled={saving || !creatorName.trim() || !socialHandle.trim()} type="submit">
{saving ? "保存中" : "保存资料"}
</button>
</form>
</section>
<section className="settings-section" aria-labelledby="account-title">
<div className="settings-section-heading">
<h2 id="account-title"></h2>
<p></p>
</div>
<dl className="settings-definition">
<div><dt></dt><dd>{settings.account.email}</dd></div>
<div><dt></dt><dd></dd></div>
<div><dt></dt><dd></dd></div>
</dl>
</section>
<section className="settings-section settings-local" aria-labelledby="local-title">
<div className="settings-section-heading">
<h2 id="local-title"></h2>
<p>Dada </p>
</div>
<div className="settings-capacity" data-status={settings.local_data.capacity_status}>
<div>
<strong>{capacityLabel(settings.local_data.capacity_status)}</strong>
<span>{formatBytes(settings.local_data.managed_content_bytes)} / {formatBytes(settings.local_data.hard_limit_bytes)}</span>
</div>
<div className="settings-capacity-track" role="progressbar" aria-label="本机数据容量" aria-valuemax={100} aria-valuemin={0} aria-valuenow={capacityPercent}>
<span style={{ width: `${capacityPercent}%` }} />
</div>
</div>
<p className="settings-fixed-notice"></p>
<p className="settings-detail"> Windows Dada </p>
</section>
<section className="settings-danger" aria-labelledby="danger-title">
<div>
<h2 id="danger-title"></h2>
<p>使使</p>
</div>
<button className="settings-danger-button" onClick={() => setDeletionOpen(true)} type="button"></button>
</section>
{deletionOpen ? (
<div className="settings-dialog-backdrop">
<section
aria-labelledby="delete-dialog-title"
aria-modal="true"
className="settings-dialog"
onKeyDown={(event) => {
if (event.key === "Escape" && !deleting) closeDeletion();
}}
role="dialog"
>
<header>
<div>
<p>DANGER ZONE</p>
<h2 id="delete-dialog-title"></h2>
</div>
<button aria-label="关闭注销确认" autoFocus disabled={deleting} onClick={closeDeletion} type="button">×</button>
</header>
<div className="settings-dialog-body">
<p className="settings-dialog-warning">使</p>
<p> 180 </p>
<button className="settings-code-button" disabled={sendingCode || deleting || Boolean(deletionId)} onClick={sendDeletionCode} type="button">
{sendingCode ? "发送中" : deletionId ? "验证码已发送" : "获取注销验证码"}
</button>
<form onSubmit={completeDeletion}>
<label htmlFor={deletionCodeId}></label>
<input disabled={deleting || !deletionId} id={deletionCodeId} inputMode="numeric" maxLength={6} onChange={(event) => setDeletionCode(event.target.value.replace(/\D/g, ""))} value={deletionCode} />
<label htmlFor={confirmationId}></label>
<input disabled={deleting} id={confirmationId} onChange={(event) => setConfirmation(event.target.value)} placeholder="输入:注销账号" value={confirmation} />
{deletionError ? <p className="settings-error" role="alert"></p> : null}
<button className="settings-delete-confirm" disabled={!deletionId || deletionCode.length !== 6 || confirmation !== "注销账号" || deleting} type="submit">
{deleting ? "注销处理中" : "永久注销"}
</button>
</form>
</div>
</section>
</div>
) : null}
</main>
);
}