feat: establish NekoNest Cloud control and relay

This commit is contained in:
2026-08-12 23:25:43 +08:00
commit f27606b709
222 changed files with 71456 additions and 0 deletions
@@ -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>
);
}
+34
View File
@@ -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>
);
}