feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { AccountDeletionRequestRecord } from "@/db/repository";
|
||||
|
||||
type SubmitState = { loading: boolean; message: string; error: boolean };
|
||||
const idle: SubmitState = { loading: false, message: "", error: false };
|
||||
|
||||
async function postDeletion(payload: Record<string, unknown>) {
|
||||
const response = await fetch("/api/account/deletion", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
}),
|
||||
});
|
||||
const body = (await response.json()) as { message?: string };
|
||||
if (!response.ok) throw new Error(body.message || "操作失败");
|
||||
}
|
||||
|
||||
function formatRequestDate(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function AccountDeletionAction({
|
||||
requests,
|
||||
}: {
|
||||
requests: AccountDeletionRequestRecord[];
|
||||
}) {
|
||||
const active = requests.find((request) => request.status === "requested");
|
||||
const processing = requests.find((request) =>
|
||||
request.status === "processing" || request.status === "relay_purged"
|
||||
);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [state, setState] = useState(idle);
|
||||
|
||||
async function submitRequest(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setState({ loading: true, message: "", error: false });
|
||||
try {
|
||||
await postDeletion({ action: "request", confirmed, reason });
|
||||
setState({ loading: false, message: "注销申请已记录。", error: false });
|
||||
window.setTimeout(() => window.location.reload(), 700);
|
||||
} catch (error) {
|
||||
setState({
|
||||
loading: false,
|
||||
message: error instanceof Error ? error.message : "操作失败",
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRequest() {
|
||||
if (!active) return;
|
||||
setState({ loading: true, message: "", error: false });
|
||||
try {
|
||||
await postDeletion({ action: "cancel", requestId: active.id });
|
||||
setState({ loading: false, message: "注销申请已撤回。", error: false });
|
||||
window.setTimeout(() => window.location.reload(), 700);
|
||||
} catch (error) {
|
||||
setState({
|
||||
loading: false,
|
||||
message: error instanceof Error ? error.message : "操作失败",
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (active) {
|
||||
return (
|
||||
<div className="deletion-request-state">
|
||||
<span>待人工核对</span>
|
||||
<small>{formatRequestDate(active.requested_at)}</small>
|
||||
{active.reason && <p>{active.reason}</p>}
|
||||
<button
|
||||
className="button button-secondary"
|
||||
type="button"
|
||||
onClick={cancelRequest}
|
||||
disabled={state.loading}
|
||||
>
|
||||
{state.loading ? "正在撤回…" : "撤回注销申请"}
|
||||
</button>
|
||||
{state.message && (
|
||||
<p className={state.error ? "form-error" : "form-success"} role="status">
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (processing) {
|
||||
return (
|
||||
<div className="deletion-request-state">
|
||||
<span>{processing.status === "relay_purged" ? "Relay 数据已逻辑删除" : "正在永久删除 Relay 数据"}</span>
|
||||
<small>{formatRequestDate(processing.requested_at)}</small>
|
||||
<p>
|
||||
{processing.status === "relay_purged"
|
||||
? "实时 Relay 数据、附件与备份已删除;账户身份和依法保留记录仍按最终退出政策处理。"
|
||||
: "访问已经暂停,删除期间不能撤回申请。"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="deletion-request-form" onSubmit={submitRequest}>
|
||||
<label>
|
||||
<span>补充说明(可选)</span>
|
||||
<textarea
|
||||
maxLength={500}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="例如:暂时不再参加公测"
|
||||
/>
|
||||
</label>
|
||||
<label className="deletion-confirmation">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(event) => setConfirmed(event.target.checked)}
|
||||
required
|
||||
/>
|
||||
<span>我知道申请不会立即删除主机上的原生会话,并可在处理前撤回。</span>
|
||||
</label>
|
||||
<button
|
||||
className="button button-secondary"
|
||||
type="submit"
|
||||
disabled={state.loading || !confirmed}
|
||||
>
|
||||
{state.loading ? "正在提交…" : "提交注销申请"}
|
||||
</button>
|
||||
{state.message && (
|
||||
<p className={state.error ? "form-error" : "form-success"} role="status">
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Link from "next/link";
|
||||
import { requireCloudViewer } from "../../cloud-auth";
|
||||
import { DashboardShell, PageHeading, StatusPill } from "../../components/Shells";
|
||||
import {
|
||||
getDashboardSnapshot,
|
||||
getOrCreateAccount,
|
||||
listAccountDeletionRequests,
|
||||
} from "@/db/repository";
|
||||
import { AccountDeletionAction } from "./AccountLifecycleActions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SecurityPage() {
|
||||
const viewer = await requireCloudViewer("/dashboard/security");
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const [snapshot, deletionRequests] = await Promise.all([
|
||||
getDashboardSnapshot(account),
|
||||
listAccountDeletionRequests(account.id),
|
||||
]);
|
||||
return (
|
||||
<DashboardShell viewer={viewer} active="/dashboard/security" serviceStatus={snapshot.serviceStatus}>
|
||||
<div className="cloud-page">
|
||||
<PageHeading eyebrow="SECURITY / 安全与设备" title="把身份、设备和内容边界分开。" description="用户、手机、主机和管理员使用不同身份;控制平面不把登录成功当作跨租户授权。" />
|
||||
<div className="security-grid">
|
||||
<section className="panel security-card"><span className="card-index">01</span><StatusPill tone="good">当前身份</StatusPill><h2>{viewer.email}</h2><p>{viewer.isLocalDemo ? "本地开发演示身份;部署后不会存在。" : "由 Sites 的 ChatGPT 登录识别;正式公共身份提供商仍需上线前确认。"}</p></section>
|
||||
<section className="panel security-card"><span className="card-index">02</span><StatusPill tone={snapshot.connection.state === "ready" ? "good" : "warn"}>租户状态</StatusPill><h2>{snapshot.tenant?.slug}</h2><p>共享 Relay 状态为 {snapshot.connection.state},授权 revision 为 {snapshot.connection.authorizationRevision}。账号登录不会自动创建任何 phone → device grant。</p></section>
|
||||
<section className="panel security-card"><span className="card-index">03</span><StatusPill tone="danger">证据待补</StatusPill><h2>sealed attachments</h2><p>命令与附件必须在真实中继、重试、日志和备份上完成端到端验证后,才会显示“已密封”。</p></section>
|
||||
</div>
|
||||
<section className="panel full-panel"><div className="panel-heading"><div><span>账户动作</span><h2>必须保留的安全出口</h2></div></div><div className="safety-actions"><article><strong>撤销主机令牌</strong><p>已可从主机列表撤销;令牌立即失效并释放槽位,不受未来计费状态阻止。</p><Link className="button button-secondary" href="/dashboard/hosts">管理主机</Link></article><article><strong>导出账户数据</strong><p>下载 Cloud 控制平面保存的账户、主机、权益、运行态和反馈。原生会话仍需从本地主机导出。</p><a className="button button-secondary" href="/api/account/export" download>下载 JSON 导出</a></article><article className="account-lifecycle-card"><strong>注销与删除请求</strong><p>先记录可撤回申请;租户卷、备份和法定保留例外仍需人工核对,未核对前不会伪装成已经删除。</p><AccountDeletionAction requests={deletionRequests} /></article></div></section>
|
||||
<div className="inline-callout"><div><strong>想先理解为什么不写“零知识”?</strong><p>托管 PWA 本身是可变代码,这也是信任模型的一部分。</p></div><Link className="button button-secondary" href="/trust">查看完整边界</Link></div>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user