feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function OpenPwaButton() {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function openPwa() {
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/pwa/handoff", { method: "POST" });
|
||||
const payload = await response.json() as {
|
||||
pwa_url?: string;
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok || !payload.pwa_url) {
|
||||
throw new Error(payload.message || "暂时无法打开手机端");
|
||||
}
|
||||
window.location.assign(payload.pwa_url);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "暂时无法打开手机端");
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<button className="button button-primary" type="button" disabled={pending} onClick={openPwa}>
|
||||
{pending ? "正在创建安全入口…" : "打开 NekoNest PWA"}
|
||||
</button>
|
||||
{error && <small className="form-error" role="alert">{error}</small>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { BetaAccessRequestRecord } from "@/db/repository";
|
||||
|
||||
type SubmitState = { loading: boolean; message: string; error: boolean };
|
||||
|
||||
export function AccessRequestForm({
|
||||
pendingRequest,
|
||||
}: {
|
||||
pendingRequest: BetaAccessRequestRecord | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pendingKey = useRef<string | null>(null);
|
||||
const [preferredOs, setPreferredOs] = useState("windows");
|
||||
const [requestedSlots, setRequestedSlots] = useState(1);
|
||||
const [useCase, setUseCase] = useState("");
|
||||
const [state, setState] = useState<SubmitState>({ loading: false, message: "", error: false });
|
||||
|
||||
async function post(payload: Record<string, unknown>) {
|
||||
pendingKey.current ??= crypto.randomUUID();
|
||||
const response = await fetch("/api/beta-access", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...payload, idempotencyKey: pendingKey.current }),
|
||||
});
|
||||
const body = (await response.json()) as { message?: string };
|
||||
if (!response.ok) throw new Error(body.message || "操作失败");
|
||||
pendingKey.current = null;
|
||||
}
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setState({ loading: true, message: "", error: false });
|
||||
try {
|
||||
await post({ action: "request", preferredOs, requestedSlots, useCase });
|
||||
setState({ loading: false, message: "申请已提交,处理结果会显示在本页。", error: false });
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setState({ loading: false, message: error instanceof Error ? error.message : "提交失败", error: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (!pendingRequest) return;
|
||||
setState({ loading: true, message: "", error: false });
|
||||
try {
|
||||
await post({ action: "cancel", requestId: pendingRequest.id });
|
||||
setState({ loading: false, message: "申请已撤回。", error: false });
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setState({ loading: false, message: error instanceof Error ? error.message : "撤回失败", error: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRequest) {
|
||||
return (
|
||||
<div className="cloud-form feedback-form">
|
||||
<p>申请正在等待人工处理。测试资格没有承诺顺序,也不会因此建立付费关系。</p>
|
||||
<button className="button button-secondary" type="button" onClick={cancel} disabled={state.loading}>
|
||||
{state.loading ? "正在撤回…" : "撤回这条申请"}
|
||||
</button>
|
||||
{state.message && <p className={state.error ? "form-error" : "form-success"} role="status">{state.message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="cloud-form feedback-form" onSubmit={submit}>
|
||||
<div className="admin-form-grid">
|
||||
<label>
|
||||
<span>计划使用的主机</span>
|
||||
<select value={preferredOs} onChange={(event) => setPreferredOs(event.target.value)}>
|
||||
<option value="windows">Windows</option>
|
||||
<option value="linux">Linux</option>
|
||||
<option value="both">Windows 和 Linux</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>希望接入几台</span>
|
||||
<input type="number" min={1} max={3} value={requestedSlots} onChange={(event) => setRequestedSlots(Number(event.target.value))} required />
|
||||
<small>首批申请限 1–3 台,实际名额以审核结果为准。</small>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>你准备怎么使用 NekoNest?</span>
|
||||
<textarea minLength={20} maxLength={1000} value={useCase} onChange={(event) => setUseCase(event.target.value)} placeholder="例如:我主要在 Windows 主机上使用 Codex,希望从手机查看并继续已有任务。请勿粘贴令牌、密码、项目代码或私密会话内容。" required />
|
||||
<small>{useCase.length}/1000 · 只需说明设备和使用场景,不要提交任何密钥或会话正文</small>
|
||||
</label>
|
||||
<button className="button button-primary" type="submit" disabled={state.loading}>
|
||||
{state.loading ? "正在提交…" : "申请免费闭测资格"}
|
||||
</button>
|
||||
{state.message && <p className={state.error ? "form-error" : "form-success"} role="status">{state.message}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { requireCloudViewer } from "../../cloud-auth";
|
||||
import { DashboardShell, PageHeading, StatusPill, formatDate } from "../../components/Shells";
|
||||
import { getDashboardSnapshot, getOrCreateAccount } from "@/db/repository";
|
||||
import { getBillingEntitlementPresentation } from "@/db/domain";
|
||||
import { deriveInvitationDisplayState } from "@/db/invitations";
|
||||
import { AccessRequestForm } from "./AccessRequestForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BillingPage() {
|
||||
const viewer = await requireCloudViewer("/dashboard/billing");
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const snapshot = await getDashboardSnapshot(account);
|
||||
const billingCopy = getBillingEntitlementPresentation(
|
||||
snapshot.entitlement.mode,
|
||||
snapshot.entitlement.publicBetaState,
|
||||
);
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
const invitations = snapshot.grants.filter((grant) => grant.source === "admin_exemption");
|
||||
const pendingAccessRequest = snapshot.accessRequests.find((request) => request.status === "requested") ?? null;
|
||||
const endingSoon = invitations.find((invitation) => {
|
||||
if (deriveInvitationDisplayState(invitation, nowIso) !== "active" || !invitation.ends_at) return false;
|
||||
const remaining = new Date(invitation.ends_at).getTime() - now.getTime();
|
||||
return remaining > 0 && remaining <= 7 * 24 * 60 * 60_000;
|
||||
});
|
||||
const available = snapshot.entitlement.unlimited
|
||||
? "当前不按槽位限额"
|
||||
: `${snapshot.entitlement.availableSlots ?? 0} 个`;
|
||||
|
||||
return (
|
||||
<DashboardShell viewer={viewer} active="/dashboard/billing">
|
||||
<div className="cloud-page">
|
||||
<PageHeading
|
||||
eyebrow="PUBLIC BETA / 公测权益"
|
||||
title={snapshot.entitlement.mode === "none" ? "这里说明当前免费资格状态。" : "当前免费,不需要处理账单。"}
|
||||
description="这里仅显示公测资格、主机占用和透明容量规则。报价、订单与支付功能暂不开放。"
|
||||
/>
|
||||
|
||||
<section className="billing-status-panel">
|
||||
<div>
|
||||
<StatusPill tone={billingCopy.tone}>{billingCopy.status}</StatusPill>
|
||||
<h2>{billingCopy.title}</h2>
|
||||
<p>{billingCopy.description}</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>启用主机</dt><dd>{snapshot.entitlement.activeSlots}</dd></div>
|
||||
<div><dt>等待配对</dt><dd>{snapshot.entitlement.reservedSlots}</dd></div>
|
||||
<div><dt>剩余容量</dt><dd>{available}</dd></div>
|
||||
<div><dt>下次资格变化</dt><dd>{formatDate(snapshot.entitlement.effectiveUntil)}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{endingSoon && (
|
||||
<section className="panel full-panel attention-panel" role="status">
|
||||
<div className="panel-heading"><div><span>CLOSED BETA NOTICE</span><h2>闭测邀请即将到期</h2></div><StatusPill tone="warn">请留意</StatusPill></div>
|
||||
<p>当前邀请将在 {formatDate(endingSoon.ends_at, true)} 到期。到期后不能新增或重新认领主机;既有主机不会被自动断开,也不会因此产生账单。</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="dashboard-columns billing-columns">
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span>当前政策</span><h2>免费公测</h2></div></div>
|
||||
<div className="billing-rules">
|
||||
<span>✓ 不绑定支付方式</span>
|
||||
<span>✓ 不生成报价或订单</span>
|
||||
<span>✓ 公测结束不自动扣款</span>
|
||||
<span>✓ 容量限制提前明示</span>
|
||||
</div>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span>未来安排</span><h2>收费以后再决定</h2></div></div>
|
||||
<p>当前先验证连接成功率、稳定性、资源成本和个人用户的真实使用频率。等数据足够,再单独设计并通知收费方案。</p>
|
||||
<div className="empty-inline">现在没有需要确认、支付或续费的项目。</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="panel full-panel">
|
||||
<div className="panel-heading">
|
||||
<div><span>CLOSED BETA / 申请测试</span><h2>申请免费闭测资格</h2></div>
|
||||
<StatusPill tone={pendingAccessRequest ? "warn" : snapshot.entitlement.mode === "none" ? "neutral" : "good"}>{pendingAccessRequest ? "等待处理" : snapshot.entitlement.mode === "none" ? "可申请" : "已有资格"}</StatusPill>
|
||||
</div>
|
||||
{snapshot.entitlement.mode !== "none" ? (
|
||||
<div className="empty-inline">当前账户已经具备免费测试资格,不需要重复申请。</div>
|
||||
) : (
|
||||
<AccessRequestForm pendingRequest={pendingAccessRequest} />
|
||||
)}
|
||||
{snapshot.accessRequests.length > 0 && (
|
||||
<div className="admin-gate-table invitation-history">
|
||||
{snapshot.accessRequests.map((request) => (
|
||||
<article key={request.id}>
|
||||
<StatusPill tone={request.status === "approved" ? "good" : request.status === "requested" ? "warn" : "neutral"}>{request.status === "approved" ? "已批准" : request.status === "requested" ? "审核中" : request.status === "declined" ? "暂未批准" : "已撤回"}</StatusPill>
|
||||
<div><strong>{request.requested_slots} 台 · {request.preferred_os === "both" ? "Windows 和 Linux" : request.preferred_os === "windows" ? "Windows" : "Linux"}</strong><small>{request.id}</small></div>
|
||||
<span>申请:{formatDate(request.requested_at, true)}</span>
|
||||
<span>{request.admin_response || "尚无处理说明"}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel full-panel">
|
||||
<div className="panel-heading"><div><span>CLOSED BETA / 我的邀请</span><h2>闭测邀请记录</h2></div><StatusPill tone={invitations.some((invitation) => deriveInvitationDisplayState(invitation, nowIso) === "active") ? "good" : "neutral"}>{invitations.filter((invitation) => deriveInvitationDisplayState(invitation, nowIso) === "active").length} 个有效</StatusPill></div>
|
||||
<p>邀请是有期限的免费接入资格,与报价、订单或支付账户无关。</p>
|
||||
<div className="admin-gate-table invitation-history">
|
||||
{invitations.length ? invitations.map((invitation) => {
|
||||
const state = deriveInvitationDisplayState(invitation, nowIso);
|
||||
return <article key={invitation.id}><StatusPill tone={state === "active" ? "good" : "neutral"}>{state === "active" ? "有效" : state === "expired" ? "已到期" : "已撤销"}</StatusPill><div><strong>{invitation.capacity_slots === null ? "当前不按主机槽位限额" : `${invitation.capacity_slots} 个主机槽位`}</strong><small>{invitation.id}</small></div><span>开始:{formatDate(invitation.starts_at, true)}</span><span>结束:{formatDate(invitation.ends_at, true)}</span></article>;
|
||||
}) : <div className="empty-inline">该账户没有闭测邀请;公开公测开放状态以页面上方为准。</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel full-panel">
|
||||
<div className="panel-heading"><div><span>边界</span><h2>公测结束会发生什么?</h2></div></div>
|
||||
<div className="billing-rules">
|
||||
<span>1. 免费资格按后台发布的政策变化</span>
|
||||
<span>2. 新配对和未完成认领停止,既有主机不自动断开</span>
|
||||
<span>3. 未主动确认前,不创建任何付费关系</span>
|
||||
<span>4. 撤销设备和必要的数据退出能力继续可用</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { TenantConnectionSummary } from "@/db/repository";
|
||||
|
||||
export type ConnectionCopy = {
|
||||
label: string;
|
||||
detail: string;
|
||||
nextStep: string;
|
||||
tone: "good" | "warn" | "danger" | "neutral" | "info";
|
||||
};
|
||||
|
||||
const connectionCopy: Record<TenantConnectionSummary["state"], ConnectionCopy> = {
|
||||
provisioning: {
|
||||
label: "正在分配中继",
|
||||
detail: "主机已认领;租户正在分配 home region 与共享 Relay 节点。",
|
||||
nextStep: "保持 daemon 的服务地址与设备令牌不变,它会在同一地址自动重试。",
|
||||
tone: "info",
|
||||
},
|
||||
ready: {
|
||||
label: "中继已就绪",
|
||||
detail: "当前 placement generation 已分配到健康的共享 Relay 节点。",
|
||||
nextStep: "daemon 继续连接注册时使用的稳定服务地址;节点位置不会暴露给客户端。",
|
||||
tone: "good",
|
||||
},
|
||||
suspended: {
|
||||
label: "租户已暂停",
|
||||
detail: "运行时或公测资格当前处于暂停状态,设备记录仍被保留。",
|
||||
nextStep: "在恢复前不要删除本地主机配置。",
|
||||
tone: "warn",
|
||||
},
|
||||
unavailable: {
|
||||
label: "状态待核实",
|
||||
detail: "控制面缺少完整 placement 或授权状态,因此不会允许建立连接。",
|
||||
nextStep: "稍后重试;持续出现时联系公测维护者。",
|
||||
tone: "warn",
|
||||
},
|
||||
};
|
||||
|
||||
export function getConnectionCopy(state: TenantConnectionSummary["state"]): ConnectionCopy {
|
||||
return connectionCopy[state];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { RouteErrorState } from "../components/RouteStates";
|
||||
|
||||
export default function DashboardError({ reset }: { reset: () => void }) {
|
||||
return (
|
||||
<RouteErrorState
|
||||
area="控制台"
|
||||
title="控制台暂时没有加载出来"
|
||||
description="账户或主机状态这次未能安全读取,因此页面没有猜测或沿用旧结果。"
|
||||
reset={reset}
|
||||
dashboard
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { FeedbackCategory } from "@/db/repository";
|
||||
|
||||
type SubmitState = { loading: boolean; message: string; error: boolean };
|
||||
|
||||
export function FeedbackForm() {
|
||||
const router = useRouter();
|
||||
const pendingKey = useRef<string | null>(null);
|
||||
const [category, setCategory] = useState<FeedbackCategory>("connection_issue");
|
||||
const [message, setMessage] = useState("");
|
||||
const [state, setState] = useState<SubmitState>({
|
||||
loading: false,
|
||||
message: "",
|
||||
error: false,
|
||||
});
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setState({ loading: true, message: "", error: false });
|
||||
pendingKey.current ??= crypto.randomUUID();
|
||||
try {
|
||||
const response = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
category,
|
||||
message,
|
||||
idempotencyKey: pendingKey.current,
|
||||
}),
|
||||
});
|
||||
const body = (await response.json()) as { message?: string };
|
||||
if (!response.ok) throw new Error(body.message || "提交失败");
|
||||
pendingKey.current = null;
|
||||
setMessage("");
|
||||
setState({
|
||||
loading: false,
|
||||
message: "已收到。处理结果会显示在本页,不需要重复提交。",
|
||||
error: false,
|
||||
});
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setState({
|
||||
loading: false,
|
||||
message: error instanceof Error ? error.message : "提交失败",
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="cloud-form feedback-form" onSubmit={submit}>
|
||||
<label>
|
||||
<span>问题类型</span>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value as FeedbackCategory)}
|
||||
>
|
||||
<option value="connection_issue">连接或接入问题</option>
|
||||
<option value="bug">功能异常</option>
|
||||
<option value="suggestion">使用建议</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>详细描述</span>
|
||||
<textarea
|
||||
minLength={10}
|
||||
maxLength={2000}
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
placeholder="请写清出现了什么、你原本想完成什么;不要粘贴令牌、密码或私密会话内容。"
|
||||
required
|
||||
/>
|
||||
<small>{message.length}/2000 · 请勿提交密钥、令牌或会话正文</small>
|
||||
</label>
|
||||
<button className="button button-primary" type="submit" disabled={state.loading}>
|
||||
{state.loading ? "正在提交…" : "提交反馈"}
|
||||
</button>
|
||||
{state.message && (
|
||||
<p className={state.error ? "form-error" : "form-success"} role="status">
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { requireCloudViewer } from "../../cloud-auth";
|
||||
import {
|
||||
DashboardShell,
|
||||
PageHeading,
|
||||
StatusPill,
|
||||
formatDate,
|
||||
} from "../../components/Shells";
|
||||
import {
|
||||
getOrCreateAccount,
|
||||
listAccountFeedback,
|
||||
type FeedbackCategory,
|
||||
} from "@/db/repository";
|
||||
import { FeedbackForm } from "./FeedbackForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const categoryLabels: Record<FeedbackCategory, string> = {
|
||||
connection_issue: "连接或接入",
|
||||
bug: "功能异常",
|
||||
suggestion: "使用建议",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
export default async function FeedbackPage() {
|
||||
const viewer = await requireCloudViewer("/dashboard/feedback");
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const feedback = await listAccountFeedback(account.id);
|
||||
|
||||
return (
|
||||
<DashboardShell viewer={viewer} active="/dashboard/feedback">
|
||||
<div className="cloud-page narrow-cloud-page">
|
||||
<PageHeading
|
||||
eyebrow="BETA FEEDBACK / 公测反馈"
|
||||
title="遇到问题,直接告诉我们。"
|
||||
description="这是免费公测期的站内反馈通道。后台回复会保存在这里;当前不承诺即时客服,但接入故障会优先处理。"
|
||||
/>
|
||||
<div className="feedback-layout">
|
||||
<section className="panel form-panel">
|
||||
<div className="panel-heading">
|
||||
<div><span>新反馈</span><h2>描述你卡住的地方</h2></div>
|
||||
</div>
|
||||
<FeedbackForm />
|
||||
</section>
|
||||
<aside className="form-aside feedback-aside">
|
||||
<span className="eyebrow">提交前</span>
|
||||
<h2>尽量让问题可以复现。</h2>
|
||||
<ol>
|
||||
<li><strong>先写目标</strong><p>你原本想完成什么操作。</p></li>
|
||||
<li><strong>再写现象</strong><p>页面或 daemon 显示了什么。</p></li>
|
||||
<li><strong>保护秘密</strong><p>不要粘贴令牌、密码、私钥或完整会话正文。</p></li>
|
||||
</ol>
|
||||
</aside>
|
||||
</div>
|
||||
<section className="panel full-panel">
|
||||
<div className="panel-heading">
|
||||
<div><span>处理记录</span><h2>我的反馈</h2></div>
|
||||
<StatusPill tone="info">{feedback.length} 条</StatusPill>
|
||||
</div>
|
||||
{feedback.length ? (
|
||||
<div className="feedback-list">
|
||||
{feedback.map((item) => (
|
||||
<article key={item.id}>
|
||||
<div className="feedback-meta">
|
||||
<StatusPill tone={item.status === "resolved" ? "good" : "warn"}>
|
||||
{item.status === "resolved" ? "已回复" : "待处理"}
|
||||
</StatusPill>
|
||||
<span>{categoryLabels[item.category]}</span>
|
||||
<time>{formatDate(item.created_at, true)}</time>
|
||||
</div>
|
||||
<p>{item.message}</p>
|
||||
{item.admin_response && (
|
||||
<div className="feedback-response">
|
||||
<strong>后台回复</strong>
|
||||
<p>{item.admin_response}</p>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-inline">还没有反馈记录。</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function HostRevokeButton({ hostId }: { hostId: string }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function revoke() {
|
||||
if (!window.confirm("撤销后 daemon 令牌立即失效并释放槽位。保留主机 identity.json 时可通过新的配对码安全恢复。确定继续?")) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/hosts/revoke", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId, reason: "用户从主机列表撤销设备凭据" }),
|
||||
});
|
||||
const body = (await response.json()) as { message?: string };
|
||||
if (!response.ok) throw new Error(body.message || "撤销失败");
|
||||
router.refresh();
|
||||
} catch (revokeError) {
|
||||
setError(revokeError instanceof Error ? revokeError.message : "撤销失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button className="button button-secondary" type="button" onClick={revoke} disabled={busy}>
|
||||
{busy ? "正在撤销…" : "撤销主机"}
|
||||
</button>
|
||||
{error && <small className="form-error" role="alert">{error}</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function PairingCancelButton({
|
||||
pairingId,
|
||||
onCancelled,
|
||||
}: {
|
||||
pairingId: string;
|
||||
onCancelled?: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function cancel() {
|
||||
if (
|
||||
!window.confirm(
|
||||
"取消后这枚配对凭证立即失效并释放占位。若 daemon 正在认领,最终状态以主机列表为准。确定取消?",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/hosts/pairing/cancel", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ pairingId }),
|
||||
});
|
||||
const body = (await response.json()) as { message?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(body.message || "取消配对请求失败");
|
||||
}
|
||||
onCancelled?.();
|
||||
router.refresh();
|
||||
} catch (cancelError) {
|
||||
setError(
|
||||
cancelError instanceof Error ? cancelError.message : "取消配对请求失败",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pairing-cancel-action">
|
||||
<button
|
||||
className="button button-secondary"
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "正在取消…" : "取消配对"}
|
||||
</button>
|
||||
{error && (
|
||||
<small className="form-error" role="alert">
|
||||
{error}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PairingCancelButton } from "../PairingCancelButton";
|
||||
import {
|
||||
buildDaemonRegistrationCommand,
|
||||
daemonStartCommand,
|
||||
type SupportedHostOS,
|
||||
} from "./onboarding";
|
||||
import type { PairingAccessState } from "../../onboarding";
|
||||
|
||||
type PairingResult = { id: string; bootstrapToken: string; expiresAt: string };
|
||||
type PairingView = PairingResult & {
|
||||
requestedName: string;
|
||||
os: SupportedHostOS;
|
||||
};
|
||||
type PairingProgress = {
|
||||
id: string;
|
||||
status: "waiting" | "claimed" | "expired" | "locked" | "cancelled";
|
||||
expiresAt: string;
|
||||
claimedHostId: string | null;
|
||||
claimedAt: string | null;
|
||||
claimAttemptState: "not_seen" | "seen" | "invalid";
|
||||
lastClaimAttemptAt: string | null;
|
||||
};
|
||||
type PairingTerminal = Exclude<PairingProgress["status"], "waiting" | "claimed">;
|
||||
type PairingCompletion = {
|
||||
hostId: string;
|
||||
claimedAt: string;
|
||||
};
|
||||
type CopyTarget = "command" | "token" | "start";
|
||||
const unavailableCopy: Record<Exclude<PairingAccessState, "available">, { title: string; detail: string; href: string; action: string; readiness?: boolean }> = {
|
||||
request_pending: {
|
||||
title: "免费闭测申请正在审核",
|
||||
detail: "处理结果会显示在公测权益页;审核期间不需要重复申请。",
|
||||
href: "/dashboard/billing",
|
||||
action: "查看申请进度",
|
||||
},
|
||||
gated: {
|
||||
title: "公开接入仍由安全门禁阻止",
|
||||
detail: "免费政策已经预设,但 P0 证据尚未齐全。可以申请小范围免费闭测资格。",
|
||||
href: "/dashboard/billing",
|
||||
action: "申请免费闭测",
|
||||
readiness: true,
|
||||
},
|
||||
inactive: {
|
||||
title: "公开公测当前未开放",
|
||||
detail: "该账户没有有效的免费公测或闭测邀请;可以提交闭测申请,不会因此创建订单或要求付款。",
|
||||
href: "/dashboard/billing",
|
||||
action: "申请免费闭测",
|
||||
},
|
||||
full: {
|
||||
title: "当前没有可用主机槽位",
|
||||
detail: "已有主机和等待中的配对请求已经占满当前免费容量,请先取消旧请求或联系管理员调整邀请。",
|
||||
href: "/dashboard/hosts",
|
||||
action: "管理主机和配对",
|
||||
},
|
||||
};
|
||||
const terminalCopy: Record<PairingTerminal, { title: string; detail: string }> = {
|
||||
expired: {
|
||||
title: "这枚配对码已经过期",
|
||||
detail: "十分钟有效期已经结束,原码不能恢复。确认 daemon 已准备好后再生成一枚新码。",
|
||||
},
|
||||
locked: {
|
||||
title: "这枚配对码已经锁定",
|
||||
detail: "错误尝试次数已达到上限,原码不能继续使用。请核对 daemon 和复制步骤后重新生成。",
|
||||
},
|
||||
cancelled: {
|
||||
title: "这枚配对码已经取消",
|
||||
detail: "原码已失效并释放预留容量;需要接入时可以重新生成。",
|
||||
},
|
||||
};
|
||||
|
||||
export function PairingForm({
|
||||
accessState,
|
||||
releaseAvailable,
|
||||
minimumDaemonVersion,
|
||||
connectOrigin,
|
||||
}: {
|
||||
accessState: PairingAccessState;
|
||||
releaseAvailable: boolean;
|
||||
minimumDaemonVersion: string;
|
||||
connectOrigin: string;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [os, setOs] = useState<SupportedHostOS>("windows");
|
||||
const [pairing, setPairing] = useState<PairingView | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState<CopyTarget | null>(null);
|
||||
const [copyError, setCopyError] = useState("");
|
||||
const [closedBetaBuildConfirmed, setClosedBetaBuildConfirmed] = useState(false);
|
||||
const [progressError, setProgressError] = useState("");
|
||||
const [completion, setCompletion] = useState<PairingCompletion | null>(null);
|
||||
const [terminal, setTerminal] = useState<PairingTerminal | null>(null);
|
||||
const [claimAttempt, setClaimAttempt] = useState<Pick<
|
||||
PairingProgress,
|
||||
"claimAttemptState" | "lastClaimAttemptAt"
|
||||
>>({ claimAttemptState: "not_seen", lastClaimAttemptAt: null });
|
||||
const resultHeading = useRef<HTMLHeadingElement>(null);
|
||||
const pairingId = pairing?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (pairing) resultHeading.current?.focus();
|
||||
}, [pairing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pairingId) return;
|
||||
let active = true;
|
||||
let timer: number | undefined;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/hosts/pairing?pairing_id=${encodeURIComponent(pairingId!)}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
const body = (await response.json()) as {
|
||||
pairing?: PairingProgress;
|
||||
message?: string;
|
||||
};
|
||||
if (!active) return;
|
||||
if (!response.ok || !body.pairing) {
|
||||
throw new Error(body.message || "暂时无法确认配对状态");
|
||||
}
|
||||
setProgressError("");
|
||||
setClaimAttempt({
|
||||
claimAttemptState: body.pairing.claimAttemptState,
|
||||
lastClaimAttemptAt: body.pairing.lastClaimAttemptAt,
|
||||
});
|
||||
if (body.pairing.status === "claimed" && body.pairing.claimedHostId && body.pairing.claimedAt) {
|
||||
setCompletion({
|
||||
hostId: body.pairing.claimedHostId,
|
||||
claimedAt: body.pairing.claimedAt,
|
||||
});
|
||||
setPairing(null);
|
||||
setCopied(null);
|
||||
setCopyError("");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
body.pairing.status === "expired"
|
||||
|| body.pairing.status === "locked"
|
||||
|| body.pairing.status === "cancelled"
|
||||
) {
|
||||
setTerminal(body.pairing.status);
|
||||
setPairing(null);
|
||||
setCopied(null);
|
||||
setCopyError("");
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(poll, 2_000);
|
||||
} catch (pollError) {
|
||||
if (!active) return;
|
||||
setProgressError(
|
||||
pollError instanceof Error
|
||||
? `${pollError.message};页面会继续重试。`
|
||||
: "暂时无法确认配对状态;页面会继续重试。",
|
||||
);
|
||||
timer = window.setTimeout(poll, 5_000);
|
||||
}
|
||||
}
|
||||
|
||||
timer = window.setTimeout(poll, 1_000);
|
||||
return () => {
|
||||
active = false;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
};
|
||||
}, [pairingId]);
|
||||
|
||||
if (accessState !== "available") {
|
||||
const copy = unavailableCopy[accessState];
|
||||
return (
|
||||
<div className="empty-state pairing-access-blocked" role="status">
|
||||
<span className="empty-symbol">×</span>
|
||||
<h2>{copy.title}</h2>
|
||||
<p>{copy.detail}</p>
|
||||
<div className="pairing-result-actions">
|
||||
<a className="button button-primary" href={copy.href}>{copy.action}</a>
|
||||
{copy.readiness && <a className="button button-secondary" href="/readiness">查看公测门禁</a>}
|
||||
<a className="button button-secondary" href="/dashboard">返回总览</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function copyText(value: string, target: CopyTarget) {
|
||||
setCopyError("");
|
||||
try {
|
||||
if (!navigator.clipboard?.writeText) throw new Error("clipboard_unavailable");
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(target);
|
||||
} catch {
|
||||
setCopied(null);
|
||||
setCopyError("浏览器未允许自动复制。请点入对应文本框,使用 Ctrl+C 或系统复制操作手动复制。");
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setTerminal(null);
|
||||
setCompletion(null);
|
||||
if (!releaseAvailable && !closedBetaBuildConfirmed) {
|
||||
setError("公开下载尚未就绪。请先取得并核验兼容的闭测构建,再确认后生成配对码。");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/hosts/pairing", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ requestedName: name, os }),
|
||||
});
|
||||
const body = (await response.json()) as { pairing?: PairingResult; message?: string };
|
||||
if (!response.ok || !body.pairing) throw new Error(body.message || "无法创建配对请求");
|
||||
setPairing({
|
||||
...body.pairing,
|
||||
requestedName: name.trim(),
|
||||
os,
|
||||
});
|
||||
setCopied(null);
|
||||
setCopyError("");
|
||||
setProgressError("");
|
||||
setClaimAttempt({ claimAttemptState: "not_seen", lastClaimAttemptAt: null });
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "无法创建配对请求");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (completion) {
|
||||
return (
|
||||
<div className="empty-state pairing-complete" role="status">
|
||||
<span className="empty-symbol">✓</span>
|
||||
<h2>主机已成功认领</h2>
|
||||
<p>一次性配对码已经从页面状态中清除。控制平面已建立可撤销设备凭据并推进授权 revision;daemon 会继续连接同一个服务地址。</p>
|
||||
<small>认领时间:{new Date(completion.claimedAt).toLocaleString("zh-CN")} · 主机编号 {completion.hostId}</small>
|
||||
<div className="pairing-result-actions">
|
||||
<a className="button button-primary" href="/dashboard/hosts">查看主机与开通状态</a>
|
||||
<button className="button button-secondary" type="button" onClick={() => setCompletion(null)}>再添加一台主机</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (terminal) {
|
||||
const copy = terminalCopy[terminal];
|
||||
return (
|
||||
<div className="empty-state pairing-terminal" role="status">
|
||||
<span className="empty-symbol">×</span>
|
||||
<h2>{copy.title}</h2>
|
||||
<p>{copy.detail}</p>
|
||||
<div className="pairing-result-actions">
|
||||
<button className="button button-primary" type="button" onClick={() => setTerminal(null)}>重新生成配对码</button>
|
||||
<a className="button button-secondary" href="/dashboard/hosts">查看主机和配对</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pairing) {
|
||||
const registrationCommand = buildDaemonRegistrationCommand({
|
||||
os: pairing.os,
|
||||
connectOrigin,
|
||||
hostName: pairing.requestedName,
|
||||
});
|
||||
const startCommand = daemonStartCommand(pairing.os);
|
||||
return (
|
||||
<div className="pairing-result">
|
||||
<span className="eyebrow">PAIRING REQUEST CREATED</span>
|
||||
<h2 ref={resultHeading} tabIndex={-1}>在 {pairing.os === "windows" ? "Windows" : "Linux"} 主机完成注册</h2>
|
||||
<p>命令已按 Cloud 稳定 Connect 服务地址和主机名生成。一次性码不会拼进命令、URL 或浏览器存储。</p>
|
||||
<div className="pairing-progress" role="status" aria-live="polite">
|
||||
<strong>{claimAttempt.claimAttemptState === "seen" ? "Cloud 已收到请求,但尚未认领" : claimAttempt.claimAttemptState === "invalid" ? "认领尝试时间待核实" : "尚未收到注册请求"}</strong>
|
||||
<span>{claimAttempt.claimAttemptState === "seen"
|
||||
? `最近一次尝试:${new Date(claimAttempt.lastClaimAttemptAt!).toLocaleString("zh-CN")}。这只证明 Cloud 收到过针对该配对 ID 的请求,不证明来源一定是你的 daemon。请先查看终端错误,并核对系统类型、配对码、daemon 版本和 identity.json;当前码有效时无需反复生成。`
|
||||
: claimAttempt.claimAttemptState === "invalid"
|
||||
? "Cloud 找到过针对这枚配对请求的记录,但时间异常,不能判断先后;请查看终端错误或提交反馈。"
|
||||
: "请先在目标主机运行注册命令并粘贴一次性码。页面每两秒确认一次,尚未收到请求通常表示命令未运行、地址不可达或请求还没发出。"}</span>
|
||||
</div>
|
||||
{progressError && <p className="form-error" role="alert">{progressError}</p>}
|
||||
<a className="text-link pairing-download-link" href="/download">还没有兼容 daemon?先查看下载与 SHA-256 校验 →</a>
|
||||
|
||||
<ol className="registration-steps">
|
||||
<li>
|
||||
<strong>复制命令,在 daemon 所在目录运行</strong>
|
||||
<label className="pairing-copy-field">
|
||||
<span>{pairing.os === "windows" ? "PowerShell" : "Bash"} 注册命令</span>
|
||||
<textarea
|
||||
readOnly
|
||||
rows={pairing.os === "windows" ? 12 : 10}
|
||||
value={registrationCommand}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
aria-describedby="registration-command-note"
|
||||
/>
|
||||
</label>
|
||||
<button className="button button-secondary" type="button" onClick={() => copyText(registrationCommand, "command")}>{copied === "command" ? "命令已复制" : "复制注册命令"}</button>
|
||||
<small id="registration-command-note">运行后终端会隐藏输入并等待你粘贴一次性码。</small>
|
||||
</li>
|
||||
<li>
|
||||
<strong>终端出现提示后,再复制并粘贴一次性码</strong>
|
||||
<label className="pairing-copy-field">
|
||||
<span>一次性配对码</span>
|
||||
<input readOnly value={pairing.bootstrapToken} onFocus={(event) => event.currentTarget.select()} autoComplete="off" spellCheck={false} />
|
||||
</label>
|
||||
<button className="button button-secondary" type="button" onClick={() => copyText(pairing.bootstrapToken, "token")}>{copied === "token" ? "配对码已复制" : "复制一次性配对码"}</button>
|
||||
<small>有效期至 {new Date(pairing.expiresAt).toLocaleString("zh-CN")};终端输入不会回显。</small>
|
||||
</li>
|
||||
<li>
|
||||
<strong>注册成功后启动 daemon</strong>
|
||||
<label className="pairing-copy-field compact">
|
||||
<span>启动命令</span>
|
||||
<input readOnly value={startCommand} onFocus={(event) => event.currentTarget.select()} />
|
||||
</label>
|
||||
<button className="button button-secondary" type="button" onClick={() => copyText(startCommand, "start")}>{copied === "start" ? "启动命令已复制" : "复制启动命令"}</button>
|
||||
<small>保持 daemon 运行;控制面认领完成后,它会在同一个稳定服务地址等待共享 Relay 就绪,不需要轮询或切换后端地址。</small>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
{copyError && <p className="form-error" role="alert">{copyError}</p>}
|
||||
{copied && !copyError && <p className="form-success" role="status">复制成功。配对码使用后请用普通文本覆盖剪贴板。</p>}
|
||||
<div className="prototype-callout"><strong>使用后立即清除</strong><span>注册命令会在结束时清除进程环境中的配对码;认领成功后服务端摘要也会立即失效。如果响应中途丢失,请在主机列表撤销记录后重新配对。不要截图或写入日志。</span></div>
|
||||
<div className="prototype-callout"><strong>恢复原记录需要最新版 daemon</strong><span>保留 identity.json、删除旧 config.json 后重新注册。恢复请求会用原 Ed25519 私钥签名;旧版 daemon 或只有公开指纹的请求会被拒绝。</span></div>
|
||||
<div className="prototype-callout"><strong>当前接入边界</strong><span>控制平面会签发独立、可撤销的设备令牌;席位只决定主机能否加入租户,不会自动授予手机读取这台主机的权限。</span></div>
|
||||
<div className="pairing-result-actions">
|
||||
<a className="button button-secondary" href="/dashboard/hosts">返回主机列表</a>
|
||||
<PairingCancelButton
|
||||
pairingId={pairing.id}
|
||||
onCancelled={() => {
|
||||
setPairing(null);
|
||||
setTerminal("cancelled");
|
||||
setCopied(null);
|
||||
setCopyError("");
|
||||
setProgressError("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="cloud-form pairing-form" onSubmit={submit}>
|
||||
{!releaseAvailable && (
|
||||
<div className="prototype-callout daemon-preflight" role="group" aria-labelledby="daemon-preflight-title">
|
||||
<strong id="daemon-preflight-title">公开 daemon 下载尚未就绪</strong>
|
||||
<span>当前不会把你送去下载不确定的“最新版”。只有已经从管理员处取得兼容构建,并核对版本与 SHA-256 的闭测参与者,才应生成十分钟配对码。</span>
|
||||
<a className="text-link" href="/download">查看最低 v{minimumDaemonVersion} 要求与下载状态 →</a>
|
||||
<label className="switch-row">
|
||||
<input type="checkbox" checked={closedBetaBuildConfirmed} onChange={(event) => setClosedBetaBuildConfirmed(event.target.checked)} />
|
||||
<span><strong>我已有经过核验的兼容闭测构建</strong><small>确认构建版本不低于 v{minimumDaemonVersion},来源和 SHA-256 已由管理员单独提供并核对。</small></span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<label><span>主机名称</span><input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:家里工作站" minLength={2} maxLength={48} required /><small>这是控制台显示名,不会成为任意路径输入。</small></label>
|
||||
<fieldset><legend>操作系统</legend><div className="choice-grid"><label className={os === "windows" ? "selected" : ""}><input type="radio" name="os" value="windows" checked={os === "windows"} onChange={() => setOs("windows")} /><strong>Windows</strong><small>amd64 · 正式支持</small></label><label className={os === "linux" ? "selected" : ""}><input type="radio" name="os" value="linux" checked={os === "linux"} onChange={() => setOs("linux")} /><strong>Linux</strong><small>amd64 / arm64 · 正式支持</small></label></div></fieldset>
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
<button className="button button-primary" type="submit" disabled={loading || (!releaseAvailable && !closedBetaBuildConfirmed)}>{loading ? "正在创建…" : "生成 10 分钟配对码"}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export type SupportedHostOS = "windows" | "linux";
|
||||
|
||||
export function assertSafeConnectOrigin(value: string): string {
|
||||
const origin = value.trim().replace(/\/$/, "");
|
||||
const parsed = new URL(origin);
|
||||
const isLoopback = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
|
||||
if (parsed.origin !== origin || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback))) {
|
||||
throw new Error("Cloud Connect 服务必须使用 HTTPS origin(本机开发地址除外)");
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
|
||||
export function resolveConnectOrigin(
|
||||
configured: string | undefined,
|
||||
development = process.env.NODE_ENV !== "production",
|
||||
): string {
|
||||
const value = configured?.trim() ?? "";
|
||||
if (value) return assertSafeConnectOrigin(value);
|
||||
if (development) return "http://127.0.0.1:3000";
|
||||
throw new Error("NEKONEST_CLOUD_CONNECT_ORIGIN is required outside development");
|
||||
}
|
||||
|
||||
export function quotePowerShell(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
export function quoteBash(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
export function buildDaemonRegistrationCommand(input: {
|
||||
os: SupportedHostOS;
|
||||
connectOrigin: string;
|
||||
hostName: string;
|
||||
}): string {
|
||||
const connectOrigin = assertSafeConnectOrigin(input.connectOrigin);
|
||||
const hostName = input.hostName.trim();
|
||||
if (!hostName) throw new Error("主机名称不能为空");
|
||||
|
||||
if (input.os === "windows") {
|
||||
return [
|
||||
`$env:NEKONEST_SERVER = ${quotePowerShell(connectOrigin)}`,
|
||||
`$env:NEKONEST_TRANSPORT_MODE = 'sealed'`,
|
||||
`$secureToken = Read-Host '粘贴一次性配对码' -AsSecureString`,
|
||||
`$tokenPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)`,
|
||||
"try {",
|
||||
` $env:NEKONEST_BOOTSTRAP_TOKEN = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tokenPtr)`,
|
||||
` if ([string]::IsNullOrWhiteSpace($env:NEKONEST_BOOTSTRAP_TOKEN)) { throw '未读取到配对码' }`,
|
||||
` & '.\\nekonest-daemon.exe' -register -name ${quotePowerShell(hostName)}`,
|
||||
"} finally {",
|
||||
" Remove-Item Env:NEKONEST_BOOTSTRAP_TOKEN -ErrorAction SilentlyContinue",
|
||||
" [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPtr)",
|
||||
"}",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
"(",
|
||||
" read -rsp '粘贴一次性配对码: ' NEKONEST_BOOTSTRAP_TOKEN",
|
||||
" printf '\\n'",
|
||||
` if [ -z "$NEKONEST_BOOTSTRAP_TOKEN" ]; then printf '未读取到配对码\\n' >&2; exit 1; fi`,
|
||||
` export NEKONEST_SERVER=${quoteBash(connectOrigin)}`,
|
||||
" export NEKONEST_TRANSPORT_MODE='sealed'",
|
||||
" export NEKONEST_BOOTSTRAP_TOKEN",
|
||||
` ./nekonest-daemon -register -name ${quoteBash(hostName)}`,
|
||||
")",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function daemonStartCommand(os: SupportedHostOS): string {
|
||||
return os === "windows" ? ".\\nekonest-daemon.exe" : "./nekonest-daemon";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { requireCloudViewer } from "../../../cloud-auth";
|
||||
import { DashboardShell, PageHeading } from "../../../components/Shells";
|
||||
import { getDashboardSnapshot, getOrCreateAccount } from "@/db/repository";
|
||||
import { getDaemonReleaseState } from "../../../daemon-release";
|
||||
import { deriveBetaOnboarding } from "../../onboarding";
|
||||
import { PairingForm } from "./PairingForm";
|
||||
import { resolveConnectOrigin } from "./onboarding";
|
||||
import { env } from "cloudflare:workers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function NewHostPage() {
|
||||
const viewer = await requireCloudViewer("/dashboard/hosts/new");
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const [snapshot, daemonRelease] = await Promise.all([
|
||||
getDashboardSnapshot(account),
|
||||
getDaemonReleaseState(),
|
||||
]);
|
||||
const onboarding = deriveBetaOnboarding({
|
||||
entitlement: snapshot.entitlement,
|
||||
hasPendingRequest: snapshot.accessRequests.some((request) => request.status === "requested"),
|
||||
});
|
||||
const connectOrigin = resolveConnectOrigin(
|
||||
env.NEKONEST_CLOUD_CONNECT_ORIGIN,
|
||||
process.env.NODE_ENV !== "production",
|
||||
);
|
||||
return (
|
||||
<DashboardShell viewer={viewer} active="/dashboard/hosts">
|
||||
<div className="cloud-page narrow-cloud-page">
|
||||
<PageHeading eyebrow="ADD OR RECOVER HOST / 添加或恢复主机" title="先创建一次性配对请求。" description="新主机和已撤销主机的安全恢复共用这一步;家里不需要打开入站端口。" actions={<Link className="button button-secondary" href="/download">先下载兼容 daemon</Link>} />
|
||||
<div className="form-layout">
|
||||
<section className="panel form-panel"><PairingForm accessState={onboarding.pairingAccessState} releaseAvailable={daemonRelease.available} minimumDaemonVersion={daemonRelease.minimumVersion} connectOrigin={connectOrigin} /></section>
|
||||
<aside className="form-aside"><span className="eyebrow">接下来会发生</span><ol><li><strong>控制平面生成短时凭证</strong><p>原始代码只显示一次,库中保存 SHA-256 哈希。</p></li><li><strong>daemon 主动认领</strong><p>daemon 提交本机身份公钥,成功后获得独立可撤销令牌。</p></li><li><strong>恢复时证明原私钥</strong><p>若该身份对应已撤销记录,最新版 daemon 必须对本次配对签名;只有公开指纹不够。</p></li><li><strong>原子占用主机槽位</strong><p>认领和槽位分配在同一事务中完成;凭证成功后立即烧毁。</p></li></ol></aside>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Link from "next/link";
|
||||
import { requireCloudViewer } from "../../cloud-auth";
|
||||
import { DashboardShell, PageHeading, StatusPill, formatDate } from "../../components/Shells";
|
||||
import { getDashboardSnapshot, getOrCreateAccount } from "@/db/repository";
|
||||
import { HostRevokeButton } from "./HostRevokeButton";
|
||||
import { PairingCancelButton } from "./PairingCancelButton";
|
||||
import { getConnectionCopy } from "../connection-copy";
|
||||
import { deriveControlPlaneContact } from "@/db/device-control-plane";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function lifecycleLabel(value: string) {
|
||||
return value === "active" ? "已启用" : value === "deactivated" ? "已撤销" : value;
|
||||
}
|
||||
|
||||
function slotLabel(value: string) {
|
||||
return value === "active" ? "占用中" : value === "released" ? "已释放" : value;
|
||||
}
|
||||
|
||||
export default async function HostsPage() {
|
||||
const viewer = await requireCloudViewer("/dashboard/hosts");
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const snapshot = await getDashboardSnapshot(account);
|
||||
const connection = getConnectionCopy(snapshot.connection.state);
|
||||
return (
|
||||
<DashboardShell viewer={viewer} active="/dashboard/hosts">
|
||||
<div className="cloud-page">
|
||||
<PageHeading
|
||||
eyebrow="HOSTS / 主机"
|
||||
title="主机与槽位"
|
||||
description="只有启用的持久主机记录占槽位。离线不释放;明确停用或撤销后才释放。"
|
||||
actions={<Link className="button button-primary" href="/dashboard/hosts/new">添加主机</Link>}
|
||||
/>
|
||||
|
||||
<div className="entitlement-bar">
|
||||
<div><span>权益来源</span><strong>{snapshot.entitlement.mode === "public_beta" ? "公开公测" : snapshot.entitlement.mode === "grant" ? "闭测邀请" : "无"}</strong></div>
|
||||
<div><span>启用</span><strong>{snapshot.entitlement.activeSlots}</strong></div>
|
||||
<div><span>配对占位</span><strong>{snapshot.entitlement.reservedSlots}</strong></div>
|
||||
<div><span>可用</span><strong>{snapshot.entitlement.unlimited ? "不按槽位限额" : snapshot.entitlement.availableSlots}</strong></div>
|
||||
</div>
|
||||
|
||||
<section className="panel full-panel">
|
||||
<div className="panel-heading"><div><span>已认领</span><h2>持久主机记录</h2></div><StatusPill tone={connection.tone}>{connection.label}</StatusPill></div>
|
||||
<p>{connection.detail} {connection.nextStep}</p>
|
||||
<p className="host-contact-note">“控制面签到”只表示设备令牌最近通过了 Cloud 授权;是否在线仍以共享 Relay 的实时连接为准。</p>
|
||||
{snapshot.hosts.length ? (
|
||||
<div className="host-list">
|
||||
{snapshot.hosts.map((host) => {
|
||||
const contact = deriveControlPlaneContact(host.control_plane_last_seen_at);
|
||||
return (
|
||||
<article className="host-row detailed-host" key={host.id}>
|
||||
<span className={`host-icon host-${host.os}`}>{host.os === "windows" ? "W" : "L"}</span>
|
||||
<div><strong>{host.name}</strong><small>{host.id}</small></div>
|
||||
<div><span className="row-label">槽位</span><strong>{slotLabel(host.slot_state)}</strong></div>
|
||||
<div>
|
||||
<span className="row-label">Daemon / 控制面</span>
|
||||
<strong>{host.daemon_version || "版本待上报"}</strong>
|
||||
<small>{host.control_plane_last_seen_at ? formatDate(host.control_plane_last_seen_at, true) : "等待首次 Relay 授权"}</small>
|
||||
</div>
|
||||
<StatusPill tone={host.lifecycle === "active" ? contact.tone : "neutral"}>{host.lifecycle === "active" ? contact.label : lifecycleLabel(host.lifecycle)}</StatusPill>
|
||||
{host.lifecycle === "active" && <HostRevokeButton hostId={host.id} />}
|
||||
{host.lifecycle === "deactivated" && <Link className="button button-secondary" href="/dashboard/hosts/new">重新配对</Link>}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state"><span className="empty-symbol">⌁</span><h3>没有真实主机记录</h3><p>这是正确的空状态。控制平面不会为示意图制造一台“在线”主机。</p><Link className="button button-primary" href="/dashboard/hosts/new">创建第一个配对请求</Link></div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel full-panel recovery-guide">
|
||||
<div className="panel-heading"><div><span>恢复与重装</span><h2>先分清身份文件还在不在</h2></div></div>
|
||||
<div className="recovery-options">
|
||||
<article><StatusPill tone="good">identity.json 还在</StatusPill><h3>恢复原主机记录</h3><p>撤销旧令牌后创建新配对码,保留 identity.json、删除旧 config.json,并使用最新版 daemon 重新注册。daemon 会签名证明仍持有原私钥;Cloud 复用原主机 ID 并签发新令牌。</p></article>
|
||||
<article><StatusPill tone="warn">身份文件已丢失</StatusPill><h3>建立新的主机记录</h3><p>先撤销旧记录,再创建新配对码。新安装会生成新身份和新主机 ID;旧记录保留为已撤销审计历史,不会静默换绑。</p></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel full-panel">
|
||||
<div className="panel-heading"><div><span>一次性</span><h2>等待认领的配对请求</h2></div></div>
|
||||
{snapshot.pairings.length ? (
|
||||
<div className="pairing-list">
|
||||
{snapshot.pairings.map((pairing) => (
|
||||
<article key={pairing.id}><div><strong>{pairing.requested_name}</strong><small>{pairing.os.toUpperCase()} · {pairing.id}</small></div><StatusPill tone="warn">等待 daemon</StatusPill><span>过期:{formatDate(pairing.expires_at, true)}</span><PairingCancelButton pairingId={pairing.id} /></article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-inline">没有等待中的配对请求。</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { RouteLoadingState } from "../components/RouteStates";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<RouteLoadingState
|
||||
area="控制台"
|
||||
description="正在核对账户、公测权益、主机和服务状态,请稍候。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
export type PairingAccessState =
|
||||
| "available"
|
||||
| "request_pending"
|
||||
| "gated"
|
||||
| "inactive"
|
||||
| "full";
|
||||
|
||||
export type BetaOnboardingState =
|
||||
| "request_needed"
|
||||
| "request_pending"
|
||||
| "ready"
|
||||
| "full";
|
||||
|
||||
export type BetaOnboarding = {
|
||||
state: BetaOnboardingState;
|
||||
pairingAccessState: PairingAccessState;
|
||||
primaryHref: string;
|
||||
primaryLabel: string;
|
||||
tone: "good" | "warn" | "neutral" | "info";
|
||||
title: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export function deriveBetaOnboarding(input: {
|
||||
entitlement: {
|
||||
mode: "public_beta" | "grant" | "none";
|
||||
publicBetaState: "open" | "gated" | "inactive";
|
||||
unlimited: boolean;
|
||||
availableSlots: number | null;
|
||||
};
|
||||
hasPendingRequest: boolean;
|
||||
}): BetaOnboarding {
|
||||
const { entitlement } = input;
|
||||
if (entitlement.mode === "none") {
|
||||
if (input.hasPendingRequest) {
|
||||
return {
|
||||
state: "request_pending",
|
||||
pairingAccessState: "request_pending",
|
||||
primaryHref: "/dashboard/billing",
|
||||
primaryLabel: "查看申请进度",
|
||||
tone: "warn",
|
||||
title: "免费闭测申请正在审核",
|
||||
detail: "处理结果会显示在公测权益页;审核期间不需要重复申请。",
|
||||
};
|
||||
}
|
||||
const gated = entitlement.publicBetaState === "gated";
|
||||
return {
|
||||
state: "request_needed",
|
||||
pairingAccessState: gated ? "gated" : "inactive",
|
||||
primaryHref: "/dashboard/billing",
|
||||
primaryLabel: "申请免费闭测",
|
||||
tone: gated ? "warn" : "neutral",
|
||||
title: gated ? "公开接入尚未开放" : "当前没有免费测试资格",
|
||||
detail: gated
|
||||
? "安全门禁仍在核对,可以先申请小范围免费闭测。"
|
||||
: "可以提交免费闭测申请;不会绑定支付方式或自动转为付费。",
|
||||
};
|
||||
}
|
||||
|
||||
if (!entitlement.unlimited && (entitlement.availableSlots ?? 0) < 1) {
|
||||
return {
|
||||
state: "full",
|
||||
pairingAccessState: "full",
|
||||
primaryHref: "/dashboard/hosts",
|
||||
primaryLabel: "管理已接入主机",
|
||||
tone: "neutral",
|
||||
title: "当前免费主机名额已用完",
|
||||
detail: "可以取消等待中的配对、撤销不再使用的主机,或等待管理员调整闭测名额。",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: "ready",
|
||||
pairingAccessState: "available",
|
||||
primaryHref: "/dashboard/hosts/new",
|
||||
primaryLabel: "连接主机",
|
||||
tone: "good",
|
||||
title: entitlement.mode === "grant" ? "闭测资格已生效" : "免费公测资格已生效",
|
||||
detail: entitlement.unlimited
|
||||
? "当前策略不按主机槽位限额,可以开始连接主机。"
|
||||
: `还可以连接 ${entitlement.availableSlots ?? 0} 台主机。`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Link from "next/link";
|
||||
import { requireCloudViewer } from "../cloud-auth";
|
||||
import {
|
||||
DashboardShell,
|
||||
PageHeading,
|
||||
StatusPill,
|
||||
formatDate,
|
||||
} from "../components/Shells";
|
||||
import { getDashboardSnapshot, getOrCreateAccount } from "@/db/repository";
|
||||
import { getConnectionCopy } from "./connection-copy";
|
||||
import { OpenPwaButton } from "./OpenPwaButton";
|
||||
import { deriveControlPlaneContact } from "@/db/device-control-plane";
|
||||
import { deriveBetaOnboarding } from "./onboarding";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const viewer = await requireCloudViewer("/dashboard");
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const snapshot = await getDashboardSnapshot(account);
|
||||
const activeHosts = snapshot.hosts.filter((host) => host.lifecycle === "active");
|
||||
const entitlement = snapshot.entitlement;
|
||||
const connection = getConnectionCopy(snapshot.connection.state);
|
||||
const onboarding = deriveBetaOnboarding({
|
||||
entitlement,
|
||||
hasPendingRequest: snapshot.accessRequests.some((request) => request.status === "requested"),
|
||||
});
|
||||
const entitlementLabel = entitlement.mode === "public_beta"
|
||||
? "公测免费"
|
||||
: entitlement.mode === "grant"
|
||||
? "闭测邀请"
|
||||
: entitlement.publicBetaState === "gated"
|
||||
? "等待安全门禁"
|
||||
: "无有效权益";
|
||||
const entitlementDetail = entitlement.mode === "none" && entitlement.publicBetaState === "gated"
|
||||
? `公开接入仍有 ${entitlement.blockedP0} 项 P0 未通过;管理员邀请账户可继续闭测`
|
||||
: entitlement.unlimited
|
||||
? "费用全免;当前策略未按槽位限额"
|
||||
: `可用 ${entitlement.availableSlots ?? 0} 个槽位`;
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
viewer={viewer}
|
||||
active="/dashboard"
|
||||
serviceStatus={snapshot.serviceStatus}
|
||||
>
|
||||
<div className="cloud-page">
|
||||
<PageHeading
|
||||
eyebrow="OVERVIEW / 总览"
|
||||
title={`晚上好,${viewer.displayName}`}
|
||||
description="这里显示控制平面真实保存的公测接入状态;原生会话仍由你的主机和 NekoNest PWA 提供。"
|
||||
actions={snapshot.connection.state === "ready"
|
||||
? <OpenPwaButton />
|
||||
: <Link className="button button-primary" href={onboarding.primaryHref}>{onboarding.primaryLabel}</Link>}
|
||||
/>
|
||||
|
||||
<section className="metric-grid" aria-label="账户概览">
|
||||
<article className="metric-card accent-card">
|
||||
<span>当前权益</span>
|
||||
<strong>{entitlementLabel}</strong>
|
||||
<small>{entitlementDetail}</small>
|
||||
</article>
|
||||
<article className="metric-card">
|
||||
<span>启用主机</span>
|
||||
<strong>{entitlement.activeSlots}</strong>
|
||||
<small>{entitlement.reservedSlots ? `${entitlement.reservedSlots} 个配对请求占位中` : "没有等待配对的占位"}</small>
|
||||
</article>
|
||||
<article className="metric-card">
|
||||
<span>租户运行态</span>
|
||||
<strong>{connection.label}</strong>
|
||||
<small>{snapshot.connection.homeRegion
|
||||
? `${snapshot.connection.homeRegion} · generation ${snapshot.connection.placementGeneration ?? "-"}`
|
||||
: "等待 home region 分配"}</small>
|
||||
</article>
|
||||
<article className="metric-card">
|
||||
<span>下次付款</span>
|
||||
<strong>无</strong>
|
||||
<small>公测不绑支付方式,也不会自动续费</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div className="dashboard-columns">
|
||||
<section className="panel">
|
||||
<div className="panel-heading">
|
||||
<div><span>主机</span><h2>接入状态</h2></div>
|
||||
<Link href="/dashboard/hosts">查看全部 →</Link>
|
||||
</div>
|
||||
{activeHosts.length ? (
|
||||
<div className="host-list">
|
||||
{activeHosts.slice(0, 4).map((host) => {
|
||||
const contact = deriveControlPlaneContact(host.control_plane_last_seen_at);
|
||||
return (
|
||||
<article className="host-row" key={host.id}>
|
||||
<span className={`host-icon host-${host.os}`}>{host.os === "windows" ? "W" : "L"}</span>
|
||||
<div><strong>{host.name}</strong><small>{host.daemon_version || "daemon 版本待上报"}</small></div>
|
||||
<div className="host-state">
|
||||
<StatusPill tone={contact.tone}>{contact.label}</StatusPill>
|
||||
<small>{host.control_plane_last_seen_at ? formatDate(host.control_plane_last_seen_at, true) : "等待首次 Relay 授权"}</small>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state compact-empty">
|
||||
<span className="empty-symbol">+</span>
|
||||
<h3>还没有已认领的主机</h3>
|
||||
<p>先创建一次性引导凭证。daemon 完成控制面认领后,会在同一个稳定服务地址等待共享 Relay 就绪。</p>
|
||||
<Link className="button button-secondary" href="/dashboard/hosts/new">创建配对请求</Link>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel attention-panel">
|
||||
<div className="panel-heading"><div><span>当前需要注意</span><h2>诚实状态</h2></div></div>
|
||||
<div className="attention-list">
|
||||
{onboarding.state !== "ready" && <article><StatusPill tone={onboarding.tone}>{onboarding.state === "request_pending" ? "审核中" : onboarding.state === "request_needed" ? "先申请" : "容量已满"}</StatusPill><div><strong>{onboarding.title}</strong><p>{onboarding.detail} <Link className="text-link" href={onboarding.primaryHref}>{onboarding.primaryLabel} →</Link></p></div></article>}
|
||||
<article><StatusPill tone={connection.tone}>{connection.label}</StatusPill><div><strong>{connection.detail}</strong><p>{connection.nextStep}</p></div></article>
|
||||
{entitlement.publicBetaState === "gated" && <article><StatusPill tone="warn">公开接入冻结</StatusPill><div><strong>P0 门禁不能被免费政策绕过</strong><p>新公开配对暂不开放;管理员明确签发的免费闭测邀请仍可继续。</p></div></article>}
|
||||
<article><StatusPill tone="info">公测</StatusPill><div><strong>报价与订单不开放</strong><p>当前没有任何付款流程;先把主机接入、恢复和稳定性做好。</p></div></article>
|
||||
<article><StatusPill tone="info">规则</StatusPill><div><strong>公测结束不自动转付费</strong><p>收费方案以后再定;未主动确认前不会创建付费关系。</p></div></article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="path-panel">
|
||||
<div><span className="eyebrow">CONNECTION PATH</span><h2>按当前状态完成下一步。</h2></div>
|
||||
<ol>
|
||||
<li className={entitlement.mode !== "none" ? "done" : "current"}><span>01</span><div><strong>获得免费测试资格</strong><p>{onboarding.title}。{onboarding.detail}</p></div></li>
|
||||
<li className={snapshot.pairings.length || activeHosts.length ? "done" : onboarding.state === "ready" ? "current" : undefined}><span>02</span><div><strong>创建短时配对请求</strong><p>有可用免费名额后,控制平面生成十分钟一次性码。</p></div></li>
|
||||
<li className={activeHosts.length ? "done" : snapshot.pairings.length ? "current" : undefined}><span>03</span><div><strong>daemon 认领并证明主机身份</strong><p>控制面签发独立设备令牌,并推进租户 authorization revision。</p></div></li>
|
||||
<li className={snapshot.connection.state === "ready" ? "done" : activeHosts.length ? "current" : undefined}><span>04</span><div><strong>共享 sealed Relay 就绪</strong><p>{snapshot.connection.state === "ready" ? "daemon 保持稳定服务地址即可连接。" : "等待租户 placement 指向健康节点;不会向客户端释放后端节点 URL。"}</p></div></li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -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