feat(auth)!: go fully passwordless and fix cross-check review findings
Remove password authentication everywhere; the only session doors are passkey (WebAuthn), email OTP, in-game bind codes, QR scan-login, and op-login vouching. Remediates the 33-finding cross-check review across backend, CLI, panel, plugins, and docs. Backend/CLI: - Drop password routes and fields from account/user/onboard/auth handlers; align tests (new account subtests, naming reserves "console", op-login/onboard/qr-login test updates). - Add migrations 0016_op_login.sql and 0017_drop_password.sql. - Thread panel/admin hostnames from hostcfg through api.go, setup_panel.go, tui_root.go and tui_preflight.go instead of hardcoding; bootstrap.sh writes panel-hostname/admin-hostname into felis.toml. - Reword breakglass and TUI copy for passwordless flows. Panel: - Delete the ChangePassword page and all password UI; align login/auth/api/types with the passwordless contract; add the migration and op-login approval flows. - i18n: convert ImageBuildPage durations/status badges and ServerLuckPerms strings to translation keys; drop 72 orphan keys per locale; unify the title as "Felis - Console". Plugins (all six rebuilt): - Velocity waiting router returns 503 at_capacity during wake; MOTD/control-channel copy and config comments. - Paper zh menu title; Limbo bind-code TTL 600s with panel_url preference; unified /link lines in fabric/forge/neoforge; shared link-client javadoc contract fixes. Docs: openapi.yaml, sequence-diagrams.md, deploy/limbo/README.md and plugins/README.md aligned with the implementation. BREAKING CHANGE: migration 0017 irreversibly drops users.password_hash and users.must_change_password; password login cannot be restored after migrating.
This commit is contained in:
97 files changed
+1923
-1444
No files matched your search
+101
-60
@@ -62,57 +62,12 @@ describe("api.me wire shape", () => {
|
||||
expect((opts as RequestInit).method).toBe("GET");
|
||||
expect((opts as RequestInit).credentials).toBe("include");
|
||||
});
|
||||
|
||||
it("surfaces must_change_password from GET /me verbatim", async () => {
|
||||
// handleMe always emits must_change_password; the forced-change gate routes on
|
||||
// it, so the snake_case key must survive the untyped boundary unchanged.
|
||||
const body = {
|
||||
user_id: "u4",
|
||||
email: "[email protected]",
|
||||
role: "admin",
|
||||
is_admin: true,
|
||||
must_change_password: true,
|
||||
};
|
||||
vi.stubGlobal("fetch", fakeFetch(body));
|
||||
const id = await api.me();
|
||||
expect(id.must_change_password).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local-password auth wire shapes", () => {
|
||||
describe("session auth wire shapes", () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("login POSTs {username, password} and returns must_change_password", async () => {
|
||||
// EXACTLY handlers_auth.go handleLogin's request body and response.
|
||||
const fetchSpy = fakeFetch({
|
||||
user_id: "u1",
|
||||
role: "admin",
|
||||
must_change_password: true,
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.login("owner", "s3cret");
|
||||
expect(res.must_change_password).toBe(true);
|
||||
expect(res.user_id).toBe("u1");
|
||||
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
expect(String(url)).toBe("/auth/login");
|
||||
expect((opts as RequestInit).method).toBe("POST");
|
||||
expect((opts as RequestInit).credentials).toBe("include");
|
||||
// The Go login route now REQUIRES Content-Type: application/json (it 415s any
|
||||
// other type to kill the cross-site form-POST forgery vector). This pins the
|
||||
// panel half of that contract: a refactor that drops the header silently breaks
|
||||
// login, and only this assertion would catch it.
|
||||
expect((opts as RequestInit).headers).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
||||
username: "owner",
|
||||
password: "s3cret",
|
||||
});
|
||||
});
|
||||
|
||||
it("logout POSTs to /auth/logout (idempotent {ok:true})", async () => {
|
||||
const fetchSpy = fakeFetch({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
@@ -150,31 +105,117 @@ describe("local-password auth wire shapes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("changePassword POSTs {current_password, new_password}", async () => {
|
||||
const fetchSpy = fakeFetch({ ok: true });
|
||||
it("maps the auth error codes to stable human copy", async () => {
|
||||
const { humanizeError } = await import("./api");
|
||||
expect(humanizeError({ code: "local_auth_disabled" })).toMatch(/turned off/i);
|
||||
expect(humanizeError({ code: "staff_account" })).toMatch(/operator/i);
|
||||
});
|
||||
|
||||
// Op-login (the staff door): start hands back the approval handle the panel shows
|
||||
// as `/felis web op approve <id>`; status is polled; finish spends the mailed code.
|
||||
// EXACTLY handlers_op_login.go's request/response keys.
|
||||
it("opLoginStart POSTs {email} and surfaces {request_id, expires_at}", async () => {
|
||||
const fetchSpy = fakeFetch({
|
||||
request_id: "req-1",
|
||||
expires_at: "2026-07-19T00:10:00Z",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
await api.changePassword("old-pw", "brand-new-pw");
|
||||
const res = await api.opLoginStart("o[email protected]");
|
||||
expect(res.request_id).toBe("req-1");
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
expect(String(url)).toBe("/auth/change-password");
|
||||
expect(String(url)).toBe("/auth/op-login/start");
|
||||
expect((opts as RequestInit).method).toBe("POST");
|
||||
// Same JSON content-type contract as login — the change-password route guards on
|
||||
// it too (defense-in-depth), so the panel must keep sending it.
|
||||
expect((opts as RequestInit).headers).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
||||
current_password: "old-pw",
|
||||
new_password: "brand-new-pw",
|
||||
email: "o[email protected]",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps the auth error codes to stable human copy", async () => {
|
||||
const { humanizeError } = await import("./api");
|
||||
expect(humanizeError({ code: "invalid_credentials" })).toMatch(/incorrect/i);
|
||||
expect(humanizeError({ code: "local_auth_disabled" })).toMatch(/turned off/i);
|
||||
expect(humanizeError({ code: "weak_password" })).toMatch(/8 and 72/);
|
||||
expect(humanizeError({ code: "password_unchanged" })).toMatch(/differ/i);
|
||||
it("opLoginStatus GETs /auth/op-login/status/{id} and surfaces approved", async () => {
|
||||
const fetchSpy = fakeFetch({ approved: true });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.opLoginStatus("req-1");
|
||||
expect(res.approved).toBe(true);
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
expect(String(url)).toBe("/auth/op-login/status/req-1");
|
||||
expect((opts as RequestInit).method).toBe("GET");
|
||||
});
|
||||
|
||||
it("opLoginFinish POSTs {request_id, code}", async () => {
|
||||
const fetchSpy = fakeFetch({ user_id: "u9", role: "admin" });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.opLoginFinish("req-1", "123456");
|
||||
expect(res.user_id).toBe("u9");
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
expect(String(url)).toBe("/auth/op-login/finish");
|
||||
expect((opts as RequestInit).method).toBe("POST");
|
||||
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
||||
request_id: "req-1",
|
||||
code: "123456",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Pin the §B3 migration wire shapes (handlers_account_migrate.go). The status union
|
||||
// ({active:false} | {active:true, state, ...}) and the issue/redeem bodies cross the
|
||||
// untyped fetch().json() boundary, so a key drift leaves the Account migration card
|
||||
// inert while typecheck/build stay green.
|
||||
describe("account migration wire shapes", () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("migrateStatus GETs /account/migrate and surfaces the state-machine fields", async () => {
|
||||
const fetchSpy = fakeFetch({
|
||||
active: true,
|
||||
state: "confirmed",
|
||||
confirm_factor: "email_otp",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.migrateStatus();
|
||||
expect(res.active).toBe(true);
|
||||
expect(res.state).toBe("confirmed");
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
expect(String(url)).toBe("/account/migrate");
|
||||
expect((opts as RequestInit).method).toBe("GET");
|
||||
expect((opts as RequestInit).credentials).toBe("include");
|
||||
});
|
||||
|
||||
it("migrateIssueCode POSTs {target_user_id} and surfaces the one-time code", async () => {
|
||||
const fetchSpy = fakeFetch({
|
||||
code: "MIGR-1234",
|
||||
expires_at: "2026-07-19T00:10:00Z",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.migrateIssueCode("u2");
|
||||
expect(res.code).toBe("MIGR-1234");
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
expect(String(url)).toBe("/account/migrate/issue-code");
|
||||
expect((opts as RequestInit).method).toBe("POST");
|
||||
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
||||
target_user_id: "u2",
|
||||
});
|
||||
});
|
||||
|
||||
it("migrateRedeem POSTs {code} and surfaces the moved servers", async () => {
|
||||
const fetchSpy = fakeFetch({ migrated: true, servers_moved: 2, servers: ["a", "b"] });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.migrateRedeem("MIGR-1234");
|
||||
expect(res.servers_moved).toBe(2);
|
||||
expect(res.servers).toEqual(["a", "b"]);
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
expect(String(url)).toBe("/account/migrate/redeem");
|
||||
expect((opts as RequestInit).method).toBe("POST");
|
||||
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
|
||||
code: "MIGR-1234",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+63
-26
@@ -12,7 +12,6 @@ import type {
|
||||
KickResult,
|
||||
LinkResult,
|
||||
LinkStatus,
|
||||
LoginResult,
|
||||
BindResult,
|
||||
PatchUserRequest,
|
||||
PlayersResult,
|
||||
@@ -108,13 +107,10 @@ export interface SetupState {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Local-password auth (spec §B1). login sets an HttpOnly session cookie as a
|
||||
// side effect — the panel never sees it — and returns only what to route on next
|
||||
// (must_change_password forces the change card before any other surface). The
|
||||
// username/password pair is the ONLY local credential; Passkey/PWA are Phase
|
||||
// B2/C. login may 403 `local_auth_disabled` on a Zero-Trust-only deployment.
|
||||
login: (username: string, password: string) =>
|
||||
request<LoginResult>("POST", "/auth/login", { username, password }),
|
||||
// Session doors (spec §B). The product is passwordless: a session is minted only
|
||||
// by passkey, email-OTP, bind code, or the op-login vouch flow below. Every door
|
||||
// sets an HttpOnly cookie as a side effect and may 403 `local_auth_disabled` on a
|
||||
// Zero-Trust-only deployment.
|
||||
|
||||
// logout is idempotent server-side (clears the session row + cookie); calling it
|
||||
// without a session still resolves 200. After it, refreshing /me yields 401, which
|
||||
@@ -142,14 +138,21 @@ export const api = {
|
||||
authPasskeyDiscoverableFinish: (login_id: string, assertion: any) =>
|
||||
request<any>("POST", "/auth/passkey/login/discoverable/finish", { login_id, assertion }),
|
||||
|
||||
// changePassword is callable during the first-login lockdown (the route is
|
||||
// AllowDuringPasswordChange): the server re-verifies current_password, rejects an
|
||||
// unchanged or weak (8–72 byte) new password, writes the new hash, and revokes
|
||||
// every OTHER session. The caller's own session is kept, so no re-login is needed.
|
||||
changePassword: (current_password: string, new_password: string) =>
|
||||
request<{ ok: boolean }>("POST", "/auth/change-password", {
|
||||
current_password,
|
||||
new_password,
|
||||
// Op-login (spec §B): the staff door. start mails an OTP to a staff address and
|
||||
// returns a request handle; an online admin vouches in-game with
|
||||
// `/felis web op approve <request_id>`; the panel polls status until approved,
|
||||
// then finish redeems {request_id, code} into a session. start answers 202 with a
|
||||
// request_id for ANY well-formed address (anti-enumeration), so the UI just waits.
|
||||
opLoginStart: (email: string) =>
|
||||
request<{ request_id: string; expires_at: string }>("POST", "/auth/op-login/start", { email }),
|
||||
|
||||
opLoginStatus: (id: string) =>
|
||||
request<{ approved: boolean }>("GET", `/auth/op-login/status/${encodeURIComponent(id)}`),
|
||||
|
||||
opLoginFinish: (request_id: string, code: string) =>
|
||||
request<{ user_id: string; role: string }>("POST", "/auth/op-login/finish", {
|
||||
request_id,
|
||||
code,
|
||||
}),
|
||||
|
||||
// Setup bootstrap (spec §B). redeem consumes the one-time token from the setup URL
|
||||
@@ -369,6 +372,46 @@ export const api = {
|
||||
passkeyDelete: (id: string) =>
|
||||
request<void>("DELETE", `/account/passkey/credentials/${id}`),
|
||||
|
||||
// Account migration (spec §B3 inherit). Started in-game with /felis migrate; the
|
||||
// web side then drives: status → step-up confirm (passkey when enrolled, email-OTP
|
||||
// otherwise) → issue-code (source names the target account and reads the one-time
|
||||
// code) → redeem (the TARGET account spends the code; the source's servers move to
|
||||
// it and the source is retired).
|
||||
migrateStatus: () =>
|
||||
request<{
|
||||
active: boolean;
|
||||
state?: string;
|
||||
target_user_id?: string;
|
||||
confirm_factor?: string;
|
||||
code_expires_at?: string;
|
||||
}>("GET", "/account/migrate"),
|
||||
|
||||
migrateConfirmOTPStart: () =>
|
||||
request<{ sent: boolean; expires_at: string }>("POST", "/account/migrate/confirm/otp/start"),
|
||||
|
||||
migrateConfirmOTPVerify: (code: string) =>
|
||||
request<{ confirmed: boolean }>("POST", "/account/migrate/confirm/otp/verify", { code }),
|
||||
|
||||
migrateConfirmPasskeyBegin: () =>
|
||||
request<any>("POST", "/account/migrate/confirm/passkey/begin"),
|
||||
|
||||
migrateConfirmPasskeyFinish: (assertion: any) =>
|
||||
request<{ confirmed: boolean }>("POST", "/account/migrate/confirm/passkey/finish", {
|
||||
assertion,
|
||||
}),
|
||||
|
||||
migrateIssueCode: (target_user_id: string) =>
|
||||
request<{ code: string; expires_at: string }>("POST", "/account/migrate/issue-code", {
|
||||
target_user_id,
|
||||
}),
|
||||
|
||||
migrateRedeem: (code: string) =>
|
||||
request<{ migrated: boolean; servers_moved: number; servers: string[] }>(
|
||||
"POST",
|
||||
"/account/migrate/redeem",
|
||||
{ code },
|
||||
),
|
||||
|
||||
listSubmissions: () =>
|
||||
request<{ submissions: Submission[] }>("GET", "/submissions").then((r) => r.submissions ?? []),
|
||||
|
||||
@@ -429,9 +472,6 @@ export const api = {
|
||||
disableUser: (id: string, disabled: boolean) =>
|
||||
request<{ id: string; disabled: boolean }>("POST", `/users/${id}/disable`, { disabled }),
|
||||
|
||||
resetUserPassword: (id: string) =>
|
||||
request<{ ok: boolean; email: string }>("POST", `/users/${id}/reset-password`),
|
||||
|
||||
getUserQuotas: (id: string) => request<QuotaView>("GET", `/users/${id}/quotas`),
|
||||
|
||||
setUserQuotas: (id: string, quotas: QuotaInput) =>
|
||||
@@ -496,15 +536,12 @@ export function humanizeError(e: unknown): string {
|
||||
|
||||
const err = e as Partial<ApiError>;
|
||||
switch (err.code) {
|
||||
// Local-password auth (spec §B1).
|
||||
// Session doors (spec §B): every passwordless door 403s this when local
|
||||
// sessions are disabled on a Zero-Trust-only deployment.
|
||||
case "local_auth_disabled":
|
||||
return t("local_auth_disabled");
|
||||
case "invalid_credentials":
|
||||
return t("invalid_credentials");
|
||||
case "weak_password":
|
||||
return t("weak_password");
|
||||
case "password_unchanged":
|
||||
return t("password_unchanged");
|
||||
case "staff_account":
|
||||
return t("staff_account");
|
||||
case "not_linked":
|
||||
return t("not_linked");
|
||||
case "invalid_code":
|
||||
|
||||
@@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest";
|
||||
import { deriveAuth, isUnauthorized } from "./auth";
|
||||
import type { Identity } from "./types";
|
||||
|
||||
// deriveAuth is the load-bearing auth decision: it decides who is bounced to /login,
|
||||
// who is forced through the change-password card, and — critically — who is KEPT in
|
||||
// deriveAuth is the load-bearing auth decision: it decides who is bounced to /login
|
||||
// and — critically — who is KEPT in
|
||||
// the app despite a /me failure. The one distinction that must never blur is a true
|
||||
// 401 (no session → login) versus any other failure (transient → stay functional),
|
||||
// because mistaking the latter for the former would log out a healthy Zero-Trust
|
||||
@@ -14,7 +14,6 @@ const admin: Identity = {
|
||||
email: "[email protected]",
|
||||
role: "admin",
|
||||
is_admin: true,
|
||||
must_change_password: false,
|
||||
is_owner: false,
|
||||
};
|
||||
|
||||
@@ -41,7 +40,6 @@ describe("deriveAuth", () => {
|
||||
expect(s.loading).toBe(true);
|
||||
expect(s.unauthenticated).toBe(false);
|
||||
expect(s.isAdmin).toBe(false);
|
||||
expect(s.mustChangePassword).toBe(false);
|
||||
});
|
||||
|
||||
it("a settled 401 with no identity is unauthenticated (→ /login)", () => {
|
||||
@@ -61,21 +59,13 @@ describe("deriveAuth", () => {
|
||||
const s = deriveAuth(admin, null, false);
|
||||
expect(s.unauthenticated).toBe(false);
|
||||
expect(s.isAdmin).toBe(true);
|
||||
expect(s.mustChangePassword).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces must_change_password from the identity", () => {
|
||||
const s = deriveAuth({ ...admin, must_change_password: true }, null, false);
|
||||
expect(s.mustChangePassword).toBe(true);
|
||||
expect(s.unauthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed on a malformed identity missing is_admin / must_change_password", () => {
|
||||
it("fails closed on a malformed identity missing is_admin", () => {
|
||||
// Mirrors the wire-shape trap: absent fields are undefined, not thrown access.
|
||||
const partial = { user_id: "u", email: "e", role: "user" } as unknown as Identity;
|
||||
const s = deriveAuth(partial, null, false);
|
||||
expect(s.isAdmin).toBe(false);
|
||||
expect(s.mustChangePassword).toBe(false);
|
||||
expect(s.unauthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ApiError, Identity } from "./types";
|
||||
|
||||
// Pure auth-state derivation, kept out of tier.tsx so it can be pinned without a
|
||||
// React renderer (mirrors lib/nav.ts). The whole local-password gate turns on one
|
||||
// React renderer (mirrors lib/nav.ts). The whole session gate turns on one
|
||||
// distinction the rest of the app routes on: a /me that returns 401 means "there
|
||||
// is genuinely no session — show the login page", whereas ANY OTHER /me failure
|
||||
// (network, 5xx, timeout) must NOT log the user out. The latter preserves the
|
||||
@@ -20,8 +20,6 @@ export interface AuthState {
|
||||
/** True ONLY when /me returned 401 — no/expired session, route to /login. A
|
||||
* transient or 5xx failure leaves this false so the app keeps rendering. */
|
||||
unauthenticated: boolean;
|
||||
/** True when the loaded identity still owes a forced first-login change. */
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
/** isUnauthorized reports whether a caught error is the request() 401 envelope —
|
||||
@@ -39,7 +37,7 @@ export function isUnauthorized(error: unknown): boolean {
|
||||
/** deriveAuth folds one /me outcome (identity OR error, plus the in-flight flag)
|
||||
* into the state the router reads. Every boolean is computed with `=== true` / an
|
||||
* explicit 401 check so an absent or malformed field fails to the safe side:
|
||||
* non-admin, still-authenticated, no forced change. */
|
||||
* non-admin, still-authenticated. */
|
||||
export function deriveAuth(
|
||||
identity: Identity | null,
|
||||
error: unknown,
|
||||
@@ -50,6 +48,5 @@ export function deriveAuth(
|
||||
loading,
|
||||
isAdmin: identity?.is_admin === true,
|
||||
unauthenticated: !loading && identity === null && isUnauthorized(error),
|
||||
mustChangePassword: identity?.must_change_password === true,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
export interface RuntimeConfig {
|
||||
apiBase: string;
|
||||
rootDomain: string;
|
||||
/** Player-console hostname (console.<root>), absent when unconfigured. */
|
||||
panelHostname?: string;
|
||||
/** Operator-console hostname (op.console.<root>), absent when unconfigured. */
|
||||
adminHostname?: string;
|
||||
}
|
||||
|
||||
const FALLBACK: RuntimeConfig = {
|
||||
@@ -26,6 +30,8 @@ export async function loadConfig(): Promise<RuntimeConfig> {
|
||||
cached = {
|
||||
apiBase: raw.apiBase ?? FALLBACK.apiBase,
|
||||
rootDomain: raw.rootDomain ?? FALLBACK.rootDomain,
|
||||
panelHostname: raw.panelHostname,
|
||||
adminHostname: raw.adminHostname,
|
||||
};
|
||||
} catch {
|
||||
cached = FALLBACK;
|
||||
|
||||
@@ -26,9 +26,8 @@ import { deriveAuth, type AuthState } from "./auth";
|
||||
// simply don't see admin surfaces. (The backend 403s admin data calls
|
||||
// independently, so this is safe.) Only a genuine 401 sets `unauthenticated`.
|
||||
//
|
||||
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login;
|
||||
// `mustChangePassword` forces the change-password card; `refresh()` re-reads /me
|
||||
// after a login / change / logout so the gate re-evaluates without a reload.
|
||||
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login; `refresh()`
|
||||
// re-reads /me after a login / logout so the gate re-evaluates without a reload.
|
||||
//
|
||||
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
|
||||
|
||||
@@ -47,7 +46,6 @@ const TierContext = createContext<TierState>({
|
||||
isAdmin: false,
|
||||
isOwner: false,
|
||||
unauthenticated: false,
|
||||
mustChangePassword: false,
|
||||
refresh: async () => {},
|
||||
});
|
||||
|
||||
|
||||
+8
-25
@@ -25,8 +25,8 @@ export interface ServerInfo {
|
||||
displayName?: string;
|
||||
phase: Phase;
|
||||
desiredState?: "Running" | "Stopped";
|
||||
players?: number;
|
||||
maxPlayers?: number;
|
||||
playersOnline?: number;
|
||||
playersMax?: number;
|
||||
autostartPolicy?: AutostartPolicy;
|
||||
/** Whether the caller may claim this server (unowned + linked + quota). */
|
||||
claimable?: boolean;
|
||||
@@ -74,7 +74,7 @@ export interface AccessResult {
|
||||
}
|
||||
|
||||
/** PlayersResult projects GET /servers/{name}/access/players (spec §7 access), the
|
||||
* ONLY source of WHO is online — ServerInfo.players carries the count alone.
|
||||
* ONLY source of WHO is online — ServerInfo.playersOnline carries the count alone.
|
||||
* `online`/`max` are the tally; `players` is a BEST-EFFORT parse of the vanilla
|
||||
* "list" reply (parseListOutput) and, like the whitelist, can come back empty on a
|
||||
* non-vanilla format while `output` (the raw RCON text, ground truth) still names
|
||||
@@ -101,10 +101,9 @@ export interface KickResult {
|
||||
* projection plus the owner joined read-only from Postgres for display.
|
||||
*
|
||||
* It is a DISTINCT type from ServerInfo, not a reuse: /fleet emits the raw CRD
|
||||
* shape — `playersOnline`/`playersMax` (not players/maxPlayers), plus `ready` and
|
||||
* the `endpoint*` runtime fields — whereas ServerInfo is the /me/servers
|
||||
* projection. Sharing one interface would silently read `undefined` across the
|
||||
* fetch().json() boundary for every renamed field. */
|
||||
* shape — `ready` and the `endpoint*` runtime fields, with playersOnline/playersMax
|
||||
* required — whereas ServerInfo is the /me/servers projection with them optional.
|
||||
* Sharing one interface would blur which fields each face actually guarantees. */
|
||||
export interface FleetServer {
|
||||
name: string;
|
||||
subdomain: string;
|
||||
@@ -213,24 +212,9 @@ export interface Identity {
|
||||
/** Server-computed Principal.IsOwner() — true only for the platform-level
|
||||
* owner account (one above admin). Owners get user management; admins don't. */
|
||||
is_owner: boolean;
|
||||
/** Local-password path only: the account owes a forced first-login password
|
||||
* change. The JWT/Access path always leaves it false. Like `is_admin` it crosses
|
||||
* the untyped fetch().json() boundary, so consumers MUST compare `=== true` — an
|
||||
* absent field is `undefined` (correctly "no change owed"), never a thrown access. */
|
||||
must_change_password: boolean;
|
||||
email_verified?: boolean;
|
||||
}
|
||||
|
||||
/** LoginResult mirrors POST /api/v1/auth/login (handlers_auth.go handleLogin). The
|
||||
* session cookie is set as a side effect (HttpOnly, so the panel never sees it);
|
||||
* the body carries only what the panel routes on next — chiefly whether to force the
|
||||
* change-password card before any other surface. */
|
||||
export interface LoginResult {
|
||||
user_id: string;
|
||||
role: "user" | "admin" | "owner";
|
||||
must_change_password: boolean;
|
||||
}
|
||||
|
||||
export interface BindResult {
|
||||
user_id: string;
|
||||
linked: boolean;
|
||||
@@ -288,7 +272,6 @@ export interface UserView {
|
||||
disabled: boolean;
|
||||
email_verified: boolean;
|
||||
server_count: number;
|
||||
must_change_password: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -304,12 +287,12 @@ export interface UserDetail extends UserView {
|
||||
linked_accounts: LinkedAccount[];
|
||||
}
|
||||
|
||||
/** CreateUserRequest mirrors handlers_users.go createUserRequest — passwordless:
|
||||
* the new account signs in via email-OTP / passkey / bind code, never a password. */
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
email?: string;
|
||||
role: "admin" | "user";
|
||||
password: string;
|
||||
must_change_password: boolean;
|
||||
}
|
||||
|
||||
export interface PatchUserRequest {
|
||||
|
||||
Reference in new issue
Block a user