feat: customize login and lobby spaces from the panel

This commit is contained in:
Lemon-miaow committed 2026-10-04 12:38:39 +08:00
1 parent efbbe27629
commit 10bd2ddad0
54 files changed
+1146 -158

No files matched your search

+11 -2
View File
@@ -286,6 +286,10 @@ function initialState(): MockState {
server("claim-me", "Claimable Node", "Stopped", null, {
playersMax: 10,
}),
server("login", "Login space", "Stopped", null, {
autostartPolicy: "public", idleStopSeconds: 0, reaperExempt: true,
image: "registry.felis.svc:5000/felis/limbo:demo", memory: "512Mi", storageSize: "1Gi",
}),
...generatedServers(),
],
access: {
@@ -764,7 +768,8 @@ function fleetView(state: MockState, accountInfo: MockAccount): FleetServer[] {
playersOnline: s.ready ? s.playersOnline : 0,
owner: owner ? state.accounts[owner].email : "",
owned: owner === accountInfo.id,
claimable: owner === null && !s.retiring,
claimable: owner === null && !s.retiring && !s.reaperExempt,
system: s.reaperExempt,
};
});
}
@@ -2241,6 +2246,10 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
sendError(ctx.res, 404, "not_found", "server not found");
return true;
}
if (serverInfo.reaperExempt && !isAdmin(ctx.account.role)) {
sendError(ctx.res, 400, "bad_name", "system service is reserved for staff");
return true;
}
if (!canSee(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not visible to this account");
return true;
@@ -2474,7 +2483,7 @@ async function handleFilesMock(ctx: SessionContext, serverInfo: MockServer): Pro
if (!node) return fail(404, "not_found", `${p} does not exist`);
if (node.is_dir) return fail(400, "bad_path", `${p} is a directory, not a file`);
if (node.data.length > MOCK_MAX_READ) return fail(413, "too_large", `${p} is larger than the editor reads`);
sendJSON(ctx.res, 200, { path: p, content: node.data.toString("base64"), sha256: mockSha(node.data) });
sendJSON(ctx.res, 200, { path: p, content: node.data.toString("base64"), sha256: mockSha(node.data), content_sha256: mockSha(node.data) });
return true;
}
case "PUT file": {
+1
View File
@@ -30,6 +30,7 @@ for (const [account, path] of [
["linked", "/account"],
["owner", "/"],
["owner", "/servers"],
["owner", "/admin/lobby?space=login"],
["owner", "/admin/images"],
["owner", "/admin/builds"],
["owner", "/admin/submissions"],
+11
View File
@@ -50,3 +50,14 @@ test("the LuckPerms player list leaves the rest of the page on the first screen"
await page.getByRole("button", { name: "Herobrine", exact: true }).click();
await expect(page.getByRole("textbox", { name: t("servers:luckperms_player_lookup") })).toHaveValue("Herobrine");
});
test("login customization fits a phone and retains a saved title", async ({ page, signIn }) => {
await signIn("owner");
await page.goto("/admin/lobby?space=login");
await page.getByLabel(t("lobby:bookTitle")).fill("Our Network");
await expectFitsScreen(page);
await page.getByRole("button", { name: t("lobby:save"), exact: true }).click();
await expect(page.getByText(t("lobby:saved"), { exact: true })).toBeVisible();
await page.reload();
await expect(page.getByLabel(t("lobby:bookTitle"))).toHaveValue("Our Network");
});
+20 -1
View File
@@ -98,7 +98,7 @@ test("an unlinked Owner can manage the panel, then preview and confirm a game ro
// Admin pages are chunks of their own, so a player never downloads them: every
// page module is fetched by its name (/src/pages/admin/UsersPage.tsx under the
// dev server, /assets/UsersPage-<hash>.js in a build).
const ADMIN_PAGE = /\/(UsersPage|UserDetailPage|ImageAdmin|ImageBuildPage|SubmissionsPage|UpdatesPage)[.-]/;
const ADMIN_PAGE = /\/(UsersPage|UserDetailPage|LobbyPage|ImageAdmin|ImageBuildPage|SubmissionsPage|UpdatesPage)[.-]/;
const ACCOUNT_PAGE = /\/Account[.-]/;
test("a player's pages load on demand and never pull in the admin pages", async ({ page, signIn }) => {
@@ -130,3 +130,22 @@ test("on a wide screen the LuckPerms player list runs down its column", async ({
await page.setViewportSize({ width: 1280, height: 1000 });
await expect.poll(async () => (await list.locator("..").boundingBox())!.height).toBe(700);
});
test("staff customize the login space through the persistent file editor", async ({ page, signIn }) => {
await signIn("owner");
await page.goto("/admin/lobby?space=login");
await expect(page.getByRole("heading", { name: t("lobby:title"), exact: true })).toBeVisible();
const heading = page.getByLabel(t("lobby:bookHeading"));
await expect(heading).toBeVisible();
await heading.fill("Welcome to our network");
await page.getByRole("button", { name: t("lobby:save"), exact: true }).click();
await expect(page.getByText(t("lobby:saved"), { exact: true })).toBeVisible();
await page.reload();
await expect(heading).toHaveValue("Welcome to our network");
});
test("players cannot open the lobby administration page", async ({ page, signIn }) => {
await signIn("linked");
await page.goto("/admin/lobby");
await expect(page.getByRole("heading", { name: t("lobby:title"), exact: true })).toHaveCount(0);
});
+4
View File
@@ -59,6 +59,9 @@ const UsersPage = lazyWithReload(() =>
const UserDetailPage = lazyWithReload(() =>
import("@/pages/admin/UserDetailPage").then((m) => ({ default: m.UserDetailPage })),
);
const LobbyPage = lazyWithReload(() =>
import("@/pages/admin/LobbyPage").then((m) => ({ default: m.LobbyPage })),
);
const UpdatesPage = lazyWithReload(() =>
import("@/pages/admin/UpdatesPage").then((m) => ({ default: m.UpdatesPage })),
);
@@ -121,6 +124,7 @@ export default function App() {
so the section root and any stale bookmarks land somewhere useful. */}
<Route path="admin" element={<RequireAdmin />}>
<Route index element={<Navigate to="/admin/images" replace />} />
<Route path="lobby" element={<LobbyPage />} />
<Route path="images" element={<ImageAdmin />} />
<Route path="builds" element={<ImageBuildPage />} />
<Route path="submissions" element={<SubmissionsPage />} />
+8 -5
View File
@@ -83,6 +83,7 @@ interface EditServerForm {
interface Props {
serverName: string;
systemService?: boolean;
currentDisplayName?: string;
currentPolicy?: AutostartPolicy;
currentImage?: string;
@@ -97,6 +98,7 @@ interface Props {
export function EditServerDialog({
serverName,
systemService = false,
currentDisplayName = "",
currentPolicy = "ownerOnly",
currentImage = "",
@@ -176,7 +178,7 @@ export function EditServerDialog({
if (displayName !== currentDisplayName) {
payload.displayName = displayName;
}
if (form.autostartPolicy !== currentPolicy) {
if (!systemService && form.autostartPolicy !== currentPolicy) {
payload.autostartPolicy = form.autostartPolicy;
}
if (imageChanged) {
@@ -190,7 +192,7 @@ export function EditServerDialog({
if (cpu !== currentCpu) {
payload.resources = { cpu };
}
if (form.idleStop !== currentIdleStop) {
if (!systemService && form.idleStop !== currentIdleStop) {
payload.idleStopSeconds = Number(form.idleStop);
}
@@ -218,7 +220,7 @@ export function EditServerDialog({
<div className="min-w-0 flex-1 text-left">
<p className="text-sm font-medium">{t("edit_server_title")}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{t("edit_server_desc")}
{t(systemService ? "edit_system_desc" : "edit_server_desc")}
</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
@@ -228,7 +230,7 @@ export function EditServerDialog({
<DialogHeader>
<DialogTitle>{t("edit_server_title")}</DialogTitle>
<DialogDescription>
{t("edit_server_desc_long")}
{t(systemService ? "edit_system_desc" : "edit_server_desc_long")}
</DialogDescription>
</DialogHeader>
@@ -350,6 +352,7 @@ export function EditServerDialog({
<Label htmlFor="edit-server-policy">{t("create_server_policy")}</Label>
<Select
value={form.autostartPolicy}
disabled={systemService}
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
>
<SelectTrigger id="edit-server-policy">
@@ -368,7 +371,7 @@ export function EditServerDialog({
<div className="grid gap-2">
<Label htmlFor="edit-server-idle">{t("edit_server_idle")}</Label>
<Select value={form.idleStop} onValueChange={(v) => set("idleStop", v)}>
<Select disabled={systemService} value={form.idleStop} onValueChange={(v) => set("idleStop", v)}>
<SelectTrigger id="edit-server-idle">
<SelectValue />
</SelectTrigger>
+4
View File
@@ -13,6 +13,8 @@ import enNavigation from "./resources/en-US/navigation.json";
import enBackups from "./resources/en-US/backups.json";
import enSubmissions from "./resources/en-US/submissions.json";
import enFiles from "./resources/en-US/files.json";
import enLobby from "./resources/en-US/lobby.json";
import zhLobby from "./resources/zh-CN/lobby.json";
import enSchedules from "./resources/en-US/schedules.json";
import zhCommon from "./resources/zh-CN/common.json";
import zhAuth from "./resources/zh-CN/auth.json";
@@ -46,6 +48,7 @@ export const i18nOptions: InitOptions = {
submissions: enSubmissions,
files: enFiles,
schedules: enSchedules,
lobby: enLobby,
},
"zh-CN": {
common: zhCommon,
@@ -61,6 +64,7 @@ export const i18nOptions: InitOptions = {
submissions: zhSubmissions,
files: zhFiles,
schedules: zhSchedules,
lobby: zhLobby,
},
},
supportedLngs: [...SUPPORTED_LANGUAGES],
+85
View File
@@ -0,0 +1,85 @@
{
"title": "Login & lobby",
"subtitle": "Customize the scenes, guidance and rules players encounter when joining.",
"space_label": "Choose a space",
"login": "Login space",
"lobby": "Lobby",
"login_description": "Players authenticate here before entering the lobby. This Limbo scene uses a schematic, rather than a Paper world map.",
"lobby_description": "Players choose their server here. Replace maps, install plugins, customize the experience and grant builders access.",
"connection_version": "Installed game version: Java {{version}}",
"unknown_version": "not recorded",
"version_hint": "Client compatibility depends on proxy plugins. Version changes need matching login, lobby and proxy support; this is the installation version, while the running version comes from each image.",
"images_link": "Manage images & versions",
"stop_login": "Stop the login space to read and save settings. New players cannot join during maintenance; start it again when finished.",
"stop_lobby": "Stop the lobby to read and save settings. Back up its map before maintenance, then start it again when finished.",
"read_failed": "Could not read settings: {{reason}}. Check felis-experience.json in the file manager.",
"saved": "Saved. Start this space to apply the settings.",
"save": "Save settings",
"saving": "Saving…",
"invalid_values": "Check numeric ranges and selected options. Text is limited to 512 characters.",
"appearance": "Welcome & menu",
"players": "Player behavior & protection",
"world": "World environment",
"loginBook": "Login guidance & waiting",
"menuTitleZh": "Chinese menu title",
"menuTitleEn": "English menu title",
"welcomeZh": "Chinese welcome message",
"welcomeEn": "English welcome message",
"showWelcome": "Send a welcome message on join",
"openMenuOnJoin": "Open the server menu on join",
"gameMode": "Default player game mode",
"teleportOnJoin": "Return to world spawn on join",
"protectBuild": "Protect against building and damage",
"invulnerable": "Prevent player damage",
"disableHunger": "Prevent hunger",
"voidRescue": "Return to spawn after a void fall",
"difficulty": "Difficulty",
"time": "Fixed time (0–23999)",
"freezeTime": "Freeze world time",
"clearWeather": "Keep clear weather",
"pvp": "Allow PvP",
"spawnMobs": "Allow natural mob spawning",
"mobGriefing": "Allow mob griefing",
"tntExplodes": "Allow TNT explosions",
"keepInventory": "Keep inventory on death",
"immediateRespawn": "Respawn immediately",
"showAdvancementMessages": "Show advancement announcements",
"bookTitle": "Login book title",
"bookAuthor": "Login book author",
"bookHeading": "Login page heading",
"bookAction": "Login link text",
"bookHelp": "Login instructions",
"openBook": "Open the login book automatically",
"loginTimeoutSeconds": "Login timeout (30–3600 seconds)",
"login_settings_hint": "The system still generates the code and login URL and sends the chat guidance. An explicit login timeout environment variable takes precedence over this setting.",
"lobby_settings_hint": "The welcome message includes a clickable server menu. Spawn comes from the map; use setworldspawn x y z in the lobby console to change it. Protection switches apply across the lobby. Builders keep their own game mode.",
"ADVENTURE": "Adventure",
"SURVIVAL": "Survival",
"CREATIVE": "Creative",
"SPECTATOR": "Spectator",
"PEACEFUL": "Peaceful",
"EASY": "Easy",
"NORMAL": "Normal",
"HARD": "Hard",
"map_title": "Maps & scenes",
"login_map_hint": "Stop the login space, upload your scene as spawn.schem at the root, then start it. Adjust spawn coordinates in server.properties.",
"lobby_map_hint": "Stop the lobby, upload and extract a world ZIP at the root. The world folder must directly contain level.dat. Set level-name in server.properties, then start it. Back up the existing map first.",
"plugins_title": "Plugins & plugin settings",
"plugins_hint": "Upload plugin JARs and edit their configuration. Plugins must match the core and version of this space; bundled Felis plugins are refreshed from the image.",
"files_title": "Advanced file manager",
"files_hint": "Edit server properties, world settings and plugin data. Upload, extract, rename or download files. Authentication forwarding settings are maintained by the system.",
"console_title": "Console & runtime logs",
"console_hint": "Read logs, run lobby commands, set world spawn or manage players.",
"login_console_hint": "Read login runtime logs. Limbo has no RCON command channel; edit configuration in the file manager.",
"backups_title": "Map backups & restore",
"backups_hint": "Back up before replacing maps, plugins or images; choose a saved backup to restore.",
"builder_title": "Lobby builder access",
"builder_hint": "Grant felis.lobby.build to let a player build without opening building to everyone.",
"builder_player": "Minecraft player name",
"builder_grant": "Allow building",
"builder_revoke": "Deny building",
"builder_running": "Start the lobby before changing builder access.",
"discard_title": "Discard unsaved settings?",
"discard_hint": "Switching spaces discards your edits. Cancel to save them first.",
"discard": "Discard & switch"
}
@@ -9,5 +9,6 @@
"admin_images": "Images",
"admin_builds": "Build Pipeline",
"admin_submissions": "Submissions",
"admin_updates": "Maintenance & Backups"
"admin_updates": "Maintenance & Backups",
"admin_lobby": "Login & lobby"
}
+4 -2
View File
@@ -64,7 +64,7 @@
"schedules_link_title": "Scheduled tasks",
"schedules_link_desc": "Restart, back up or run commands at set times.",
"system_service": "System service",
"system_service_hint": "Provisioned and managed by the platform (felis systemservers); it accepts no user operations.",
"system_service_hint": "Staff manage system services in Login & lobby.",
"players_back_to_console": "Back to console",
"players_not_yours_title": "Not your server",
"players_not_yours_body": "Only the owner or an admin can manage this server's players.",
@@ -268,5 +268,7 @@
"migration_state_restoring": "Restoring and verifying target world",
"migration_state_switching": "Switching active volume",
"migration_state_succeeded": "Migration complete; start manually after inspection",
"migration_state_failed": "Migration failed; server remains stopped"
"migration_state_failed": "Migration failed; server remains stopped",
"system_customize": "Customize space",
"edit_system_desc": "Adjust the name, image and resources. A replacement must retain the Felis login or menu plugin and match the proxy protocol. System services do not stop when idle."
}
+85
View File
@@ -0,0 +1,85 @@
{
"title": "登录与大厅",
"subtitle": "设置玩家加入时看到的场景、提示和大厅规则。",
"space_label": "选择要自定义的空间",
"login": "登录空间",
"lobby": "正式大厅",
"login_description": "玩家先在这里验证身份,再进入正式大厅。登录场景使用 Limbo schematic,不能直接使用 Paper 世界地图。",
"lobby_description": "玩家在这里选择服务器。可以更换地图、安装插件、自定义大厅体验并授权建造。",
"connection_version": "安装时的游戏版本:Java {{version}}",
"unknown_version": "未记录",
"version_hint": "客户端版本兼容由代理插件决定。更换版本需配套更新登录空间、大厅镜像和代理;此处显示安装版本,实际版本以镜像为准。",
"images_link": "管理镜像与版本",
"stop_login": "停止登录空间后可读取和保存配置。维护期间新玩家无法进入;完成后请启动登录空间。",
"stop_lobby": "停止大厅后可读取和保存配置。维护前可先备份地图,完成后请启动大厅。",
"read_failed": "读取设置失败:{{reason}}。可在文件管理中检查 felis-experience.json。",
"saved": "已保存。启动该空间后生效。",
"save": "保存设置",
"saving": "正在保存…",
"invalid_values": "请检查数字范围和选项,文案最多 512 个字符。",
"appearance": "欢迎与菜单",
"players": "玩家行为与保护",
"world": "世界环境",
"loginBook": "登录提示与等待",
"menuTitleZh": "中文菜单标题",
"menuTitleEn": "英文菜单标题",
"welcomeZh": "中文欢迎文案",
"welcomeEn": "英文欢迎文案",
"showWelcome": "加入时发送欢迎提示",
"openMenuOnJoin": "加入时自动打开服务器菜单",
"gameMode": "玩家默认游戏模式",
"teleportOnJoin": "加入时回到世界出生点",
"protectBuild": "禁止普通玩家破坏和建造",
"invulnerable": "玩家免受伤害",
"disableHunger": "禁止饥饿",
"voidRescue": "掉入虚空时回到出生点",
"difficulty": "难度",
"time": "固定时间(0–23999)",
"freezeTime": "固定世界时间",
"clearWeather": "保持晴天",
"pvp": "允许 PvP",
"spawnMobs": "允许自然生成生物",
"mobGriefing": "允许生物破坏",
"tntExplodes": "允许 TNT 爆炸",
"keepInventory": "死亡后保留物品",
"immediateRespawn": "立即重生",
"showAdvancementMessages": "显示进度公告",
"bookTitle": "登录书标题",
"bookAuthor": "登录书作者",
"bookHeading": "登录书页标题",
"bookAction": "登录链接文案",
"bookHelp": "登录说明",
"openBook": "自动打开登录书",
"loginTimeoutSeconds": "登录等待时间(30–3600 秒)",
"login_settings_hint": "绑定码和登录链接始终由系统生成,聊天中的提示仍保留。部署中若显式设置登录超时环境变量,它优先于这里的等待时间。",
"lobby_settings_hint": "欢迎提示会附带可点击的服务器菜单。出生点使用地图保存的位置,可在大厅控制台用 setworldspawn x y z 设置。保护开关影响整个大厅;建造权限玩家保留自己的游戏模式。",
"ADVENTURE": "冒险",
"SURVIVAL": "生存",
"CREATIVE": "创造",
"SPECTATOR": "旁观",
"PEACEFUL": "和平",
"EASY": "简单",
"NORMAL": "普通",
"HARD": "困难",
"map_title": "地图与场景",
"login_map_hint": "停止登录空间,上传自定义场景为根目录的 spawn.schem,再启动。出生位置等设置在 server.properties 中调整。",
"lobby_map_hint": "停止大厅,上传世界 ZIP 并解压到根目录,确保世界目录直接包含 level.dat;在 server.properties 中设置 level-name 后启动。建议先备份现有地图。",
"plugins_title": "插件与插件配置",
"plugins_hint": "上传插件 JAR、编辑插件配置。插件需适配该空间的核心与版本;系统自带的 Felis 插件会随镜像更新。",
"files_title": "高级文件管理",
"files_hint": "编辑服务器属性、世界配置和插件数据,上传、解压、重命名或下载文件。认证转发设置由系统维护。",
"console_title": "控制台与运行日志",
"console_hint": "查看日志,执行大厅命令、设置出生点或管理玩家。",
"login_console_hint": "查看登录空间日志。Limbo 不支持 RCON 命令,请通过文件管理修改配置。",
"backups_title": "地图备份与恢复",
"backups_hint": "更换地图、插件或镜像前保存备份;可选择已有备份恢复。",
"builder_title": "大厅建造权限",
"builder_hint": "授予 felis.lobby.build 后,该玩家可在大厅建造,不必开放所有人的建造权限。",
"builder_player": "Minecraft 角色名",
"builder_grant": "允许建造",
"builder_revoke": "禁止建造",
"builder_running": "请先启动大厅,才能修改建造权限。",
"discard_title": "放弃未保存的设置?",
"discard_hint": "切换空间会丢失本次编辑。可以取消后先保存。",
"discard": "放弃并切换"
}
@@ -9,5 +9,6 @@
"admin_images": "镜像",
"admin_builds": "构建流水线",
"admin_submissions": "审核提交",
"admin_updates": "维护与备份"
"admin_updates": "维护与备份",
"admin_lobby": "登录与大厅"
}
+4 -2
View File
@@ -64,7 +64,7 @@
"schedules_link_title": "计划任务",
"schedules_link_desc": "定时重启、备份或执行命令。",
"system_service": "系统服务",
"system_service_hint": "由平台预置并管理(felis systemservers),不接受用户操作。",
"system_service_hint": "系统服务由管理员在“登录与大厅”中管理。",
"players_back_to_console": "返回控制台",
"players_not_yours_title": "这不是你的服务器",
"players_not_yours_body": "只有所有者或管理员才能管理此服务器的玩家。",
@@ -267,5 +267,7 @@
"migration_state_restoring": "正在恢复并校验目标卷",
"migration_state_switching": "正在切换活动卷",
"migration_state_succeeded": "迁移完成,等待手动启动",
"migration_state_failed": "迁移失败,服务器保持停服"
"migration_state_failed": "迁移失败,服务器保持停服",
"system_customize": "自定义大厅",
"edit_system_desc": "调整名称、镜像与资源。更换镜像需保留 Felis 登录或菜单插件,并保持与代理的版本兼容。系统服务不自动休眠。"
}
+95
View File
@@ -0,0 +1,95 @@
import { api } from "./api";
import type { ApiError } from "./types";
export const EXPERIENCE_PATH = "felis-experience.json";
export type Experience = Record<string, unknown>;
export type ExperienceField = {
key: string;
value: string | number | boolean;
choices?: string[];
min?: number;
max?: number;
multiline?: boolean;
};
export type ExperienceGroup = { key: string; fields: ExperienceField[] };
export const LOBBY_GROUPS: ExperienceGroup[] = [
{ key: "appearance", fields: [
{ key: "menuTitleZh", value: "Felis 服务器" },
{ key: "menuTitleEn", value: "Felis Servers" },
{ key: "welcomeZh", value: "欢迎来到大厅。输入 /menu 或点击 " },
{ key: "welcomeEn", value: "Welcome to the lobby. Type /menu or click " },
{ key: "showWelcome", value: true },
{ key: "openMenuOnJoin", value: false },
] },
{ key: "players", fields: [
{ key: "gameMode", value: "ADVENTURE", choices: ["ADVENTURE", "SURVIVAL", "CREATIVE", "SPECTATOR"] },
{ key: "teleportOnJoin", value: true },
{ key: "protectBuild", value: true },
{ key: "invulnerable", value: true },
{ key: "disableHunger", value: true },
{ key: "voidRescue", value: true },
] },
{ key: "world", fields: [
{ key: "difficulty", value: "PEACEFUL", choices: ["PEACEFUL", "EASY", "NORMAL", "HARD"] },
{ key: "time", value: 6000, min: 0, max: 23999 },
{ key: "freezeTime", value: true },
{ key: "clearWeather", value: true },
{ key: "pvp", value: false },
{ key: "spawnMobs", value: false },
{ key: "mobGriefing", value: false },
{ key: "tntExplodes", value: false },
{ key: "keepInventory", value: true },
{ key: "immediateRespawn", value: true },
{ key: "showAdvancementMessages", value: false },
] },
];
export const LOGIN_GROUPS: ExperienceGroup[] = [
{ key: "loginBook", fields: [
{ key: "bookTitle", value: "Felis Login" },
{ key: "bookAuthor", value: "Felis" },
{ key: "bookHeading", value: "Felis 登录 / Login" },
{ key: "bookAction", value: "▶ 点此打开登录页\n▶ Open login page", multiline: true },
{ key: "bookHelp", value: "在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there).", multiline: true },
{ key: "openBook", value: true },
{ key: "loginTimeoutSeconds", value: 600, min: 30, max: 3600 },
] },
];
export function experienceValid(values: Experience, groups: ExperienceGroup[]): boolean {
return groups.flatMap((g) => g.fields).every((field) => {
const value = values[field.key] ?? field.value;
if (typeof value !== typeof field.value) return false;
if (typeof value === "number") return Number.isInteger(value) && value >= field.min! && value <= field.max!;
if (typeof value === "string") return value.length <= 512 && (!field.choices || field.choices.includes(value));
return true;
});
}
export async function readExperience(name: string, groups: ExperienceGroup[]): Promise<{ values: Experience; sha256: string }> {
let file;
try {
file = await api.readServerFile(name, EXPERIENCE_PATH);
} catch (error) {
if ((error as ApiError)?.code === "not_found") return { values: {}, sha256: "" };
throw error;
}
const bytes = Uint8Array.from(atob(file.content), (char) => char.charCodeAt(0));
const values: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
if (!values || typeof values !== "object" || Array.isArray(values) || !experienceValid(values as Experience, groups)) {
throw new Error("invalid_experience");
}
return { values: values as Experience, sha256: file.sha256 };
}
export async function writeExperience(name: string, values: Experience, sha256: string): Promise<string> {
const bytes = new TextEncoder().encode(JSON.stringify(values, null, 2) + "\n");
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
const content = btoa(binary);
const file = sha256
? await api.writeServerFile(name, EXPERIENCE_PATH, content, sha256)
: await api.createServerFile(name, EXPERIENCE_PATH, content);
return file.sha256;
}
+2
View File
@@ -1,4 +1,5 @@
import {
DoorOpen,
LayoutDashboard,
Server,
UserRound,
@@ -61,6 +62,7 @@ export const NAV_SECTIONS: NavSection[] = [
adminOnly: true,
ownerOnly: false,
items: [
{ to: "/admin/lobby", key: "admin_lobby", icon: DoorOpen },
{ to: "/admin/images", key: "admin_images", icon: Boxes },
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
{ to: "/admin/submissions", key: "admin_submissions", icon: ClipboardCheck },
+7 -4
View File
@@ -1378,7 +1378,7 @@ export interface paths {
put?: never;
/**
* Start downloading one backup (owner-or-admin plus a former-owner match).
* @description Starts a Job that reads the archive from the backup store and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The Job checks the archive against the sha256 recorded when it was written as it streams; a mismatch cuts the download off short of its end. On the way out config/paper-global.yml (the cluster's forwarding secret) is left out and server.properties has its rcon.password redacted, so the download carries no Content-Length. A user gets 404 for a backup outside their scope, as their list never shows it. One export per user at a time, 2 across the install, 6 per user per hour.
* @description Starts a Job that reads the archive from the backup store and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The Job checks the archive against the sha256 recorded when it was written as it streams; a mismatch cuts the download off short of its end. On the way out config/paper-global.yml (the cluster's forwarding secret) is left out and server.properties has its rcon.password and forwarding-secrets redacted, so the download carries no Content-Length. A user gets 404 for a backup outside their scope, as their list never shows it. One export per user at a time, 2 across the install, 6 per user per hour.
*/
post: operations["exportBackup"];
delete?: never;
@@ -1566,7 +1566,7 @@ export interface paths {
put?: never;
/**
* Start downloading one file or folder of a stopped server's world (owner-or-admin).
* @description An export (poll GET /exports/{ticket}, then open its download): a Job reads the file, or zips the folder, from the world volume read-only and hands it to felis-api, which streams it to the browser. A file saves under its own name with its length; a folder as NAME.zip, streamed without one, with symbolic links, devices and sockets left out. config/paper-global.yml, the cluster's forwarding secret, is refused as a file and left out of a folder, and server.properties goes out with its rcon.password redacted; both are matched by the file itself, so a link to either under another name is guarded too. The server cannot start until the download has ended. Two file downloads per user at a time, 4 across the install, 30 per user per hour, counted apart from world and backup exports. Audited as file.download.
* @description An export (poll GET /exports/{ticket}, then open its download): a Job reads the file, or zips the folder, from the world volume read-only and hands it to felis-api, which streams it to the browser. A file saves under its own name with its length; a folder as NAME.zip, streamed without one, with symbolic links, devices and sockets left out. config/paper-global.yml, the cluster's forwarding secret, is refused as a file and left out of a folder, and server.properties goes out with its rcon.password and forwarding-secrets redacted; both are matched by the file itself, so a link to either under another name is guarded too. The server cannot start until the download has ended. Two file downloads per user at a time, 4 across the install, 30 per user per hour, counted apart from world and backup exports. Audited as file.download.
*/
post: operations["downloadServerFile"];
delete?: never;
@@ -3035,7 +3035,7 @@ export interface components {
claimable: boolean;
/** @description Present and true when the owner lookup failed, so an absent owner says nothing about whether the server is claimed. */
ownerUnknown?: boolean;
/** @description True for a platform-provisioned system service (the login gate, the lobby). Their reserved names are rejected by every per-server route, so the cockpit renders them read-only instead of offering actions that would 400. */
/** @description True for a platform-provisioned system service (the login gate, the lobby). Staff can manage them through the existing server routes; players see them read-only. Creating, claiming and deleting these reserved names remain prohibited. */
system?: boolean;
};
/** @description A pending retirement (internal/api/repo.go RetireState): the owner gave the server up, or with delete an admin is deleting it. The reaper carries it out on its next daily run: it archives the world as a released backup, deletes the world volume and releases the server, and for a deletion also removes it. Until then the server stays stopped and cannot be woken or claimed. */
@@ -4921,6 +4921,7 @@ export interface operations {
};
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
@@ -4950,6 +4951,7 @@ export interface operations {
};
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
/** @description Quota exceeded. */
403: {
@@ -5627,6 +5629,7 @@ export interface operations {
"application/json": components["schemas"]["ServerInfo"];
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
@@ -7256,7 +7259,7 @@ export interface operations {
* @description Base64-encoded file bytes.
*/
content: string;
/** @description SHA-256 of the file as stored (before the rcon.password redaction in server.properties). Send it back as expect_sha256 on the next write. */
/** @description SHA-256 of the file as stored (before secret redaction in server.properties). Send it back as expect_sha256 on the next write. */
sha256: string;
/** @description SHA-256 of the decoded content as sent (after any redaction). A client that gets content hashing otherwise got it damaged on the way, and reads it again. */
content_sha256: string;
+1
View File
@@ -340,6 +340,7 @@ export function ServerConsole() {
{isAdmin && (
<EditServerDialog
serverName={name}
systemService={name === "login" || name === "lobby"}
currentDisplayName={data.displayName}
currentPolicy={data.autostartPolicy as AutostartPolicy}
currentImage={data.image}
+3 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useParams, useSearchParams } from "react-router-dom";
import {
AlertTriangle,
ArrowUp,
@@ -115,6 +115,7 @@ type Naming =
* instead of letting each call fail. */
export function ServerFiles() {
const { name = "" } = useParams();
const [params] = useSearchParams();
const { t, i18n } = useTranslation("files");
const locale = i18n.language;
const { isAdmin, loading: tierLoading } = useTier();
@@ -128,7 +129,7 @@ export function ServerFiles() {
const owned = canManage(isAdmin, mineQ.data, name);
const stopped = statusQ.data?.phase === "Stopped";
const [dir, setDir] = useState("");
const [dir, setDir] = useState(params.get("path") ?? "");
const [entries, setEntries] = useState<ServerFileEntry[] | null>(null);
const [truncated, setTruncated] = useState(false);
const [listErr, setListErr] = useState<unknown>(null);
+83
View File
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { LobbyPage } from "./LobbyPage";
const calls = vi.hoisted(() => ({
status: vi.fn(), listImages: vi.fn(), readServerFile: vi.fn(), writeServerFile: vi.fn(), createServerFile: vi.fn(), accessPermission: vi.fn(),
}));
vi.mock("@/lib/api", async (original) => ({ ...await original<typeof import("@/lib/api")>(), api: calls }));
vi.mock("@/lib/config", async (original) => ({ ...await original<typeof import("@/lib/config")>(), loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "example.test", gameVersion: "26.3" }) }));
beforeEach(() => {
vi.clearAllMocks();
calls.status.mockImplementation(async (name) => ({ name, subdomain: name, phase: "Stopped", desiredState: "Stopped", ready: false, reaperExempt: true, playersOnline: 0, playersMax: 200 }));
calls.listImages.mockResolvedValue([]);
calls.readServerFile.mockResolvedValue({ content: btoa(JSON.stringify({ menuTitleEn: "Old title", customPlugin: { enabled: true } })), sha256: "read-hash" });
calls.writeServerFile.mockResolvedValue({ sha256: "saved-hash" });
calls.createServerFile.mockResolvedValue({ sha256: "saved-hash" });
});
function page(space = "lobby") {
render(<MemoryRouter initialEntries={[`/admin/lobby?space=${space}`]}><LobbyPage /></MemoryRouter>);
}
describe("LobbyPage", () => {
it("saves with the read hash and preserves unknown plugin settings", async () => {
page();
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
await screen.findByText("Saved. Start this space to apply the settings.");
const [name, path, content, hash] = calls.writeServerFile.mock.calls[0];
expect([name, path, hash]).toEqual(["lobby", "felis-experience.json", "read-hash"]);
expect(JSON.parse(atob(content))).toEqual({ menuTitleEn: "My Network", customPlugin: { enabled: true } });
});
it("uses defaults for a missing file and creates without overwriting a concurrent file", async () => {
calls.readServerFile.mockRejectedValue({ status: 404, code: "not_found" });
page("login");
fireEvent.change(await screen.findByLabelText("Login timeout (30–3600 seconds)"), { target: { value: "900" } });
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
await waitFor(() => expect(calls.createServerFile).toHaveBeenCalled());
expect(calls.createServerFile.mock.calls[0].slice(0, 2)).toEqual(["login", "felis-experience.json"]);
expect(calls.writeServerFile).not.toHaveBeenCalled();
});
it("requires a stop before reading or writing a running space", async () => {
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true });
page();
await screen.findByText(/Stop the lobby to read and save settings/);
expect(calls.readServerFile).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Save settings" })).toBeNull();
});
it("keeps the draft when another editor changed the file", async () => {
calls.writeServerFile.mockRejectedValue({ status: 409, code: "file_changed" });
page();
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
await screen.findByRole("alert");
expect((screen.getByLabelText("English menu title") as HTMLInputElement).value).toBe("My Network");
expect(calls.createServerFile).not.toHaveBeenCalled();
});
it("confirms a switch with unsaved edits", async () => {
page();
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
await userEvent.click(screen.getByRole("button", { name: "Login space" }));
await screen.findByRole("dialog", { name: "Discard unsaved settings?" });
expect(calls.status).not.toHaveBeenCalledWith("login");
await userEvent.click(screen.getByRole("button", { name: "Discard & switch" }));
await screen.findByLabelText("Login book title");
expect(calls.writeServerFile).not.toHaveBeenCalled();
});
it("does not offer to save malformed JSON", async () => {
calls.readServerFile.mockResolvedValue({ content: btoa("{broken"), sha256: "hash" });
page();
await screen.findByRole("alert");
expect(screen.queryByRole("button", { name: "Save settings" })).toBeNull();
});
});
+182
View File
@@ -0,0 +1,182 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { DoorOpen, FolderOpen, Map, Package, Save, Shield, Terminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { PageHeader } from "@/components/PageHeader";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge";
import { PowerButton } from "@/components/PowerButton";
import { CopyAddress } from "@/components/CopyAddress";
import { EditServerDialog } from "@/components/EditServerDialog";
import { ErrorState, Loading } from "@/components/States";
import { InlineError, MessageLine } from "@/components/MessageLine";
import { api, humanizeError } from "@/lib/api";
import { joinAddress } from "@/lib/config";
import { STATUS_POLL_FAST_MS, useAsync, useConfig, usePolling, useUnsavedGuard } from "@/lib/hooks";
import { experienceValid, LOGIN_GROUPS, LOBBY_GROUPS, readExperience, writeExperience, type Experience } from "@/lib/experience";
function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; stopped: boolean; onDirtyChange: (dirty: boolean) => void }) {
const { t } = useTranslation("lobby");
const groups = name === "login" ? LOGIN_GROUPS : LOBBY_GROUPS;
const query = useAsync(() => stopped ? readExperience(name, groups) : Promise.resolve(null), [name, stopped]);
const [draft, setDraft] = useState<{ values: Experience; original: string; sha256: string } | null>(null);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ kind: "error" | "success"; text: string } | null>(null);
useEffect(() => {
if (query.data && !draft) {
setDraft({ ...query.data, original: JSON.stringify(query.data.values) });
}
}, [query.data, draft]);
const dirty = draft !== null && JSON.stringify(draft.values) !== draft.original;
useUnsavedGuard(dirty);
useEffect(() => onDirtyChange(dirty), [dirty, onDirtyChange]);
async function save() {
if (!draft || !stopped || saving || !experienceValid(draft.values, groups)) return;
setSaving(true);
setMessage(null);
try {
const sha256 = await writeExperience(name, draft.values, draft.sha256);
setDraft({ ...draft, original: JSON.stringify(draft.values), sha256 });
setMessage({ kind: "success", text: t("saved") });
} catch (error) {
setMessage({ kind: "error", text: humanizeError(error) });
} finally {
setSaving(false);
}
}
return (
<div className="space-y-4">
{!stopped && <p className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm">{t(name === "login" ? "stop_login" : "stop_lobby")}</p>}
{stopped && query.loading && !draft && <Loading />}
{query.error != null && <ErrorState error={t("read_failed", { reason: humanizeError(query.error) })} onRetry={query.reload} />}
{draft && groups.map((group) => (
<Card key={group.key}>
<CardHeader><CardTitle>{t(group.key)}</CardTitle></CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{group.fields.map((field) => {
const value = draft.values[field.key] ?? field.value;
const id = `experience-${field.key}`;
const set = (next: unknown) => setDraft({ ...draft, values: { ...draft.values, [field.key]: next } });
if (typeof field.value === "boolean") return (
<label key={id} className="flex min-h-11 items-center gap-3 rounded-md border border-border px-3 text-sm" htmlFor={id}>
<input id={id} type="checkbox" checked={value as boolean} onChange={(e) => set(e.target.checked)} disabled={!stopped || saving} className="h-4 w-4 accent-primary" />
{t(field.key)}
</label>
);
return (
<div key={id} className="grid min-w-0 gap-2">
<Label htmlFor={id}>{t(field.key)}</Label>
{field.choices ? (
<select id={id} value={value as string} onChange={(e) => set(e.target.value)} disabled={!stopped || saving} className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm">
{field.choices.map((choice) => <option key={choice} value={choice}>{t(choice)}</option>)}
</select>
) : field.multiline ? (
<textarea id={id} value={value as string} maxLength={512} onChange={(e) => set(e.target.value)} disabled={!stopped || saving} className="min-h-24 w-full rounded-md border border-input bg-background p-3 text-sm" />
) : (
<Input id={id} type={typeof field.value === "number" ? "number" : "text"} value={value as string | number} min={field.min} max={field.max} step={1} maxLength={512} onChange={(e) => set(typeof field.value === "number" ? (e.target.value === "" ? "" : Number(e.target.value)) : e.target.value)} disabled={!stopped || saving} />
)}
</div>
);
})}
</CardContent>
</Card>
))}
{draft && <>
<p className="text-sm text-muted-foreground">{t(name === "login" ? "login_settings_hint" : "lobby_settings_hint")}</p>
{message && <MessageLine kind={message.kind} message={message.text} />}
{!experienceValid(draft.values, groups) && <InlineError message={t("invalid_values")} />}
<Button onClick={() => void save()} disabled={!stopped || !dirty || saving || !experienceValid(draft.values, groups)}><Save className="h-4 w-4" />{t(saving ? "saving" : "save")}</Button>
</>}
</div>
);
}
function BuilderAccess({ ready }: { ready: boolean }) {
const { t } = useTranslation("lobby");
const [player, setPlayer] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<{ kind: "error" | "success"; text: string } | null>(null);
async function grant(value: boolean) {
setBusy(true);
setMessage(null);
try {
const result = await api.accessPermission("lobby", "set", player.trim(), "felis.lobby.build", value);
setMessage({ kind: "success", text: result.output });
} catch (error) {
setMessage({ kind: "error", text: humanizeError(error) });
} finally {
setBusy(false);
}
}
return <Card>
<CardHeader><CardTitle>{t("builder_title")}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">{t("builder_hint")}</p>
<Label htmlFor="builder-player">{t("builder_player")}</Label>
<Input id="builder-player" value={player} onChange={(e) => setPlayer(e.target.value)} placeholder="Steve" />
<div className="flex flex-wrap gap-2">
<Button disabled={!ready || busy || !/^[A-Za-z0-9_]{1,16}$/.test(player.trim())} onClick={() => void grant(true)}>{t("builder_grant")}</Button>
<Button variant="outline" disabled={!ready || busy || !/^[A-Za-z0-9_]{1,16}$/.test(player.trim())} onClick={() => void grant(false)}>{t("builder_revoke")}</Button>
</div>
{!ready && <p className="text-sm text-muted-foreground">{t("builder_running")}</p>}
{message && <MessageLine kind={message.kind} message={message.text} />}
</CardContent>
</Card>;
}
export function LobbyPage() {
const { t } = useTranslation("lobby");
const [params, setParams] = useSearchParams();
const name = params.get("space") === "login" ? "login" : "lobby";
const [dirty, setDirty] = useState(false);
const [nextSpace, setNextSpace] = useState<string | null>(null);
const cfg = useConfig();
const status = useAsync(() => api.status(name), [name]);
usePolling(status.reload, STATUS_POLL_FAST_MS);
const data = status.data;
const links = [
{ icon: Map, title: "map_title", hint: name === "login" ? "login_map_hint" : "lobby_map_hint", to: `/servers/${name}/files` },
{ icon: Package, title: "plugins_title", hint: "plugins_hint", to: `/servers/${name}/files?path=plugins` },
{ icon: FolderOpen, title: "files_title", hint: "files_hint", to: `/servers/${name}/files` },
{ icon: Terminal, title: "console_title", hint: name === "login" ? "login_console_hint" : "console_hint", to: `/servers/${name}` },
{ icon: Shield, title: "backups_title", hint: "backups_hint", to: `/servers/${name}/backups` },
];
return <div className="space-y-6">
<PageHeader icon={DoorOpen} title={t("title")} subtitle={t("subtitle")} />
<Card><CardContent className="flex flex-wrap items-center justify-between gap-4 pt-5">
<div className="space-y-1">
<p className="text-sm font-medium">{t("connection_version", { version: cfg?.gameVersion ?? t("unknown_version") })}</p>
{cfg && <CopyAddress address={joinAddress("login", cfg)} />}
<p className="max-w-2xl text-xs text-muted-foreground">{t("version_hint")}</p>
</div>
<Link to="/admin/images" className="text-sm text-primary hover:underline">{t("images_link")}</Link>
</CardContent></Card>
<div className="flex flex-wrap gap-2" aria-label={t("space_label")}>
{(["lobby", "login"] as const).map((space) => <Button key={space} variant={name === space ? "default" : "outline"} aria-pressed={name === space} onClick={() => { if (space !== name) { if (dirty) setNextSpace(space); else setParams({ space }); } }}>{t(space)}</Button>)}
</div>
<p className="text-sm text-muted-foreground">{t(`${name}_description`)}</p>
{status.loading && !data && <Loading />}
{status.error != null && <ErrorState error={humanizeError(status.error)} onRetry={status.reload} />}
{data && <>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card p-4">
<div className="min-w-0 space-y-2"><div className="flex flex-wrap items-center gap-3"><h2 className="font-semibold">{data.displayName || t(name)}</h2><PhaseBadge phase={shownPhase(data)} /></div><p className="break-all text-xs text-muted-foreground">{data.image}</p></div>
<PowerButton name={name} phase={data.phase} desiredState={data.desiredState} failed={startFailure(data) !== null} playersOnline={data.playersOnline} playerCountUnknown={data.playerCountUnknown} onChanged={status.reload} />
</div>
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<ExperienceSettings key={name} name={name} onDirtyChange={setDirty} stopped={data.phase === "Stopped" && data.desiredState === "Stopped"} />
<div className="space-y-4">
{links.map(({ icon: Icon, title, hint, to }) => <Link key={title} to={to} className="flex gap-3 rounded-lg border border-border bg-card p-4 hover:border-primary/40"><Icon className="mt-0.5 h-5 w-5 shrink-0 text-primary" /><div className="min-w-0"><p className="text-sm font-medium">{t(title)}</p><p className="mt-1 text-xs text-muted-foreground">{t(hint)}</p></div></Link>)}
<EditServerDialog serverName={name} systemService currentDisplayName={data.displayName} currentPolicy={data.autostartPolicy} currentImage={data.image} currentMemory={data.memory} currentStorage={data.storageSize} currentCpu={data.cpu} currentIdleStopSeconds={0} onUpdated={status.reload} />
{name === "lobby" && <BuilderAccess ready={data.ready} />}
</div>
</div>
</>}
<ConfirmDialog open={nextSpace !== null} onOpenChange={(open) => { if (!open) setNextSpace(null); }} title={t("discard_title")} description={t("discard_hint")} confirmLabel={t("discard")} onConfirm={async () => { if (nextSpace) { setParams({ space: nextSpace }); setDirty(false); setNextSpace(null); } }} />
</div>;
}
+2 -2
View File
@@ -532,8 +532,8 @@ function ServerActions({
}
if (server.system) {
// A system service carries a reserved name that every per-server route
// rejects, so offer no actions — just the honest label.
// Staff customize system services in the dedicated lobby view.
if (isAdmin) return <Link className="text-sm text-primary hover:underline" to={`/admin/lobby?space=${server.name}`}>{ts("system_customize")}</Link>;
return (
<span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}>
{ts("system_service")}