feat: implement TASK-WP1-05 account deletion
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
.settings-page,
|
||||
.settings-loading {
|
||||
min-height: 100vh;
|
||||
color: #111111;
|
||||
background: #f6f6f4;
|
||||
}
|
||||
|
||||
.settings-loading {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-loading button {
|
||||
min-height: 44px;
|
||||
border: 1px solid #111111;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
min-height: 72px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 5vw;
|
||||
border-bottom: 1px solid #c8c8c3;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.settings-brand {
|
||||
color: #111111;
|
||||
font-family: Arial Black, "Segoe UI", sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.settings-header nav {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.settings-header nav a,
|
||||
.settings-header nav span {
|
||||
color: #111111;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.settings-header nav span {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
padding: 64px max(5vw, calc((100vw - 1120px) / 2)) 38px;
|
||||
border-bottom: 1px solid #c8c8c3;
|
||||
}
|
||||
|
||||
.settings-title p,
|
||||
.settings-dialog header p {
|
||||
margin: 0 0 12px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-title h1 {
|
||||
margin: 0;
|
||||
font-size: 40px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.settings-section,
|
||||
.settings-danger {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.8fr) minmax(0, 1.4fr);
|
||||
gap: 64px;
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 46px 0;
|
||||
border-bottom: 1px solid #c8c8c3;
|
||||
}
|
||||
|
||||
.settings-section-heading h2,
|
||||
.settings-danger h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.settings-section-heading p,
|
||||
.settings-danger p,
|
||||
.settings-detail {
|
||||
margin: 0;
|
||||
color: #585852;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.settings-form input,
|
||||
.settings-dialog input {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #989891;
|
||||
border-radius: 2px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.settings-form label,
|
||||
.settings-dialog label {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-primary,
|
||||
.settings-code-button,
|
||||
.settings-delete-confirm,
|
||||
.settings-danger-button {
|
||||
min-height: 44px;
|
||||
border-radius: 2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-primary {
|
||||
width: 160px;
|
||||
margin-top: 12px;
|
||||
border: 1px solid #111111;
|
||||
color: #111111;
|
||||
background: #f2f500;
|
||||
}
|
||||
|
||||
.settings-primary:disabled,
|
||||
.settings-code-button:disabled,
|
||||
.settings-delete-confirm:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.settings-definition {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-definition div {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 18px;
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid #deded9;
|
||||
}
|
||||
|
||||
.settings-definition dt {
|
||||
color: #686862;
|
||||
}
|
||||
|
||||
.settings-definition dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-local {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.settings-local > :not(.settings-section-heading) {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.settings-capacity {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-capacity > div:first-child {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.settings-capacity-track {
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #111111;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.settings-capacity-track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #111111;
|
||||
}
|
||||
|
||||
.settings-capacity[data-status="warning"] .settings-capacity-track span,
|
||||
.settings-capacity[data-status="critical"] .settings-capacity-track span {
|
||||
background: #e2b500;
|
||||
}
|
||||
|
||||
.settings-capacity[data-status="full"] .settings-capacity-track span,
|
||||
.settings-capacity[data-status="unavailable"] .settings-capacity-track span {
|
||||
background: #c7352b;
|
||||
}
|
||||
|
||||
.settings-fixed-notice {
|
||||
margin: 24px 0 0;
|
||||
padding: 20px;
|
||||
border-left: 5px solid #111111;
|
||||
background: #f2f500;
|
||||
font-weight: 800;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settings-detail {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.settings-danger {
|
||||
align-items: center;
|
||||
border-bottom: 0;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.settings-danger-button {
|
||||
width: 160px;
|
||||
justify-self: start;
|
||||
border: 1px solid #a6251d;
|
||||
color: #ffffff;
|
||||
background: #b82f26;
|
||||
}
|
||||
|
||||
.settings-error {
|
||||
margin: 8px 0 0;
|
||||
color: #a6251d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-saved {
|
||||
margin: 8px 0 0;
|
||||
color: #256227;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-dialog-backdrop {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgb(17 17 17 / 62%);
|
||||
}
|
||||
|
||||
.settings-dialog {
|
||||
width: min(640px, 100%);
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
border: 1px solid #111111;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
box-shadow: 10px 10px 0 #111111;
|
||||
}
|
||||
|
||||
.settings-dialog header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px;
|
||||
border-bottom: 1px solid #c8c8c3;
|
||||
}
|
||||
|
||||
.settings-dialog h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.settings-dialog header button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.settings-dialog-body {
|
||||
padding: 26px 28px 30px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.settings-dialog-warning {
|
||||
padding: 16px;
|
||||
border-left: 5px solid #b82f26;
|
||||
background: #fff2f0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-code-button {
|
||||
width: 180px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #111111;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.settings-dialog form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-delete-confirm {
|
||||
width: 180px;
|
||||
margin-top: 16px;
|
||||
border: 1px solid #8e2019;
|
||||
color: #ffffff;
|
||||
background: #b82f26;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.settings-header {
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
.settings-header nav {
|
||||
gap: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
padding: 40px 20px 28px;
|
||||
}
|
||||
|
||||
.settings-title h1 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.settings-section,
|
||||
.settings-danger {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
margin: 0 20px;
|
||||
padding: 34px 0;
|
||||
}
|
||||
|
||||
.settings-local > :not(.settings-section-heading) {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.settings-definition div {
|
||||
grid-template-columns: 88px 1fr;
|
||||
}
|
||||
|
||||
.settings-capacity > div:first-child {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-dialog {
|
||||
box-shadow: 5px 5px 0 #111111;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||
|
||||
import type { AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AdminSessionResponse, UserSessionResponse, LogoutResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js";
|
||||
import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, UserSessionResponse, LogoutResponse, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
|
||||
|
||||
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
||||
|
||||
@@ -45,6 +45,15 @@ export async function checkBrowserSupport(body: {
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function completeAccountDeletion(body: AccountDeletionCompleteRequest, options: ClientOptions = {}): Promise<AccountDeletionResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/deletion/complete`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<AccountDeletionResponse>;
|
||||
}
|
||||
|
||||
export async function completeAdminLogin(body: AdminLoginCompleteRequest, options: ClientOptions = {}): Promise<AdminLoginCompleteResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
@@ -72,6 +81,13 @@ export async function completeRegistration(body: RegistrationCompleteRequest, op
|
||||
return response.json() as Promise<RegistrationCompleteResponse>;
|
||||
}
|
||||
|
||||
export async function getAccountSettings(options: ClientOptions = {}): Promise<AccountSettingsResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/settings`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<AccountSettingsResponse>;
|
||||
}
|
||||
|
||||
export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} });
|
||||
@@ -136,6 +152,13 @@ export async function logoutUser(options: ClientOptions = {}): Promise<LogoutRes
|
||||
return response.json() as Promise<LogoutResponse>;
|
||||
}
|
||||
|
||||
export async function sendAccountDeletionCode(options: ClientOptions = {}): Promise<AccountDeletionSendResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/deletion/send`, { method: "POST", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<AccountDeletionSendResponse>;
|
||||
}
|
||||
|
||||
export async function sendAdminLoginCode(body: AdminLoginSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
@@ -162,3 +185,12 @@ export async function sendRegistrationCode(body: RegistrationSendRequest, option
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<RegistrationSendResponse>;
|
||||
}
|
||||
|
||||
export async function updateAccountProfile(body: AccountProfileUpdateRequest, options: ClientOptions = {}): Promise<AccountProfileUpdateResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/settings/profile`, { body: JSON.stringify(body), method: "PUT", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<AccountProfileUpdateResponse>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||
|
||||
export type AccountDeletionCompleteRequest = {
|
||||
"confirmation": "注销账号";
|
||||
"deletion_id": string;
|
||||
"verification_code": string;
|
||||
};
|
||||
|
||||
export type AccountDeletionResponse = {
|
||||
"status": "deleted";
|
||||
};
|
||||
|
||||
export type AccountDeletionSendResponse = {
|
||||
"challenge_expires_at": string;
|
||||
"deletion_id": string;
|
||||
"resend_available_at": string;
|
||||
"status": "verification_sent";
|
||||
};
|
||||
|
||||
export type AccountProfileUpdateRequest = {
|
||||
"creator_name": string;
|
||||
"social_id": string;
|
||||
};
|
||||
|
||||
export type AccountProfileUpdateResponse = {
|
||||
"profile": {
|
||||
"creator_name": string;
|
||||
"social_id": string;
|
||||
};
|
||||
"status": "saved";
|
||||
};
|
||||
|
||||
export type AccountSettingsResponse = {
|
||||
"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;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminAuthenticatedUser = {
|
||||
"role": "super_admin";
|
||||
"status": "active";
|
||||
@@ -93,6 +143,10 @@ export type CreditSummary = {
|
||||
"reserved_balance": number;
|
||||
};
|
||||
|
||||
export type CsrfHeaders = {
|
||||
"x-csrf-token": string;
|
||||
};
|
||||
|
||||
export type ErrorDetails = {
|
||||
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||
"current_task_ref"?: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
|
||||
import { AdminAuthPage } from "./admin-auth.js";
|
||||
import { UserAuthPage } from "./user-auth.js";
|
||||
import { AccountSettingsPage } from "./account-settings.js";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
@@ -19,9 +20,11 @@ let authRevision = 0;
|
||||
|
||||
function renderAuthenticationEntry() {
|
||||
authRevision += 1;
|
||||
const authenticationPage = window.location.pathname.startsWith("/admin")
|
||||
? <AdminAuthPage key={authRevision} />
|
||||
: <UserAuthPage key={authRevision} />;
|
||||
const authenticationPage = window.location.pathname === "/app/settings"
|
||||
? <AccountSettingsPage key={authRevision} />
|
||||
: window.location.pathname.startsWith("/admin")
|
||||
? <AdminAuthPage key={authRevision} />
|
||||
: <UserAuthPage key={authRevision} />;
|
||||
appRoot.render(
|
||||
<StrictMode>
|
||||
{authenticationPage}
|
||||
|
||||
Reference in New Issue
Block a user