Files
nekonest-cloud/app/dashboard/hosts/new/PairingForm.tsx
T

364 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>一次性配对码已经从页面状态中清除。控制平面已建立可撤销设备凭据并推进授权 revisiondaemon 会继续连接同一个服务地址。</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>
);
}