feat: configure authenticated player entry routes

This commit is contained in:
Lemon-miaow committed 2026-10-07 17:19:20 +08:00
1 parent 07973a4bbd
commit 20994be996
24 files changed
+1044 -17

No files matched your search

+17
View File
@@ -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)) {
+46
View File
@@ -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" });
});
+1 -1
View File
@@ -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>;
}
+37 -2
View File
@@ -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."
}
+37 -2
View File
@@ -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": "进入策略已保存。新连接将在代理读取配置后使用此策略。"
}
+4
View File
@@ -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"),
+142
View File
@@ -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;
+10
View File
@@ -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">