feat: initialize panel access before linking Minecraft accounts
Configure connection and storage before creating or resuming the one-time Owner login. Remove Minecraft prerequisites from setup and preserve established login credentials. Let staff preview and confirm roles from configured authentication sources using the existing account-link storage and game UUID mapping. Retain in-game code proof for players, add client-version and lobby guidance, and support NodePort passkey origins.
This commit is contained in:
59 files changed
+1702
-1564
No files matched your search
+42
-1
@@ -1,4 +1,4 @@
|
||||
import { test, expect, t } from "./fixtures";
|
||||
import { test, expect, t, expectFitsScreen } from "./fixtures";
|
||||
|
||||
test("a signed-out visit signs in by email code and returns to the page it asked for", async ({ page }) => {
|
||||
await page.goto("/servers");
|
||||
@@ -54,6 +54,47 @@ test("an admin reaches the user list", async ({ page, signIn }) => {
|
||||
await expect(page.getByText("[email protected]")).toBeVisible();
|
||||
});
|
||||
|
||||
test("an unlinked Owner can manage the panel, then preview and confirm a game role", async ({ page, signIn }) => {
|
||||
const profile = { source: "littleskin", name: "LemonMiaow", profile_uuid: "123456781234423482341234567890ab", mc_uuid: "canonical-role", auth_source: "thirdparty" };
|
||||
let linked = false;
|
||||
let designations = 0;
|
||||
await page.route("**/api/v1/account/link/start", (route) => route.fulfill({ json: { linked } }));
|
||||
await page.route("**/api/v1/account/link/sources", (route) => route.fulfill({ json: { sources: [{ tag: "littleskin", lookup_available: true }] } }));
|
||||
await page.route("**/api/v1/account/link/profile**", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "POST") {
|
||||
expect(request.postDataJSON()).toEqual({ source: profile.source, profile_uuid: profile.profile_uuid });
|
||||
linked = true;
|
||||
designations++;
|
||||
await route.fulfill({ json: { linked: true, mc_uuid: profile.mc_uuid, auth_source: profile.auth_source } });
|
||||
} else {
|
||||
expect(new URL(request.url()).searchParams.get("profile")).toBe("LemonMiaow");
|
||||
await route.fulfill({ json: profile });
|
||||
}
|
||||
});
|
||||
await page.route("**/config.json", (route) => route.fulfill({ json: { apiBase: "/api/v1", rootDomain: "mc.example", gameVersion: "26.3", gamePort: 25570 } }));
|
||||
await signIn("owner");
|
||||
await page.goto("/admin/users");
|
||||
await expect(page.getByRole("heading", { name: t("admin:users_title") })).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/account");
|
||||
await page.getByLabel(t("account:staff_profile")).fill("LemonMiaow");
|
||||
await page.getByRole("button", { name: t("account:staff_lookup") }).click();
|
||||
await expect(page.getByText(profile.profile_uuid)).toBeVisible();
|
||||
expect(designations).toBe(0);
|
||||
await expectFitsScreen(page);
|
||||
await page.getByRole("button", { name: t("account:staff_confirm") }).click();
|
||||
await expect(page.getByText(t("account:staff_linked_desc"))).toBeVisible();
|
||||
expect(designations).toBe(1);
|
||||
|
||||
await page.getByText(t("account:game_guide"), { exact: true }).click();
|
||||
await expect(page.getByText(t("account:game_version", { version: "26.3" }))).toBeVisible();
|
||||
await expect(page.getByText("mc.example:25570", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(t("account:game_lobby"))).toBeVisible();
|
||||
await expectFitsScreen(page);
|
||||
});
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -105,5 +105,23 @@
|
||||
"reauth_sign_in_desc": "Operator accounts can also sign out and sign in again. A fresh sign-in counts for 5 minutes.",
|
||||
"reauth_sign_in_btn": "Sign out and sign in again",
|
||||
"reauth_done_continue": "Confirmed. Press Continue to add the passkey.",
|
||||
"email_change_desc": "Enter the new address and we'll send it a code. Once it's verified, sign-in codes go there, the old address gets a notice, and your other devices are signed out."
|
||||
"email_change_desc": "Enter the new address and we'll send it a code. Once it's verified, sign-in codes go there, the old address gets a notice, and your other devices are signed out.",
|
||||
"staff_linked_desc": "This role is linked to your administrator account and identifies you in-game.",
|
||||
"staff_link_desc": "Link a game role to identify yourself in-game. Panel management is already available; you can do this later.",
|
||||
"staff_sources_loading": "Loading authentication sources…",
|
||||
"staff_source_mojang": "Minecraft official",
|
||||
"staff_source": "Authentication source",
|
||||
"staff_profile": "Minecraft role name or UUID",
|
||||
"staff_lookup": "Look up role",
|
||||
"staff_working": "Working…",
|
||||
"staff_lookup_unsupported": "This source needs api_url configured before roles can be looked up. You can continue using the panel.",
|
||||
"staff_confirm_desc": "Confirm this role as your game identity. It will carry your administrator authority in-game.",
|
||||
"staff_confirm": "Confirm role link",
|
||||
"staff_source_help": "Yggdrasil is Minecraft’s account authentication protocol. Official and third-party providers use it to supply role profiles. Select the source used by your launcher and enter a role name or UUID, not an email.",
|
||||
"game_guide": "Prepare to enter Minecraft",
|
||||
"game_version": "Use Minecraft Java Edition {{version}} for the login server and lobby. Target servers may require another version or mods; check their instructions.",
|
||||
"game_version_unknown": "The login server’s client version is not configured. Ask the operator to confirm it and set game_version; the panel build version does not determine it.",
|
||||
"game_lobby": "The login server verifies your identity before sending you to the lobby. The lobby is a hub for choosing a target server: use /menu and wait for it to start. Check the server list if login or lobby is not ready.",
|
||||
"game_thirdparty": "For a third-party source, configure the same provider in your launcher. Profile lookup reads an identity; joining still requires authentication with that provider.",
|
||||
"staff_code_alternative": "Provider does not support lookup? Use an in-game link code"
|
||||
}
|
||||
@@ -24,14 +24,10 @@
|
||||
"bind_hint_no_address": "In Minecraft (Java Edition), join this server. The login server gives a one-time bind code on your first join; after that, type /link in-game for a new one.",
|
||||
"no_owner_title": "No Owner yet, so nobody can sign in",
|
||||
"no_owner_subtitle": "The Owner is the account that owns this server",
|
||||
"no_owner_intro": "This server has no Owner bound yet. Every sign-in method stays off until one is. Bind one as follows:",
|
||||
"no_owner_step_setup": "On the server, run this command in a terminal. It opens straight onto the Owner binding:",
|
||||
"no_owner_step_join": "In Minecraft (Java Edition), join this address. The login server opens a book with your bind code, and chat shows it too:",
|
||||
"no_owner_step_join_no_address": "In Minecraft (Java Edition), join this server at the address the terminal shows. The login server opens a book with your bind code, and chat shows it too.",
|
||||
"no_owner_ip_fallback": "While the domain does not point at this server yet, join by the server's IP address instead.",
|
||||
"no_owner_step_code": "Type the code into the terminal. The web link in the book is for players; the Owner's code goes into the terminal.",
|
||||
"no_owner_step_link": "Open the setup link the terminal then shows, and set an email and a passkey. After that, you can sign in here.",
|
||||
"no_owner_recheck": "Done binding? Check again",
|
||||
"no_owner_intro": "The host administrator needs to initialize the first Owner login. Minecraft is not required.",
|
||||
"no_owner_step_setup": "Run this command on the host to finish deployment and configure panel access:",
|
||||
"no_owner_step_link": "Open the one-time setup link in your browser, record an email, and create a passkey. Link a Minecraft role later from Account.",
|
||||
"no_owner_recheck": "Finished setup? Check again",
|
||||
"no_owner_rechecking": "Checking…",
|
||||
"no_owner_still_unbound": "There is still no Owner. Check that the terminal has shown the setup link.",
|
||||
"bind_btn": "Verify & Sign In",
|
||||
@@ -67,5 +63,6 @@
|
||||
"setup_create_passkey": "Create passkey",
|
||||
"setup_registering": "Registering…",
|
||||
"setup_default_passkey_name": "Default passkey",
|
||||
"session_ended_notice": "Your session expired or was revoked. Sign in again to go back to the page you were on."
|
||||
"session_ended_notice": "Your session expired or was revoked. Sign in again to go back to the page you were on.",
|
||||
"setup_game_optional": "Finish login setup to open the panel. A Minecraft role can be linked later from Account."
|
||||
}
|
||||
@@ -49,5 +49,6 @@
|
||||
"go_to_servers_btn": "Manage My Servers",
|
||||
"fleet_flat_webgl": "WebGL is unavailable in this browser, so the fleet is shown flat.",
|
||||
"fleet_flat_error": "The 3D view failed to load, so the fleet is shown flat.",
|
||||
"fleet_flat_empty": "No servers yet"
|
||||
"fleet_flat_empty": "No servers yet",
|
||||
"staff_unlinked_desc": "No Minecraft role is linked yet. Panel management is available; choose an authentication source and link a role from Account when you are ready to play."
|
||||
}
|
||||
@@ -135,5 +135,9 @@
|
||||
"schedule_limit": "This server already has as many scheduled tasks as it can hold. Delete one first.",
|
||||
"schedule_running": "This task is running right now. Try again once the run finishes.",
|
||||
"schedule_stale": "The server has a new owner since this task was saved. Save the task again before running it.",
|
||||
"bad_schedule": "The task was not saved: {{detail}}"
|
||||
"bad_schedule": "The task was not saved: {{detail}}",
|
||||
"auth_source_unknown": "Select a configured authentication source.",
|
||||
"auth_source_unavailable": "The authentication source is unavailable or returned an invalid role. Try again later.",
|
||||
"auth_source_lookup_unsupported": "Role lookup is not configured for this authentication source.",
|
||||
"minecraft_profile_not_found": "No role matched in this source. Check the role name or UUID."
|
||||
}
|
||||
@@ -104,5 +104,23 @@
|
||||
"reauth_sign_in_desc": "管理员账户也可以退出后重新登录,重新登录后 5 分钟内视为已验证。",
|
||||
"reauth_sign_in_btn": "退出并重新登录",
|
||||
"reauth_done_continue": "已确认。点“继续”添加 Passkey。",
|
||||
"email_change_desc": "输入新邮箱,我们会向新地址发送验证码。验证通过后登录验证码改发到新邮箱,旧邮箱会收到通知,其它设备会退出登录。"
|
||||
"email_change_desc": "输入新邮箱,我们会向新地址发送验证码。验证通过后登录验证码改发到新邮箱,旧邮箱会收到通知,其它设备会退出登录。",
|
||||
"staff_linked_desc": "此角色已关联到你的管理员账户,进服时将识别你的管理身份。",
|
||||
"staff_link_desc": "关联游戏角色后,进服时可识别你的管理身份。你现在已能使用全部面板管理功能,可以稍后再关联。",
|
||||
"staff_sources_loading": "正在读取认证源…",
|
||||
"staff_source_mojang": "Minecraft 正版",
|
||||
"staff_source": "认证源",
|
||||
"staff_profile": "Minecraft 角色名或 UUID",
|
||||
"staff_lookup": "查询角色",
|
||||
"staff_working": "处理中…",
|
||||
"staff_lookup_unsupported": "此认证源需要配置 api_url 才能查询角色。你仍可继续管理面板。",
|
||||
"staff_confirm_desc": "确认将这个角色关联到你的管理员账户,进服时赋予对应的管理员身份。",
|
||||
"staff_confirm": "确认关联此角色",
|
||||
"staff_source_help": "“世界树”(Yggdrasil)是 Minecraft 的账号认证协议。正版与第三方认证站都通过它提供角色信息;请选择实际登录游戏所用的认证源,输入的是角色名或 UUID,不是邮箱。",
|
||||
"game_guide": "准备进入 Minecraft",
|
||||
"game_version": "使用 Minecraft Java 版 {{version}} 连接登录服和大厅。目标服务器可能需要不同版本或模组,请查看对应服务器说明。",
|
||||
"game_version_unknown": "登录服的客户端版本尚未配置,请由运维确认后填写 game_version;不要按面板版本猜测。",
|
||||
"game_lobby": "连接后先经过登录服验证身份,随后进入大厅。大厅是选择目标服务器的中转站,可用 /menu 选择服务器并等待它启动;登录服或大厅未就绪时,可在服务列表查看状态。",
|
||||
"game_thirdparty": "使用第三方认证源时,启动器也须配置同一个认证站。角色查询只能读取身份,进服时仍需通过该站的登录验证。",
|
||||
"staff_code_alternative": "认证站不支持查询?也可以使用游戏内链接码"
|
||||
}
|
||||
@@ -24,14 +24,10 @@
|
||||
"bind_hint_no_address": "用 Minecraft Java 版加入本服务器。第一次进入时登录服会给出一次性绑定码,之后在游戏内输入 /link 获取新的。",
|
||||
"no_owner_title": "还没有 Owner,暂时无法登录",
|
||||
"no_owner_subtitle": "Owner 是这台服务器的所有者账号",
|
||||
"no_owner_intro": "这台服务器还没有绑定 Owner。绑定完成前,所有登录方式都处于关闭状态。按以下步骤完成绑定:",
|
||||
"no_owner_step_setup": "在服务器终端运行下面的命令,它会直接进入 Owner 绑定:",
|
||||
"no_owner_step_join": "用 Minecraft Java 版加入下面的地址。登录服会打开一本书,并在聊天栏显示绑定码:",
|
||||
"no_owner_step_join_no_address": "用 Minecraft Java 版加入这台服务器,地址显示在终端里。登录服会打开一本书,并在聊天栏显示绑定码。",
|
||||
"no_owner_ip_fallback": "域名还没有解析到这台服务器时,改用服务器的 IP 地址加入。",
|
||||
"no_owner_step_code": "把绑定码输入终端。书里的网页链接供玩家使用,Owner 的绑定码要输入终端。",
|
||||
"no_owner_step_link": "打开终端随后显示的设置链接,设置邮箱和通行密钥。完成后就能在这里登录。",
|
||||
"no_owner_recheck": "已完成绑定,重新检查",
|
||||
"no_owner_intro": "这台服务器尚未创建 Owner 登录。主机管理员完成以下步骤后即可使用面板,无需启动 Minecraft。",
|
||||
"no_owner_step_setup": "在服务器终端运行下面的命令,完成部署与面板访问设置:",
|
||||
"no_owner_step_link": "用浏览器打开终端给出的一次性设置链接,登记邮箱并创建通行密钥。之后可在账户页关联 Minecraft 角色。",
|
||||
"no_owner_recheck": "已完成设置,重新检查",
|
||||
"no_owner_rechecking": "检查中…",
|
||||
"no_owner_still_unbound": "还没有检测到 Owner。请确认终端已经显示设置链接。",
|
||||
"bind_btn": "验证并登录",
|
||||
@@ -67,5 +63,6 @@
|
||||
"setup_create_passkey": "创建通行密钥",
|
||||
"setup_registering": "注册中…",
|
||||
"setup_default_passkey_name": "默认通行密钥",
|
||||
"session_ended_notice": "你的登录已过期或已被撤销,请重新登录。登录后会回到刚才的页面。"
|
||||
"session_ended_notice": "你的登录已过期或已被撤销,请重新登录。登录后会回到刚才的页面。",
|
||||
"setup_game_optional": "完成登录设置即可进入管理面板。Minecraft 角色可以稍后在账户页关联。"
|
||||
}
|
||||
@@ -49,5 +49,6 @@
|
||||
"go_to_servers_btn": "操作我的服务器",
|
||||
"fleet_flat_webgl": "浏览器未启用 WebGL,已切换为平面视图。",
|
||||
"fleet_flat_error": "3D 视图加载失败,已切换为平面视图。",
|
||||
"fleet_flat_empty": "还没有服务器"
|
||||
"fleet_flat_empty": "还没有服务器",
|
||||
"staff_unlinked_desc": "Minecraft 角色尚未关联。面板管理功能已可使用;准备进入游戏时,可在账户页选择认证源并关联角色。"
|
||||
}
|
||||
@@ -135,5 +135,9 @@
|
||||
"schedule_limit": "这台服务器的计划任务数量已达上限,请先删除一个。",
|
||||
"schedule_running": "这个任务正在执行,请等本次执行结束后再试。",
|
||||
"schedule_stale": "保存这个任务后服务器换了所有者,请先重新保存任务再执行。",
|
||||
"bad_schedule": "计划任务未保存:{{detail}}"
|
||||
"bad_schedule": "计划任务未保存:{{detail}}",
|
||||
"auth_source_unknown": "请选择已配置的认证源。",
|
||||
"auth_source_unavailable": "认证源暂时不可用或返回了无效角色,请稍后重试。",
|
||||
"auth_source_lookup_unsupported": "此认证源尚未配置角色查询地址。",
|
||||
"minecraft_profile_not_found": "在所选认证源中没有找到这个角色,请检查角色名或 UUID。"
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import type {
|
||||
Identity,
|
||||
KickResult,
|
||||
LinkResult,
|
||||
MinecraftAuthSource,
|
||||
MinecraftProfile,
|
||||
LinkStatus,
|
||||
BindResult,
|
||||
PasskeyCredential,
|
||||
@@ -943,6 +945,14 @@ export const api = rejectingSync({
|
||||
linkVerify: (code: string) =>
|
||||
request<LinkResult>("POST", "/account/link/verify", { code }),
|
||||
|
||||
linkSources: () => request<{ sources: MinecraftAuthSource[] }>("GET", "/account/link/sources"),
|
||||
|
||||
lookupProfile: (source: string, profile: string) =>
|
||||
request<MinecraftProfile>("GET", `/account/link/profile?${new URLSearchParams({ source, profile })}`),
|
||||
|
||||
linkProfile: (source: string, profile_uuid: string) =>
|
||||
request<LinkResult>("POST", "/account/link/profile", { source, profile_uuid }),
|
||||
|
||||
emailStart: (email: string) =>
|
||||
request<{ sent: boolean; expires_at: string }>("POST", "/account/email/start", { email }),
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ afterEach(() => {
|
||||
|
||||
describe("loadConfig", () => {
|
||||
it("reads the server's config and build stamp", async () => {
|
||||
serve({ apiBase: "/api/v1", rootDomain: "mc.example", adminHostname: "op.console.mc.example", build });
|
||||
serve({ apiBase: "/api/v1", rootDomain: "mc.example", adminHostname: "op.console.mc.example", gameVersion: "26.3", build });
|
||||
const { loadConfig } = await freshConfig();
|
||||
|
||||
const cfg = await loadConfig();
|
||||
@@ -37,6 +37,7 @@ describe("loadConfig", () => {
|
||||
rootDomain: "mc.example",
|
||||
panelHostname: undefined,
|
||||
adminHostname: "op.console.mc.example",
|
||||
gameVersion: "26.3",
|
||||
build,
|
||||
});
|
||||
expect(cfg.fallback).toBeUndefined();
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface RuntimeConfig {
|
||||
adminHostname?: string;
|
||||
/** The public Minecraft port, absent when it is the default 25565. */
|
||||
gamePort?: number;
|
||||
/** Login/lobby Minecraft protocol, unknown for unconfigured custom images. */
|
||||
gameVersion?: string;
|
||||
/** What the server runs, for the version badge. */
|
||||
build?: BuildInfo;
|
||||
/** /config.json could not be read, so these are build-time defaults and
|
||||
@@ -85,6 +87,7 @@ export async function loadConfig(): Promise<RuntimeConfig> {
|
||||
panelHostname: raw.panelHostname,
|
||||
adminHostname: raw.adminHostname,
|
||||
gamePort: parsePort(raw.gamePort),
|
||||
gameVersion: typeof raw.gameVersion === "string" && raw.gameVersion ? raw.gameVersion : undefined,
|
||||
build: parseBuild(raw.build),
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -1114,8 +1114,8 @@ export interface paths {
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Report whether an Owner has been bound on this install.
|
||||
* @description Public, pre-session probe the sign-in page reads on load. Until `felis setup` binds an Owner, local sign-in is off and every login door answers 403 local_auth_disabled; the page then explains that no Owner exists and how to bind one instead of offering the doors. It discloses only whether the install is still unclaimed, and claiming it needs root on the host. It is not gated on local_auth_enabled and does not draw on the login doors' per-address rate limit.
|
||||
* Report whether an Owner has been created on this install.
|
||||
* @description Public, pre-session probe the sign-in page reads on load. Until `felis setup` creates an Owner, local sign-in is off and every login door answers 403 local_auth_disabled; the page then explains that no Owner exists and how to create one instead of offering the doors. It discloses only whether the install is still unclaimed, and claiming it needs root on the host. It is not gated on local_auth_enabled and does not draw on the login doors' per-address rate limit.
|
||||
*/
|
||||
get: operations["ownerStatus"];
|
||||
put?: never;
|
||||
@@ -1938,6 +1938,47 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/account/link/sources": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List configured sources for staff game-role designation. */
|
||||
get: operations["linkSources"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/account/link/profile": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Look up a role by name or native UUID in a selected authentication source.
|
||||
* @description Staff-only preview; role lookup does not prove account ownership and creates no binding.
|
||||
*/
|
||||
get: operations["lookupProfile"];
|
||||
put?: never;
|
||||
/**
|
||||
* Designate a role as the authenticated staff account's game identity.
|
||||
* @description Requires a fresh login factor. Re-queries the native UUID, maps it on the server, and binds only to the caller. Other users' bindings cannot be overwritten. Panel initialization does not require this operation.
|
||||
*/
|
||||
post: operations["linkProfile"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/account/link/start": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -8949,6 +8990,153 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
linkSources: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Sources in game-authentication priority order; no upstream URLs are exposed. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
sources: {
|
||||
tag: string;
|
||||
lookup_available: boolean;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
};
|
||||
};
|
||||
lookupProfile: {
|
||||
parameters: {
|
||||
query: {
|
||||
source: string;
|
||||
profile: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Role found. mc_uuid uses the exact same per-source mapping as game authentication. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
source: string;
|
||||
name: string;
|
||||
profile_uuid: string;
|
||||
/** Format: uuid */
|
||||
mc_uuid: string;
|
||||
/** @enum {string} */
|
||||
auth_source: "mojang" | "thirdparty";
|
||||
};
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description No matching role in the selected source. */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Source returned an invalid or mismatched profile. */
|
||||
502: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Source unavailable. */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
linkProfile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
source: string;
|
||||
/** Format: uuid */
|
||||
profile_uuid: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Role linked, idempotently for the same user and UUID. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
linked: boolean;
|
||||
/** Format: uuid */
|
||||
mc_uuid: string;
|
||||
/** @enum {string} */
|
||||
auth_source: "mojang" | "thirdparty";
|
||||
};
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description Role no longer exists. */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Role already linked to another user */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Source returned an invalid or mismatched profile. */
|
||||
502: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Source unavailable. */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
linkStart: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -357,6 +357,19 @@ export interface CreateServerRequest {
|
||||
};
|
||||
}
|
||||
|
||||
export interface MinecraftAuthSource {
|
||||
tag: string;
|
||||
lookup_available: boolean;
|
||||
}
|
||||
|
||||
export interface MinecraftProfile {
|
||||
source: string;
|
||||
name: string;
|
||||
profile_uuid: string;
|
||||
mc_uuid: string;
|
||||
auth_source: string;
|
||||
}
|
||||
|
||||
/** LinkStatus projects POST /account/link/start (spec §10): whether the caller's
|
||||
* session is already bound to a Minecraft identity. The endpoint also returns an
|
||||
* API-consumer `instructions` string; the panel renders its own player-facing copy
|
||||
|
||||
@@ -9,6 +9,11 @@ import { Account } from "./Account";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
passkeyList: vi.fn(),
|
||||
linkStatus: vi.fn(),
|
||||
linkSources: vi.fn(),
|
||||
lookupProfile: vi.fn(),
|
||||
linkProfile: vi.fn(),
|
||||
linkVerify: vi.fn(),
|
||||
passkeyDelete: vi.fn(),
|
||||
listMySessions: vi.fn(),
|
||||
migrateStatus: vi.fn(),
|
||||
@@ -22,7 +27,11 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
linkStatus: () => Promise.resolve({ linked: true }),
|
||||
linkStatus: mocks.linkStatus,
|
||||
linkSources: mocks.linkSources,
|
||||
lookupProfile: mocks.lookupProfile,
|
||||
linkProfile: mocks.linkProfile,
|
||||
linkVerify: mocks.linkVerify,
|
||||
migrateStatus: mocks.migrateStatus,
|
||||
migrateIssueCode: mocks.migrateIssueCode,
|
||||
passkeyList: mocks.passkeyList,
|
||||
@@ -68,6 +77,11 @@ function renderAccount() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.linkStatus.mockReset().mockResolvedValue({ linked: true });
|
||||
mocks.linkSources.mockReset().mockResolvedValue({ sources: [{ tag: "littleskin", lookup_available: true }] });
|
||||
mocks.lookupProfile.mockReset();
|
||||
mocks.linkProfile.mockReset();
|
||||
mocks.linkVerify.mockReset();
|
||||
mocks.passkeyList.mockReset();
|
||||
mocks.passkeyDelete.mockReset();
|
||||
mocks.listMySessions.mockReset();
|
||||
@@ -206,3 +220,61 @@ describe("Account migration", () => {
|
||||
expect(screen.queryByPlaceholderText(t("account:migration_target_placeholder"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("staff Minecraft role designation", () => {
|
||||
const profile = { source: "littleskin", name: "LemonMiaow", profile_uuid: "123456781234423482341234567890ab", mc_uuid: "canonical-role", auth_source: "thirdparty" };
|
||||
beforeEach(() => {
|
||||
mocks.identity = { ...identity(true), role: "owner", is_admin: true, is_owner: true };
|
||||
mocks.linkStatus.mockResolvedValue({ linked: false });
|
||||
mocks.passkeyList.mockResolvedValue({ credentials: [laptop] });
|
||||
mocks.lookupProfile.mockResolvedValue(profile);
|
||||
mocks.linkProfile.mockResolvedValue({ linked: true, mc_uuid: profile.mc_uuid });
|
||||
});
|
||||
|
||||
async function previewRole() {
|
||||
await userEvent.type(await screen.findByLabelText(t("account:staff_profile")), "LemonMiaow");
|
||||
await userEvent.click(screen.getByRole("button", { name: t("account:staff_lookup") }));
|
||||
await screen.findByRole("button", { name: t("account:staff_confirm") });
|
||||
}
|
||||
|
||||
it("previews a role in the chosen source before confirmation writes a binding", async () => {
|
||||
renderAccount();
|
||||
await previewRole();
|
||||
expect(mocks.lookupProfile).toHaveBeenCalledWith("littleskin", "LemonMiaow");
|
||||
expect(screen.getByText(profile.profile_uuid)).toBeTruthy();
|
||||
expect(mocks.linkProfile).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: t("account:staff_confirm") }));
|
||||
expect(mocks.linkProfile).toHaveBeenCalledWith("littleskin", profile.profile_uuid);
|
||||
expect(await screen.findByText(t("account:staff_linked_desc"))).toBeTruthy();
|
||||
expect(mocks.linkVerify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a preview when the input changes", async () => {
|
||||
renderAccount();
|
||||
await previewRole();
|
||||
await userEvent.type(screen.getByLabelText(t("account:staff_profile")), "2");
|
||||
expect(screen.queryByRole("button", { name: t("account:staff_confirm") })).toBeNull();
|
||||
expect(mocks.linkProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the role preview and explains a refused binding", async () => {
|
||||
mocks.linkProfile.mockRejectedValue({ status: 409, code: "already_linked", message: "" });
|
||||
renderAccount();
|
||||
await previewRole();
|
||||
await userEvent.click(screen.getByRole("button", { name: t("account:staff_confirm") }));
|
||||
expect((await screen.findByRole("alert")).textContent).toBe(t("errors:already_linked"));
|
||||
expect(screen.getByText(profile.profile_uuid)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("retains game-code proof for players", async () => {
|
||||
mocks.identity = identity(true);
|
||||
mocks.linkVerify.mockResolvedValue({ linked: true, mc_uuid: profile.mc_uuid });
|
||||
renderAccount();
|
||||
await userEvent.type(await screen.findByLabelText(t("account:link_code")), "abcd1234");
|
||||
await userEvent.click(screen.getByRole("button", { name: t("account:verify_btn") }));
|
||||
expect(mocks.linkVerify).toHaveBeenCalledWith("ABCD1234");
|
||||
expect(mocks.linkSources).not.toHaveBeenCalled();
|
||||
expect(mocks.linkProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+118
-17
@@ -11,13 +11,16 @@ import { MessageLine, InlineError } from "@/components/MessageLine";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { api, clientError, humanizeError } from "@/lib/api";
|
||||
import { formatAbsolute } from "@/lib/format";
|
||||
import type { PasskeyCredential } from "@/lib/types";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import type { MinecraftProfile, PasskeyCredential } from "@/lib/types";
|
||||
import { useAsync, useConfig } from "@/lib/hooks";
|
||||
import { AccountSessionsCard } from "@/pages/AccountSessions";
|
||||
import { isReauthCancelled, isReauthRequired, useReauth } from "@/components/ReauthDialog";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { requestAssertion } from "@/lib/passkey";
|
||||
import { entryAddress } from "@/lib/config";
|
||||
import { CopyAddress } from "@/components/CopyAddress";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -28,11 +31,8 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
// The Account page is the web half of the §10 link flow. A code is born in-game
|
||||
// (online-mode auth proves the UUID) and consumed here (the session proves the
|
||||
// user) — so this page only ever reports status and redeems a code; it can never
|
||||
// originate a binding. The Minecraft-link card is a small state machine: checking
|
||||
// → linked, or → the two-step "get a code in-game, enter it here" form.
|
||||
// Players redeem a code proven in-game. Staff can designate a role from a
|
||||
// configured authentication source after their panel login is established.
|
||||
|
||||
export function Account() {
|
||||
const status = useAsync(() => api.linkStatus(), []);
|
||||
@@ -293,6 +293,7 @@ export function Account() {
|
||||
}
|
||||
}
|
||||
|
||||
const codeForm = <LinkForm code={code} setCode={setCode} submitting={submitting} error={error} onSubmit={submit} />;
|
||||
return (
|
||||
<>
|
||||
<PageHeader icon={UserRound} title={t("title")} subtitle={t("subtitle")} />
|
||||
@@ -305,20 +306,23 @@ export function Account() {
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
{linked ? (
|
||||
<LinkedState uuid={verifiedUUID} />
|
||||
<LinkedState uuid={verifiedUUID} staff={identity?.is_admin === true} />
|
||||
) : status.loading && !status.data ? (
|
||||
<Loading label={t("checking_link")} />
|
||||
) : status.error ? (
|
||||
<ErrorState error={status.error} onRetry={status.reload} />
|
||||
) : identity?.is_admin ? (
|
||||
<StaffLinkForm onLinked={(uuid) => { setVerifiedUUID(uuid); void refresh(); }} />
|
||||
) : (
|
||||
<LinkForm
|
||||
code={code}
|
||||
setCode={setCode}
|
||||
submitting={submitting}
|
||||
error={error}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
codeForm
|
||||
)}
|
||||
{identity?.is_admin && !linked && (
|
||||
<details className="mt-4 space-y-3">
|
||||
<summary className="cursor-pointer text-muted-foreground">{t("staff_code_alternative")}</summary>
|
||||
{codeForm}
|
||||
</details>
|
||||
)}
|
||||
<MinecraftGuide />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -575,7 +579,7 @@ export function Account() {
|
||||
/** LinkedState confirms the binding. The UUID is shown only when this session
|
||||
* just verified it (start does not return it), so a pre-existing link renders
|
||||
* the confirmation without a UUID rather than inventing one. */
|
||||
function LinkedState({ uuid }: { uuid: string | null }) {
|
||||
function LinkedState({ uuid, staff }: { uuid: string | null; staff: boolean }) {
|
||||
const { t } = useTranslation("account");
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -583,7 +587,7 @@ function LinkedState({ uuid }: { uuid: string | null }) {
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
{t("linked_title")}
|
||||
</div>
|
||||
<p className="text-muted-foreground">{t("linked_desc")}</p>
|
||||
<p className="text-muted-foreground">{t(staff ? "staff_linked_desc" : "linked_desc")}</p>
|
||||
{uuid && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-xs uppercase tracking-wide">{t("uuid_label")}</span>
|
||||
@@ -596,6 +600,103 @@ function LinkedState({ uuid }: { uuid: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StaffLinkForm({ onLinked }: { onLinked: (uuid: string) => void }) {
|
||||
const { t } = useTranslation("account");
|
||||
const sources = useAsync(() => api.linkSources(), []);
|
||||
const reauth = useReauth();
|
||||
const [selected, setSelected] = useState("");
|
||||
const [input, setInput] = useState("");
|
||||
const [preview, setPreview] = useState<MinecraftProfile | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const source = selected || sources.data?.sources[0]?.tag || "";
|
||||
const available = sources.data?.sources.find((item) => item.tag === source)?.lookup_available;
|
||||
|
||||
async function lookup(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (busy || !input.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setPreview(null);
|
||||
try {
|
||||
setPreview(await api.lookupProfile(source, input.trim()));
|
||||
} catch (err) {
|
||||
setError(humanizeError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (busy || !preview) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await reauth.guard(() => api.linkProfile(preview.source, preview.profile_uuid));
|
||||
onLinked(result.mc_uuid);
|
||||
} catch (err) {
|
||||
if (!isReauthCancelled(err)) setError(humanizeError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (sources.loading && !sources.data) return <Loading label={t("staff_sources_loading")} />;
|
||||
if (sources.error) return <ErrorState error={sources.error} onRetry={sources.reload} />;
|
||||
|
||||
const sourceLabel = (tag: string) => tag === "mojang" ? t("staff_source_mojang") : tag;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">{t("staff_link_desc")}</p>
|
||||
<p className="text-muted-foreground">{t("staff_source_help")}</p>
|
||||
<form onSubmit={lookup} className="space-y-3 max-w-lg">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-source">{t("staff_source")}</Label>
|
||||
<Select value={source} disabled={busy} onValueChange={(value) => { setSelected(value); setPreview(null); setError(null); }}>
|
||||
<SelectTrigger id="role-source"><SelectValue placeholder={t("staff_source")} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{sources.data?.sources.map((item) => <SelectItem key={item.tag} value={item.tag}>{sourceLabel(item.tag)}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-profile">{t("staff_profile")}</Label>
|
||||
<Input id="role-profile" value={input} disabled={busy} maxLength={64} autoComplete="off" spellCheck={false}
|
||||
onChange={(e) => { setInput(e.target.value); setPreview(null); setError(null); }} />
|
||||
</div>
|
||||
{available === false && <p className="text-muted-foreground">{t("staff_lookup_unsupported")}</p>}
|
||||
<Button type="submit" disabled={busy || !available || !input.trim()}>{t(busy ? "staff_working" : "staff_lookup")}</Button>
|
||||
</form>
|
||||
{preview && (
|
||||
<div className="space-y-3 rounded-md border p-4 max-w-lg">
|
||||
<p className="font-medium">{preview.name}</p>
|
||||
<p className="text-muted-foreground">{sourceLabel(preview.source)}</p>
|
||||
<code className="block break-all font-mono text-xs">{preview.profile_uuid}</code>
|
||||
<p className="text-muted-foreground">{t("staff_confirm_desc")}</p>
|
||||
<Button disabled={busy} onClick={() => void confirm()}>{t(busy ? "staff_working" : "staff_confirm")}</Button>
|
||||
</div>
|
||||
)}
|
||||
<InlineError message={error} />
|
||||
{reauth.dialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MinecraftGuide() {
|
||||
const { t } = useTranslation("account");
|
||||
const cfg = useConfig();
|
||||
if (!cfg) return null;
|
||||
return (
|
||||
<details className="mt-4 border-t pt-4 space-y-3">
|
||||
<summary className="cursor-pointer font-medium">{t("game_guide")}</summary>
|
||||
<p className="text-muted-foreground">{cfg.gameVersion ? t("game_version", { version: cfg.gameVersion }) : t("game_version_unknown")}</p>
|
||||
{!cfg.fallback && <CopyAddress address={entryAddress(cfg)} />}
|
||||
<p className="text-muted-foreground">{t("game_lobby")}</p>
|
||||
<p className="text-muted-foreground">{t("game_thirdparty")}</p>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** LinkForm is the two-step redemption UX: get a code in-game, then enter it. The
|
||||
* input auto-uppercases for instant feedback; the server also trims + uppercases,
|
||||
* so this is cosmetic, not the source of truth. */
|
||||
|
||||
@@ -103,7 +103,7 @@ export function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
function LinkCard({ link }: { link: AsyncState<{ linked: boolean }> }) {
|
||||
function LinkCard({ link, staff }: { link: AsyncState<{ linked: boolean }>; staff: boolean }) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
if (link.error) {
|
||||
@@ -164,11 +164,11 @@ function LinkCard({ link }: { link: AsyncState<{ linked: boolean }> }) {
|
||||
<>
|
||||
<div className="space-y-1 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-500 font-semibold text-sm">
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 animate-bounce" />
|
||||
<AlertTriangle className="h-5 w-5 shrink-0" />
|
||||
<span>{t("account_unlinked_title")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("account_unlinked_desc")}
|
||||
{t(staff ? "staff_unlinked_desc" : "account_unlinked_desc")}
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/account" className="shrink-0 w-full sm:w-auto">
|
||||
@@ -290,7 +290,7 @@ function FleetView({
|
||||
{/* 1. 游戏角色绑定 Banner */}
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<LinkCard link={link} />
|
||||
<LinkCard link={link} staff={isAdmin} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -215,8 +215,7 @@ describe("operator sign-in", () => {
|
||||
});
|
||||
|
||||
|
||||
// Until `felis setup` binds an Owner every door answers "disabled", so the page says
|
||||
// why and how to bind one, naming the address to join in Minecraft.
|
||||
// Host setup creates the Owner before any game identity is linked.
|
||||
describe("an install with no Owner", () => {
|
||||
const NO_OWNER = () => t("auth:no_owner_title");
|
||||
|
||||
@@ -224,35 +223,19 @@ describe("an install with no Owner", () => {
|
||||
calls.authOwnerStatus.mockResolvedValue({ owner_bound: false });
|
||||
});
|
||||
|
||||
it("explains why nobody can sign in, with the command and the address to join", async () => {
|
||||
it("guides the administrator from host setup to the browser without entering Minecraft", async () => {
|
||||
config.value = { apiBase: "/api/v1", rootDomain: "203.0.113.7.nip.io", gamePort: 25570 };
|
||||
await renderLogin(NO_OWNER());
|
||||
|
||||
expect(screen.getByText("sudo felis setup")).toBeTruthy();
|
||||
expect(await screen.findByText("203.0.113.7:25570")).toBeTruthy();
|
||||
expect(screen.queryByText(t("auth:no_owner_ip_fallback"))).toBeNull();
|
||||
// None of the doors that cannot work is offered.
|
||||
expect(screen.getByText(t("auth:no_owner_step_link"))).toBeTruthy();
|
||||
expect(screen.queryByText("203.0.113.7:25570")).toBeNull();
|
||||
expect(screen.queryByText("/link")).toBeNull();
|
||||
expect(screen.queryByLabelText(t("auth:email_address"))).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: t("auth:passkey_btn") })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: t("auth:tab_bind_btn") })).toBeNull();
|
||||
});
|
||||
|
||||
it("offers the IP when the address is a domain name", async () => {
|
||||
config.value = { apiBase: "/api/v1", rootDomain: "mc.example" };
|
||||
await renderLogin(NO_OWNER());
|
||||
|
||||
expect(await screen.findByText("mc.example")).toBeTruthy();
|
||||
expect(screen.getByText(t("auth:no_owner_ip_fallback"))).toBeTruthy();
|
||||
});
|
||||
|
||||
it("points at the terminal for the address when config.json could not be read", async () => {
|
||||
config.value = { apiBase: "/api/v1", rootDomain: "localhost", fallback: true };
|
||||
await renderLogin(NO_OWNER());
|
||||
|
||||
expect(screen.getByText(t("auth:no_owner_step_join_no_address"))).toBeTruthy();
|
||||
expect(screen.queryByText("localhost")).toBeNull();
|
||||
});
|
||||
|
||||
it("checks again on request and shows the doors once an Owner is bound", async () => {
|
||||
calls.authOwnerStatus
|
||||
.mockResolvedValueOnce({ owner_bound: false })
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { loginReturnPath } from "@/lib/auth";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { entryAddress, isIPAddress, loadConfig } from "@/lib/config";
|
||||
import { entryAddress, loadConfig } from "@/lib/config";
|
||||
import { CopyAddress } from "@/components/CopyAddress";
|
||||
import { requestAssertion } from "@/lib/passkey";
|
||||
import { InlineError } from "@/components/MessageLine";
|
||||
@@ -111,7 +111,7 @@ export function Login() {
|
||||
}
|
||||
if (identity) return <Navigate to={next} replace />;
|
||||
if (ownerBound === false) {
|
||||
return <NoOwnerNotice joinAddr={joinAddr} onBound={() => setOwnerBound(true)} />;
|
||||
return <NoOwnerNotice onBound={() => setOwnerBound(true)} />;
|
||||
}
|
||||
|
||||
async function handleBindSubmit(e: FormEvent) {
|
||||
@@ -595,11 +595,11 @@ export function Login() {
|
||||
);
|
||||
}
|
||||
|
||||
// NoOwnerNotice replaces the doors on an install `felis setup` has not bound an Owner
|
||||
// NoOwnerNotice replaces the doors on an install `felis setup` has not created an Owner
|
||||
// on. Local sign-in is off until it does, so every door would answer "disabled"; this
|
||||
// says why and walks through the binding, which happens in the server's terminal plus
|
||||
// one Minecraft join. The steps match the terminal's own bind screen (tui_mc_bind.go).
|
||||
function NoOwnerNotice({ joinAddr, onBound }: { joinAddr: string; onBound: () => void }) {
|
||||
// says why and walks through provisioning in the server's terminal plus
|
||||
// the browser handoff produced by the host setup console.
|
||||
function NoOwnerNotice({ onBound }: { onBound: () => void }) {
|
||||
const { t } = useTranslation("auth");
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
@@ -628,18 +628,6 @@ function NoOwnerNotice({ joinAddr, onBound }: { joinAddr: string; onBound: () =>
|
||||
sudo felis setup
|
||||
</code>
|
||||
</>,
|
||||
joinAddr ? (
|
||||
<>
|
||||
<p>{t("no_owner_step_join")}</p>
|
||||
<CopyAddress address={joinAddr} className="mt-1" />
|
||||
{!isIPAddress(joinAddr) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground/80">{t("no_owner_ip_fallback")}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p>{t("no_owner_step_join_no_address")}</p>
|
||||
),
|
||||
<p>{t("no_owner_step_code")}</p>,
|
||||
<p>{t("no_owner_step_link")}</p>,
|
||||
];
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ describe("Setup", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: t("auth:setup_retry") }));
|
||||
|
||||
expect(await screen.findByText(t("auth:setup_welcome", { name: "owner" }))).toBeTruthy();
|
||||
expect(screen.getByText(t("auth:setup_game_optional"))).toBeTruthy();
|
||||
expect(calls.setupRedeem.mock.calls).toEqual([["raw-token"], ["raw-token"]]);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useTier } from "@/lib/tier";
|
||||
import { InlineError } from "@/components/MessageLine";
|
||||
|
||||
// Setup is the Owner's first-run onboarding wizard (spec §B setup bootstrap). The
|
||||
// `felis setup` MC-bind flow prints https://op.console.<root>/setup?token=<raw> —
|
||||
// `felis setup` host-authorized flow prints https://op.console.<root>/setup?token=<raw> —
|
||||
// the Owner is staff, so onboarding lands on the operator console, not the player
|
||||
// panel; this page redeems that one-time token (minting a lockdown session), then drives the
|
||||
// two remaining steps — record an email, enroll a passkey — before handing off to the
|
||||
@@ -167,7 +167,8 @@ export function Setup() {
|
||||
return (
|
||||
<AuthLayout title={t("setup_title")} subtitle={t("setup_welcome", { name: state.username })}>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<p className="text-sm text-muted-foreground">{t("setup_game_optional")}</p>
|
||||
{!state.email ? (
|
||||
<EmailStep initialEmail={state.email} onRecorded={reload} />
|
||||
) : !state.has_passkey ? (
|
||||
|
||||
Reference in new issue
Block a user