feat: configure authenticated player entry routes
This commit is contained in:
24 files changed
+1044
-17
No files matched your search
@@ -6,6 +6,7 @@ import type {
|
||||
AuthSourceConfig,
|
||||
AuthSourcesSettings,
|
||||
WakePolicySettings,
|
||||
EntryPolicySettings,
|
||||
AutostartPolicy,
|
||||
BackupView,
|
||||
Build,
|
||||
@@ -94,6 +95,7 @@ interface MockState {
|
||||
updateWindow: { start: string | null; end: string | null };
|
||||
authSources: AuthSourcesSettings;
|
||||
wakePolicy: WakePolicySettings;
|
||||
entryPolicy: EntryPolicySettings;
|
||||
// Each server's world volume, seeded on first visit.
|
||||
files: Record<string, MockTree>;
|
||||
}
|
||||
@@ -457,6 +459,7 @@ function initialState(): MockState {
|
||||
},
|
||||
],
|
||||
updateWindow: { start: null, end: null },
|
||||
entryPolicy: { mode: "domain", defaultServer: "", requireAccountLink: true, offlineAction: "wake", waitingSpace: "lobby", fallbackServer: "", revision: "initial" },
|
||||
wakePolicy: { maxRunningServers: 0, wakeCooldownSeconds: 30, revision: "initial", managed: false },
|
||||
authSources: {
|
||||
sources: [{ tag: "littleskin", prefix: "LS", url: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/hasJoined", api_url: "", enabled: true }],
|
||||
@@ -1023,6 +1026,20 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
if (!isOwner(ctx.account.role)) sendError(ctx.res, 403, "forbidden", "Owner account required");
|
||||
else sendJSON(ctx.res, 200, { available: false, tasks: [] });
|
||||
return true;
|
||||
case "GET settings/entry-policy":
|
||||
case "PUT settings/entry-policy": {
|
||||
if (!isOwner(ctx.account.role)) sendError(ctx.res, 403, "forbidden", "Owner account required");
|
||||
else if (is("GET", ctx)) sendJSON(ctx.res, 200, ctx.state.entryPolicy);
|
||||
else {
|
||||
const body = await readJSON<EntryPolicySettings>(ctx.req);
|
||||
if (body.revision !== ctx.state.entryPolicy.revision) sendError(ctx.res, 409, "conflict", "Entry policy changed");
|
||||
else {
|
||||
ctx.state.entryPolicy = { ...body, revision: createHash("sha256").update(JSON.stringify(body)).digest("hex") };
|
||||
sendJSON(ctx.res, 200, ctx.state.entryPolicy);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "GET settings/wake-policy":
|
||||
case "PUT settings/wake-policy": {
|
||||
if (!isOwner(ctx.account.role)) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test, expect, t, expectFitsScreen } from "./fixtures";
|
||||
|
||||
test("entry policy keeps its form visible, saves direct routing and retains Limbo control", async ({ page, signIn }) => {
|
||||
await signIn("owner");
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||
await page.route("**/api/v1/settings/entry-policy", async (route) => { await pending; await route.continue(); });
|
||||
await page.goto("/admin/platform");
|
||||
const direct = page.getByRole("radio", { name: `${t("admin:entry_direct")} ${t("admin:entry_direct_hint")}` });
|
||||
await expect(direct).toBeVisible();
|
||||
await expect(direct).toBeDisabled();
|
||||
release();
|
||||
await expect(direct).toBeEnabled();
|
||||
const selected = page.getByRole("radio", { checked: true });
|
||||
await selected.focus();
|
||||
await page.keyboard.press("Home");
|
||||
await expect(page.getByRole("radio", { name: `${t("admin:entry_lobby")} ${t("admin:entry_lobby_hint")}` })).toBeFocused();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(direct).toBeFocused();
|
||||
await expect(page.getByLabel(t("admin:entry_link"), { exact: true })).toHaveAttribute("aria-checked", "false");
|
||||
await expect(page.getByLabel(t("admin:entry_waiting"))).toHaveCount(0);
|
||||
await page.getByLabel(t("admin:entry_main"), { exact: true }).click();
|
||||
await page.getByRole("option", { name: /survival/i }).click();
|
||||
await page.getByRole("button", { name: t("admin:entry_save") }).click();
|
||||
await expect(page.getByText(t("admin:entry_saved"), { exact: true })).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(direct).toHaveAttribute("aria-checked", "true");
|
||||
await expect(page.getByLabel(t("admin:entry_main"), { exact: true })).toContainText("survival");
|
||||
await page.getByLabel(t("admin:entry_offline")).click();
|
||||
await page.getByRole("option", { name: t("admin:entry_wake"), exact: true }).click();
|
||||
await expect(page.getByLabel(t("admin:entry_waiting"))).toContainText(t("admin:entry_wait_login"));
|
||||
await direct.click();
|
||||
await expect(page.getByLabel(t("admin:entry_waiting"))).toContainText(t("admin:entry_wait_login"));
|
||||
await page.getByLabel(t("admin:entry_link"), { exact: true }).click();
|
||||
await expect(page.getByLabel(t("admin:entry_waiting"))).toBeDisabled();
|
||||
await expect(page.getByLabel(t("admin:entry_waiting"))).toContainText(t("admin:entry_lobby"));
|
||||
await page.getByLabel(t("admin:entry_link"), { exact: true }).click();
|
||||
for (const viewport of [{ width: 1440, height: 1050 }, { width: 375, height: 812 }]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await expectFitsScreen(page);
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 1050 });
|
||||
await page.getByRole("main").evaluate((el) => { el.scrollTop = 0; });
|
||||
await expect(direct).toBeInViewport();
|
||||
await page.screenshot({ path: "/tmp/felis-player-entry.png" });
|
||||
});
|
||||
@@ -103,7 +103,7 @@ test("Owner executes node management and receives stage, failure logs and retry"
|
||||
await page.getByLabel(t("admin:node_control_ip")).fill("192.0.2.10");
|
||||
const submit = page.getByRole("button", { name: t("admin:node_control_enable"), exact: true });
|
||||
await expect(submit).toBeDisabled();
|
||||
await page.getByRole("switch").click();
|
||||
await page.getByLabel(t("admin:node_control_confirm_enable")).click();
|
||||
await submit.click();
|
||||
expect(submitted).toMatchObject({ action: "enable", externalIP: "192.0.2.10", confirmMaintenance: true });
|
||||
await expect(page.getByText(t("admin:node_control_stage_database_backup"), { exact: true })).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowRight, DoorOpen, Globe, Loader2, LogIn, RefreshCw, Route, Save, Server, ShieldCheck } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { MessageLine } from "@/components/MessageLine";
|
||||
import { isReauthCancelled, useReauth } from "@/components/ReauthDialog";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync, useUnsavedGuard } from "@/lib/hooks";
|
||||
import type { EntryPolicySettings } from "@/lib/types";
|
||||
|
||||
const modes = [{ value: "lobby", icon: DoorOpen }, { value: "direct", icon: Server }, { value: "domain", icon: Globe }] as const;
|
||||
|
||||
const initial: EntryPolicySettings = { mode: "domain", defaultServer: "", requireAccountLink: true, offlineAction: "wake", waitingSpace: "lobby", fallbackServer: "", revision: "" };
|
||||
|
||||
export function PlayerEntrySettings() {
|
||||
const { t } = useTranslation("admin");
|
||||
const query = useAsync(api.getEntryPolicy, []);
|
||||
const fleet = useAsync(api.fleet, []);
|
||||
const reauth = useReauth();
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const [saved, setSaved] = useState<EntryPolicySettings | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
const dirty = saved !== null && JSON.stringify(draft) !== JSON.stringify(saved);
|
||||
useUnsavedGuard(dirty);
|
||||
useEffect(() => {
|
||||
if (query.data) { setSaved(query.data); setDraft(query.data); }
|
||||
}, [query.data]);
|
||||
const busy = query.loading || saving || !saved;
|
||||
const servers = (fleet.data ?? []).filter((server) => !server.system && server.name !== "login" && server.name !== "lobby");
|
||||
const valid = (draft.mode !== "direct" || !!draft.defaultServer)
|
||||
&& (draft.offlineAction !== "fallback" || (!!draft.fallbackServer && draft.fallbackServer !== draft.defaultServer));
|
||||
function change(update: Partial<EntryPolicySettings>) { setDraft((value) => ({ ...value, ...update })); setMessage(null); }
|
||||
function serverSelect(field: "defaultServer" | "fallbackServer", optional = false) {
|
||||
const value = draft[field];
|
||||
return <Select value={value || "__unset__"} disabled={busy || fleet.loading || !!fleet.error} onValueChange={(name) => change({ [field]: name === "__unset__" ? "" : name })}>
|
||||
<SelectTrigger id={`entry-${field}`}><SelectValue placeholder={t("entry_select_server")} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{optional && <SelectItem value="__unset__">{t("entry_no_default")}</SelectItem>}
|
||||
{!optional && !value && <SelectItem value="__unset__" disabled>{t("entry_select_server")}</SelectItem>}
|
||||
{value && !servers.some((server) => server.name === value) && <SelectItem value={value}>{value}</SelectItem>}
|
||||
{servers.map((server) => <SelectItem key={server.name} value={server.name}>{server.displayName || server.name} · {server.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>;
|
||||
}
|
||||
async function save() {
|
||||
if (busy || !dirty || !valid) return;
|
||||
setSaving(true); setMessage(null);
|
||||
try {
|
||||
const policy = await reauth.guard(() => api.setEntryPolicy(draft));
|
||||
setSaved(policy); setDraft(policy);
|
||||
setMessage({ kind: "success", text: t("entry_saved") });
|
||||
} catch (error) {
|
||||
if (!isReauthCancelled(error)) setMessage({ kind: "error", text: humanizeError(error) });
|
||||
} finally { setSaving(false); }
|
||||
}
|
||||
return <Card>
|
||||
<CardHeader className="flex-row items-center justify-between gap-3"><CardTitle className="flex items-center gap-2"><Route className="h-4 w-4 text-primary" aria-hidden="true" />{t("entry_title")}</CardTitle><Button variant="outline" size="sm" disabled={dirty || saving || query.loading} onClick={() => { query.reload(); fleet.reload(); }}><RefreshCw />{t("platform_reload")}</Button></CardHeader>
|
||||
<CardContent className="space-y-6" aria-busy={query.loading}>
|
||||
<p className="text-sm text-muted-foreground">{t("entry_description")}</p>
|
||||
{query.loading && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />{t("platform_loading")}</p>}
|
||||
<div role="radiogroup" aria-label={t("entry_mode")} className="grid gap-3 sm:grid-cols-3" onKeyDown={(event) => {
|
||||
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key) || busy) return;
|
||||
event.preventDefault();
|
||||
const choices = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]'));
|
||||
const current = choices.indexOf(event.target as HTMLButtonElement);
|
||||
const index = event.key === "Home" ? 0 : event.key === "End" ? choices.length - 1 : (current + (event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 1) + choices.length) % choices.length;
|
||||
choices[index].focus(); choices[index].click();
|
||||
}}>
|
||||
{modes.map(({ value: mode, icon: Icon }) => <Button key={mode} type="button" role="radio" tabIndex={draft.mode === mode ? 0 : -1} aria-checked={draft.mode === mode} variant="outline" disabled={busy} className={`h-auto justify-start whitespace-normal px-4 py-3 text-left active:scale-100 ${draft.mode === mode ? "border-primary/40 bg-primary/5 ring-1 ring-primary/20" : ""}`} onClick={() => { if (draft.mode === mode) return; change({ mode, ...(mode === "lobby" ? { defaultServer: "" } : {}), ...(mode === "direct" ? { requireAccountLink: false, offlineAction: "disconnect", waitingSpace: "login", fallbackServer: "" } : {}) }); }}>
|
||||
<span className="space-y-1"><span className="flex items-center gap-2"><Icon className={draft.mode === mode ? "text-primary" : "text-muted-foreground"} aria-hidden="true" />{t(`entry_${mode}`)}</span><span className="block text-xs font-normal leading-relaxed text-muted-foreground">{t(`entry_${mode}_hint`)}</span></span>
|
||||
</Button>)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-muted/40 px-4 py-3 text-sm" aria-label={t("entry_preview")}>
|
||||
<span className="flex items-center gap-2"><ShieldCheck className="h-4 w-4 text-primary" aria-hidden="true" />{t("entry_identity")}</span>{draft.requireAccountLink && <><ArrowRight className="h-4 w-4 text-muted-foreground" /><span className="flex items-center gap-2"><LogIn className="h-4 w-4 text-muted-foreground" aria-hidden="true" />{t("entry_web")}</span></>}<ArrowRight className="h-4 w-4 text-muted-foreground" /><span className="font-medium">{draft.mode === "lobby" ? t("entry_lobby") : draft.mode === "domain" ? t("entry_domain_target") : draft.defaultServer || t("entry_select_server")}</span>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{draft.mode !== "lobby" && <div className="space-y-2"><Label htmlFor="entry-defaultServer">{t(draft.mode === "direct" ? "entry_main" : "entry_default")}</Label>{serverSelect("defaultServer", draft.mode === "domain")}<p className="text-xs leading-relaxed text-muted-foreground">{t(draft.mode === "direct" ? "entry_main_hint" : "entry_default_hint")}</p></div>}
|
||||
<div className="space-y-2"><Label htmlFor="entry-offline">{t("entry_offline")}</Label><Select value={draft.offlineAction} disabled={busy} onValueChange={(value) => change({ offlineAction: value as EntryPolicySettings["offlineAction"], ...(value !== "fallback" ? { fallbackServer: "" } : {}) })}><SelectTrigger id="entry-offline"><SelectValue /></SelectTrigger><SelectContent>{(["wake", "fallback", "disconnect"] as const).map((action) => <SelectItem key={action} value={action}>{t(`entry_${action}`)}</SelectItem>)}</SelectContent></Select></div>
|
||||
{draft.offlineAction === "fallback" && <div className="space-y-2"><Label htmlFor="entry-fallbackServer">{t("entry_fallback_server")}</Label>{serverSelect("fallbackServer")}</div>}
|
||||
{draft.offlineAction === "wake" && <div className="space-y-2"><Label htmlFor="entry-waiting">{t("entry_waiting")}</Label><Select value={draft.waitingSpace} disabled={busy || draft.requireAccountLink} onValueChange={(value) => change({ waitingSpace: value as EntryPolicySettings["waitingSpace"] })}><SelectTrigger id="entry-waiting"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="login">{t("entry_wait_login")}</SelectItem><SelectItem value="lobby">{t("entry_lobby")}</SelectItem></SelectContent></Select><p className="text-xs leading-relaxed text-muted-foreground">{t(draft.requireAccountLink ? "entry_web_wait" : "entry_wake_hint")}</p></div>}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 border-t border-border pt-5"><div className="space-y-1"><Label htmlFor="entry-link">{t("entry_link")}</Label><p className="text-xs leading-relaxed text-muted-foreground">{t("entry_link_hint")}</p></div><Switch id="entry-link" checked={draft.requireAccountLink} disabled={busy} onCheckedChange={(checked) => change({ requireAccountLink: checked, ...(checked ? { waitingSpace: "lobby" } : {}) })} /></div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{t("entry_components")} <Link className="font-medium text-primary hover:underline" to="/admin/lobby">{t("entry_manage_spaces")}</Link></p>
|
||||
{query.error != null && <MessageLine kind="error" message={humanizeError(query.error)} />}
|
||||
{fleet.error != null && <MessageLine kind="error" message={humanizeError(fleet.error)} />}
|
||||
{message && <MessageLine kind={message.kind} message={message.text} />}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4"><p className="text-xs text-muted-foreground">{t("entry_scope")}</p><div className="flex gap-2">{dirty && <Button variant="outline" disabled={saving} onClick={() => { if (saved) setDraft(saved); setMessage(null); }}>{t("platform_discard")}</Button>}<Button disabled={busy || !dirty || !valid} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" /> : <Save />}{t("entry_save")}</Button></div></div>
|
||||
{reauth.dialog}
|
||||
</CardContent>
|
||||
</Card>;
|
||||
}
|
||||
@@ -308,7 +308,7 @@
|
||||
"scan_not_kept": "The file was not retained for this build. It may have exceeded the size limit or its generation step may have failed.",
|
||||
"scan_download_failed": "Unable to download: {{reason}}",
|
||||
"platform_title": "Platform settings",
|
||||
"platform_subtitle": "Global controls for server startup and wake requests.",
|
||||
"platform_subtitle": "Manage player entry, server startup and distributed nodes.",
|
||||
"platform_reload": "Reload",
|
||||
"platform_wake_title": "Startup and wake policy",
|
||||
"platform_loading": "Loading the saved policy…",
|
||||
@@ -393,5 +393,40 @@
|
||||
"node_control_enable_ip_hint": "Required. Enter the controller’s currently registered fixed IP, using a static address or DHCP reservation.",
|
||||
"node_control_ip_hint": "Required. Enter the fixed IP configured on the target worker.",
|
||||
"node_control_incomplete": "Complete all required fields and acknowledge the maintenance conditions before executing.",
|
||||
"node_control_back": "Back"
|
||||
"node_control_back": "Back",
|
||||
"entry_title": "Player entry policy",
|
||||
"entry_description": "Configure game identity verification, panel account linking and destination independently. Existing deployments retain their current flow until the first save.",
|
||||
"entry_mode": "Entry mode",
|
||||
"entry_lobby": "Lobby",
|
||||
"entry_direct": "Direct to main server",
|
||||
"entry_domain": "Route by hostname",
|
||||
"entry_lobby_hint": "Authenticate, then enter the lobby to choose a server.",
|
||||
"entry_direct_hint": "Authenticate, then enter the selected main server.",
|
||||
"entry_domain_hint": "Select the destination using the connection hostname.",
|
||||
"entry_preview": "Entry flow preview",
|
||||
"entry_identity": "Game identity verification",
|
||||
"entry_web": "Limbo web sign-in",
|
||||
"entry_domain_target": "Hostname destination",
|
||||
"entry_main": "Main server",
|
||||
"entry_default": "Default destination (optional)",
|
||||
"entry_select_server": "Select a server",
|
||||
"entry_no_default": "No default destination",
|
||||
"entry_main_hint": "All entry hostnames lead to this server. Its autostart policy still controls startup permission.",
|
||||
"entry_default_hint": "Used for unmatched hostnames. Without a default, web sign-in connections enter the lobby; other connections are rejected.",
|
||||
"entry_offline": "When the destination is offline",
|
||||
"entry_wake": "Start automatically and wait",
|
||||
"entry_fallback": "Enter a fallback server",
|
||||
"entry_disconnect": "Reject with an explanation",
|
||||
"entry_fallback_server": "Fallback server",
|
||||
"entry_waiting": "Startup waiting space",
|
||||
"entry_wait_login": "Login space (Limbo)",
|
||||
"entry_wake_hint": "The waiting space must be running. Startup failure or timeout is reported; a connection waiting in Limbo is then disconnected.",
|
||||
"entry_web_wait": "Web sign-in uses the lobby while waiting for startup.",
|
||||
"entry_link": "Require panel account linking",
|
||||
"entry_link_hint": "Players must complete web sign-in in Limbo when enabled. Otherwise game identity is still verified and panel linking remains optional.",
|
||||
"entry_components": "Web sign-in requires the login space and lobby. Automatic authentication only requires the selected destination and waiting space. Unused spaces can be stopped manually.",
|
||||
"entry_manage_spaces": "Manage login space and lobby",
|
||||
"entry_scope": "Applies to new connections within 15 seconds. Current players are unaffected.",
|
||||
"entry_save": "Save entry policy",
|
||||
"entry_saved": "Entry policy saved. New connections use it after the proxy refreshes its configuration."
|
||||
}
|
||||
@@ -304,7 +304,7 @@
|
||||
"scan_not_kept": "构建未留存此文件,可能由于文件过大或生成步骤失败。",
|
||||
"scan_download_failed": "下载失败:{{reason}}",
|
||||
"platform_title": "平台设置",
|
||||
"platform_subtitle": "统一管理服务器启动与唤醒的全局策略。",
|
||||
"platform_subtitle": "管理玩家入口、服务器启动与分布式节点。",
|
||||
"platform_reload": "重新读取",
|
||||
"platform_wake_title": "启动与唤醒策略",
|
||||
"platform_loading": "正在读取已保存的策略…",
|
||||
@@ -389,5 +389,40 @@
|
||||
"node_control_enable_ip_hint": "必填。填写主控当前登记的固定 IP;应为静态地址或已配置 DHCP 地址保留。",
|
||||
"node_control_ip_hint": "必填。填写目标 worker 已配置的固定 IP。",
|
||||
"node_control_incomplete": "填写全部必填项并确认维护条件后,方可执行。",
|
||||
"node_control_back": "返回"
|
||||
"node_control_back": "返回",
|
||||
"entry_title": "玩家进入策略",
|
||||
"entry_description": "分别配置游戏身份验证、面板账号关联和进入目标。已部署环境在首次保存前保留原有流程。",
|
||||
"entry_mode": "进入方式",
|
||||
"entry_lobby": "正式大厅",
|
||||
"entry_direct": "直达主服",
|
||||
"entry_domain": "按域名进入",
|
||||
"entry_lobby_hint": "认证完成后进入大厅,由玩家选择服务器。",
|
||||
"entry_direct_hint": "认证完成后直接进入指定主服。",
|
||||
"entry_domain_hint": "按连接域名选择目标服务器。",
|
||||
"entry_preview": "进入流程预览",
|
||||
"entry_identity": "游戏身份认证",
|
||||
"entry_web": "Limbo 网页登录",
|
||||
"entry_domain_target": "域名对应服务器",
|
||||
"entry_main": "默认主服",
|
||||
"entry_default": "默认目标(选填)",
|
||||
"entry_select_server": "选择服务器",
|
||||
"entry_no_default": "不设置默认目标",
|
||||
"entry_main_hint": "所有入口均连接此服务器。启动权限仍由目标服务器的自动启动策略决定。",
|
||||
"entry_default_hint": "连接域名未匹配服务器时使用。未设置时,要求网页登录的连接进入大厅,其余连接被拒绝。",
|
||||
"entry_offline": "目标未运行时",
|
||||
"entry_wake": "自动启动并等待",
|
||||
"entry_fallback": "进入备用服务器",
|
||||
"entry_disconnect": "拒绝连接并说明原因",
|
||||
"entry_fallback_server": "备用服务器",
|
||||
"entry_waiting": "启动等待空间",
|
||||
"entry_wait_login": "登录空间(Limbo)",
|
||||
"entry_wake_hint": "等待空间须处于运行状态。启动失败或等待超时将显示原因;Limbo 等待连接随后断开。",
|
||||
"entry_web_wait": "启用网页登录时,启动等待使用正式大厅。",
|
||||
"entry_link": "要求关联面板账号",
|
||||
"entry_link_hint": "启用后,玩家须在 Limbo 完成网页登录。关闭后仍验证游戏身份,面板账号可另行关联。",
|
||||
"entry_components": "网页登录需要登录空间及正式大厅;自动认证仅需要所选进入目标和等待空间。未使用的空间可手动停用。",
|
||||
"entry_manage_spaces": "管理登录空间与大厅",
|
||||
"entry_scope": "保存后对新连接生效;代理在 15 秒内读取配置,当前在线玩家不受影响。",
|
||||
"entry_save": "保存进入策略",
|
||||
"entry_saved": "进入策略已保存。新连接将在代理读取配置后使用此策略。"
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
AuthSourceConfig,
|
||||
AuthSourcesSettings,
|
||||
WakePolicySettings,
|
||||
EntryPolicySettings,
|
||||
MinecraftProfile,
|
||||
LinkStatus,
|
||||
BindResult,
|
||||
@@ -960,6 +961,9 @@ export const api = rejectingSync({
|
||||
|
||||
linkSources: () => request<{ sources: MinecraftAuthSource[] }>("GET", "/account/link/sources"),
|
||||
|
||||
getEntryPolicy: () => request<EntryPolicySettings>("GET", "/settings/entry-policy"),
|
||||
setEntryPolicy: (policy: EntryPolicySettings) => request<EntryPolicySettings>("PUT", "/settings/entry-policy", policy),
|
||||
|
||||
getWakePolicy: () => request<WakePolicySettings>("GET", "/settings/wake-policy"),
|
||||
setWakePolicy: (policy: Omit<WakePolicySettings, "managed">) => request<WakePolicySettings>("PUT", "/settings/wake-policy", policy),
|
||||
getAuthSources: () => request<AuthSourcesSettings>("GET", "/settings/auth-sources"),
|
||||
|
||||
@@ -1334,6 +1334,44 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/internal/settings/entry-policy": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read player entry policy for the authenticated proxy and login gate. */
|
||||
get: operations["internalEntryPolicy"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/settings/entry-policy": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read player entry policy (Owner). */
|
||||
get: operations["getEntryPolicy"];
|
||||
/**
|
||||
* Save player entry policy (Owner, fresh reauthentication).
|
||||
* @description Applies to new connections within the proxy's 15-second refresh interval. Does not change online-mode, autostart authorization or existing players, and does not stop system spaces automatically.
|
||||
*/
|
||||
put: operations["setEntryPolicy"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/settings/wake-policy": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3036,6 +3074,25 @@ export interface components {
|
||||
startedAt?: string;
|
||||
logsAvailable: boolean;
|
||||
};
|
||||
EntryPolicySettings: {
|
||||
/** @enum {string} */
|
||||
mode: "lobby" | "direct" | "domain";
|
||||
/** @description Required for direct mode; optional fallback destination for unmatched hostnames. */
|
||||
defaultServer: string;
|
||||
/** @description Require Limbo web sign-in and panel association in addition to proxy game identity authentication. */
|
||||
requireAccountLink: boolean;
|
||||
/** @enum {string} */
|
||||
offlineAction: "wake" | "fallback" | "disconnect";
|
||||
/**
|
||||
* @description Web sign-in currently requires lobby waiting.
|
||||
* @enum {string}
|
||||
*/
|
||||
waitingSpace: "login" | "lobby";
|
||||
/** @description Required for fallback action and distinct from the default server. */
|
||||
fallbackServer: string;
|
||||
/** @description Opaque revision; stale or concurrent writes return 409. */
|
||||
revision: string;
|
||||
};
|
||||
WakePolicySettings: {
|
||||
maxRunningServers: number;
|
||||
wakeCooldownSeconds: number;
|
||||
@@ -7086,6 +7143,91 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
internalEntryPolicy: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Current policy; proxy snapshots it for each new connection. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["EntryPolicySettings"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
};
|
||||
};
|
||||
getEntryPolicy: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Current policy and revision; an unsaved deployment preserves its legacy routing. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["EntryPolicySettings"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
};
|
||||
};
|
||||
setEntryPolicy: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["EntryPolicySettings"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Saved policy and revision. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["EntryPolicySettings"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description Selected server does not exist. */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Policy changed; reload before saving. */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
getWakePolicy: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -796,3 +796,13 @@ export interface NodeControlTask {
|
||||
error?: string;
|
||||
log?: string;
|
||||
}
|
||||
|
||||
export interface EntryPolicySettings {
|
||||
mode: "lobby" | "direct" | "domain";
|
||||
defaultServer: string;
|
||||
requireAccountLink: boolean;
|
||||
offlineAction: "wake" | "fallback" | "disconnect";
|
||||
waitingSpace: "login" | "lobby";
|
||||
fallbackServer: string;
|
||||
revision: string;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { PlatformSettingsPage } from "./PlatformSettingsPage";
|
||||
import type { WakePolicySettings } from "@/lib/types";
|
||||
const calls = vi.hoisted(() => ({ getWakePolicy: vi.fn(), setWakePolicy: vi.fn(), nodes: vi.fn(), nodeTasks: vi.fn(), nodeTask: vi.fn(), startNodeTask: vi.fn(), retryNodeTask: vi.fn() }));
|
||||
const calls = vi.hoisted(() => ({ getEntryPolicy: vi.fn(), setEntryPolicy: vi.fn(), fleet: vi.fn(), getWakePolicy: vi.fn(), setWakePolicy: vi.fn(), nodes: vi.fn(), nodeTasks: vi.fn(), nodeTask: vi.fn(), startNodeTask: vi.fn(), retryNodeTask: vi.fn() }));
|
||||
vi.mock("@/lib/api", async (original) => ({ ...await original<typeof import("@/lib/api")>(), api: calls }));
|
||||
const runtime = vi.hoisted(() => ({ distributed: false, fallback: false, apiBase: "/api/v1", rootDomain: "example.test" }));
|
||||
vi.mock("@/lib/hooks", async (original) => ({ ...await original<typeof import("@/lib/hooks")>(), useConfig: () => runtime }));
|
||||
@@ -17,6 +17,8 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runtime.distributed = false;
|
||||
runtime.fallback = false;
|
||||
calls.getEntryPolicy.mockResolvedValue({ mode: "domain", defaultServer: "", requireAccountLink: true, offlineAction: "wake", waitingSpace: "lobby", fallbackServer: "", revision: "initial" });
|
||||
calls.fleet.mockResolvedValue([]);
|
||||
calls.nodes.mockResolvedValue([]);
|
||||
calls.nodeTasks.mockResolvedValue({ available: false, tasks: [] });
|
||||
calls.getWakePolicy.mockResolvedValue(policy);
|
||||
@@ -42,7 +44,7 @@ describe("platform policy", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save and apply" }));
|
||||
await screen.findByRole("alert");
|
||||
expect(limit().value).toBe("2");
|
||||
expect(screen.getByRole("button", { name: "Reload" })).toHaveProperty("disabled", true);
|
||||
expect(screen.getAllByRole("button", { name: "Reload" })[0]).toHaveProperty("disabled", true);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save and apply" }));
|
||||
await screen.findByText("Saved. The new policy is active without a restart.");
|
||||
expect(calls.setWakePolicy).toHaveBeenLastCalledWith({ maxRunningServers: 2, wakeCooldownSeconds: 30, revision: "initial" });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, RefreshCw, Save, Settings } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlayerEntrySettings } from "@/components/PlayerEntrySettings";
|
||||
import { NodeControlPanel } from "@/components/NodeControlPanel";
|
||||
import { DistributedNodes } from "@/components/DistributedNodes";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -52,6 +53,7 @@ export function PlatformSettingsPage() {
|
||||
}
|
||||
return <div className="space-y-6">
|
||||
<PageHeader icon={Settings} title={t("platform_title")} subtitle={t("platform_subtitle")} actions={<Button variant="outline" size="sm" disabled={dirty || busy} onClick={query.reload}><RefreshCw />{t("platform_reload")}</Button>} />
|
||||
<PlayerEntrySettings />
|
||||
<Card><CardHeader><CardTitle>{t("platform_wake_title")}</CardTitle></CardHeader><CardContent className="space-y-6">
|
||||
{query.loading && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />{t("platform_loading")}</p>}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
|
||||
Reference in new issue
Block a user