diff --git a/panel/src/App.tsx b/panel/src/App.tsx
index d27c5d1..b8344c2 100644
--- a/panel/src/App.tsx
+++ b/panel/src/App.tsx
@@ -7,6 +7,8 @@ import { AppShell } from "@/components/AppShell";
import { RequireAdmin } from "@/components/RequireAdmin";
import { RequireAuth } from "@/components/RequireAuth";
import { RequireOwner } from "@/components/RequireOwner";
+import { ValidParam } from "@/components/ValidParam";
+import { SERVER_NAME_PARAM, USER_ID_PARAM } from "@/lib/params";
import { Login } from "@/pages/Login";
import { Setup } from "@/pages/Setup";
import { Dashboard } from "@/pages/Dashboard";
@@ -70,11 +72,18 @@ export default function App() {
{/* User-Side — app-tier */}
} />
} />
- } />
- } />
- } />
- } />
- } />
+ {/* A :name that is not a server name (a crafted link carrying
+ "/", "?" or "..") renders "not found" before any request. */}
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+ } />
+
} />
} />
@@ -90,7 +99,9 @@ export default function App() {
{/* Owner-gated: user management (one level above admin). */}
}>
} />
- } />
+ }>
+ } />
+
diff --git a/panel/src/components/AppShell.tsx b/panel/src/components/AppShell.tsx
index 73f3bd9..cf487ae 100644
--- a/panel/src/components/AppShell.tsx
+++ b/panel/src/components/AppShell.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
-import { Cat, Globe, Sun, Moon, LogOut } from "lucide-react";
+import { Cat, Globe, Sun, Moon, LogOut, WifiOff } from "lucide-react";
import { useTranslation } from "react-i18next";
import * as SelectPrimitive from "@radix-ui/react-select";
import { cn } from "@/lib/utils";
@@ -8,7 +8,8 @@ import { useTier } from "@/lib/tier";
import { visibleSections, type NavSection } from "@/lib/nav";
import { Select, SelectContent, SelectItem } from "@/components/ui/select";
import { useTheme } from "@/lib/theme";
-import { api } from "@/lib/api";
+import { api, CONNECTION_EVENT, isConnectionLost } from "@/lib/api";
+import { Button } from "@/components/ui/button";
import { ErrorBoundary } from "@/components/ErrorBoundary";
function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) {
@@ -137,6 +138,36 @@ function ThemeToggle() {
);
}
+// ConnectionBanner shows while API calls get no response at all (api.ts
+// CONNECTION_EVENT): the network is down, or Cloudflare Access sent the call to
+// its login page. It clears on the next call that gets through (the pages'
+// polling makes one soon); an expired Access sign-in only passes on a full page
+// load, hence the reload button.
+function ConnectionBanner() {
+ const { t } = useTranslation("common");
+ const [lost, setLost] = useState(isConnectionLost);
+ useEffect(() => {
+ const onChange = (e: Event) => setLost(!(e as CustomEvent<{ ok: boolean }>).detail.ok);
+ window.addEventListener(CONNECTION_EVENT, onChange);
+ return () => window.removeEventListener(CONNECTION_EVENT, onChange);
+ }, []);
+ if (!lost) return null;
+ return (
+
+
+
+ {t("connection_lost")}
+
+
+
+ );
+}
+
export function AppShell() {
const { isAdmin, isOwner } = useTier();
const { pathname } = useLocation();
@@ -190,6 +221,7 @@ export function AppShell() {
+
diff --git a/panel/src/components/RequireAuth.tsx b/panel/src/components/RequireAuth.tsx
index bf48fdd..bc9fe46 100644
--- a/panel/src/components/RequireAuth.tsx
+++ b/panel/src/components/RequireAuth.tsx
@@ -1,4 +1,4 @@
-import { Navigate, Outlet } from "react-router-dom";
+import { Navigate, Outlet, useLocation } from "react-router-dom";
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTier } from "@/lib/tier";
@@ -7,7 +7,9 @@ import { useTier } from "@/lib/tier";
// and everything under it). It encodes the three-way verdict from the tier model:
//
// loading → a full-screen spinner (never flash login during boot /me)
-// unauthenticated → /login (a genuine 401: no/expired session)
+// unauthenticated → /login (a genuine 401: no/expired session), carrying
+// the page to return to (?next=) and, when a session
+// ended under an open page, state.sessionEnded
// otherwise → render the app ()
//
// The "otherwise" branch deliberately includes the graded-Zero-Trust degraded case
@@ -15,8 +17,9 @@ import { useTier } from "@/lib/tier";
// app still renders User-Side, exactly as before local auth existed. Only a true
// 401 bounces to /login. Every admin route remains independently server-guarded.
export function RequireAuth() {
- const { loading, unauthenticated } = useTier();
+ const { loading, unauthenticated, sessionEnded } = useTier();
const { t } = useTranslation("common");
+ const location = useLocation();
if (loading) {
return (
@@ -26,6 +29,10 @@ export function RequireAuth() {
);
}
- if (unauthenticated) return ;
+ if (unauthenticated) {
+ const back = location.pathname + location.search;
+ const to = back === "/" ? "/login" : `/login?next=${encodeURIComponent(back)}`;
+ return ;
+ }
return ;
}
diff --git a/panel/src/components/States.tsx b/panel/src/components/States.tsx
index d3fbf1b..52f45ef 100644
--- a/panel/src/components/States.tsx
+++ b/panel/src/components/States.tsx
@@ -1,4 +1,4 @@
-import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon } from "lucide-react";
+import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -71,6 +71,22 @@ export function NotAuthorized() {
);
}
+export function NotFound() {
+ const { t } = useTranslation("common");
+ return (
+
+
+
+
{t("not_found_title")}
+
{t("not_found_body")}
+
+
+ {t("back_to_dashboard")}
+
+
+ );
+}
+
/**
* PendingBackend is an HONEST placeholder for a surface whose backend read does
* not exist yet (DESIGN-WEB-3SIDES non-goal #3: "a page with no real backend
diff --git a/panel/src/components/ValidParam.tsx b/panel/src/components/ValidParam.tsx
new file mode 100644
index 0000000..18c9b04
--- /dev/null
+++ b/panel/src/components/ValidParam.tsx
@@ -0,0 +1,10 @@
+import { Outlet, useParams } from "react-router-dom";
+import { NotFound } from "@/components/States";
+
+// ValidParam is a layout route that renders its children only when one URL
+// parameter has the expected shape (lib/params.ts), and "not found" otherwise.
+export function ValidParam({ param, pattern }: { param: string; pattern: RegExp }) {
+ const value = useParams()[param];
+ if (value === undefined || !pattern.test(value)) return ;
+ return ;
+}
diff --git a/panel/src/i18n/resources/en-US/auth.json b/panel/src/i18n/resources/en-US/auth.json
index 8324827..49df9ab 100644
--- a/panel/src/i18n/resources/en-US/auth.json
+++ b/panel/src/i18n/resources/en-US/auth.json
@@ -46,5 +46,6 @@
"setup_passkey_desc": "Create a passkey with your fingerprint, face, or device PIN. It's how you'll sign in to the console — required to finish setup.",
"setup_create_passkey": "Create passkey",
"setup_registering": "Registering…",
- "setup_default_passkey_name": "Default passkey"
+ "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."
}
diff --git a/panel/src/i18n/resources/en-US/common.json b/panel/src/i18n/resources/en-US/common.json
index d29fd54..d0d8a20 100644
--- a/panel/src/i18n/resources/en-US/common.json
+++ b/panel/src/i18n/resources/en-US/common.json
@@ -26,5 +26,9 @@
"crash_updated_body": "A new version of the panel was just released. Reload to open this page.",
"crash_reload": "Reload",
"crash_details": "Error details",
- "crash_copy": "Copy error details"
+ "crash_copy": "Copy error details",
+ "not_found_title": "Page not found",
+ "not_found_body": "The name in the link is not valid, or the page does not exist.",
+ "connection_lost": "Can't reach Felis: the network is down, or your Cloudflare Access sign-in expired. This clears by itself once the network is back; an expired Access sign-in needs a page reload.",
+ "reload_page": "Reload page"
}
diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json
index 173d389..2fb0e15 100644
--- a/panel/src/i18n/resources/en-US/errors.json
+++ b/panel/src/i18n/resources/en-US/errors.json
@@ -79,5 +79,9 @@
"too_many_streams": "Too many live streams are open — close some pages and try again.",
"protected_admin": "That name belongs to a linked administrator and can't be reclaimed.",
"auth_unavailable": "The sign-in service isn't available right now.",
- "setup_token_invalid": "That setup token is invalid or expired."
+ "setup_token_invalid": "That setup token is invalid or expired.",
+ "network_error": "Can't reach Felis: the network is down, or your Cloudflare Access sign-in expired. Reload the page to sign in again.",
+ "upstream_unavailable": "The service is unavailable right now (it may be restarting or upgrading). Try again shortly.",
+ "bad_path_param": "The name in this link is not valid. Open it again from the list.",
+ "payload_too_large": "That is larger than the entry proxy accepts, so it was not sent."
}
diff --git a/panel/src/i18n/resources/zh-CN/auth.json b/panel/src/i18n/resources/zh-CN/auth.json
index 0659568..ab24497 100644
--- a/panel/src/i18n/resources/zh-CN/auth.json
+++ b/panel/src/i18n/resources/zh-CN/auth.json
@@ -46,5 +46,6 @@
"setup_passkey_desc": "使用指纹、面容或设备 PIN 创建一个通行密钥,这将是你登录控制台的方式——完成初始化必须注册。",
"setup_create_passkey": "创建通行密钥",
"setup_registering": "注册中…",
- "setup_default_passkey_name": "默认通行密钥"
+ "setup_default_passkey_name": "默认通行密钥",
+ "session_ended_notice": "你的登录已过期或已被撤销,请重新登录。登录后会回到刚才的页面。"
}
diff --git a/panel/src/i18n/resources/zh-CN/common.json b/panel/src/i18n/resources/zh-CN/common.json
index eaaa210..422ca28 100644
--- a/panel/src/i18n/resources/zh-CN/common.json
+++ b/panel/src/i18n/resources/zh-CN/common.json
@@ -26,5 +26,9 @@
"crash_updated_body": "面板刚发布了新版本,这个页面需要重新加载才能打开。",
"crash_reload": "重新加载",
"crash_details": "错误信息",
- "crash_copy": "复制错误信息"
+ "crash_copy": "复制错误信息",
+ "not_found_title": "找不到这个页面",
+ "not_found_body": "链接里的名称不合法,或者这个页面不存在。",
+ "connection_lost": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。网络恢复后会自动消失;Access 过期需要刷新页面重新登录。",
+ "reload_page": "刷新页面"
}
diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json
index 4510a0d..6ccd812 100644
--- a/panel/src/i18n/resources/zh-CN/errors.json
+++ b/panel/src/i18n/resources/zh-CN/errors.json
@@ -79,5 +79,9 @@
"too_many_streams": "同时打开的实时连接过多——请关闭一些页面后再试。",
"protected_admin": "该名字属于已绑定的管理员账户,不能被认领。",
"auth_unavailable": "登录服务当前不可用。",
- "setup_token_invalid": "初始化令牌无效或已过期。"
+ "setup_token_invalid": "初始化令牌无效或已过期。",
+ "network_error": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。刷新页面可以重新登录。",
+ "upstream_unavailable": "服务暂时不可用(可能正在重启或升级),请稍后重试。",
+ "bad_path_param": "链接里的名称无效,请从列表重新打开。",
+ "payload_too_large": "内容超过了入口允许的大小,没有发送成功。"
}
diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts
index cc8ede9..4b2ee64 100644
--- a/panel/src/lib/api.test.ts
+++ b/panel/src/lib/api.test.ts
@@ -12,7 +12,8 @@ vi.mock("./config", () => ({
}));
// Imported after the mock so api.ts picks up the mocked loadConfig.
-const { api, SETUP_REQUIRED_EVENT } = await import("./api");
+const { api, SETUP_REQUIRED_EVENT, SESSION_EXPIRED_EVENT, CONNECTION_EVENT, humanizeError, isConnectionLost } =
+ await import("./api");
function fakeFetch(body: unknown, init?: { ok?: boolean; status?: number }) {
return vi.fn(async () => ({
@@ -784,3 +785,141 @@ describe("image whitelist and builds wire shapes", () => {
});
});
});
+
+describe("path parameters", () => {
+ beforeEach(() => vi.restoreAllMocks());
+ afterEach(() => vi.unstubAllGlobals());
+
+ function lastURL(spy: typeof fetch): string {
+ const calls = (spy as unknown as ReturnType).mock.calls;
+ return String(calls[calls.length - 1][0]);
+ }
+
+ it('encodes "/", "?" and "#" inside one segment', async () => {
+ const spy = fakeFetch({ player: "x", groups: [], permissions: [], output: "" });
+ vi.stubGlobal("fetch", spy);
+ await api.accessLuckPermsInfo("lobby", "a/b?c#d");
+ expect(lastURL(spy)).toBe("/servers/lobby/access/luckperms/a%2Fb%3Fc%23d");
+ });
+
+ it("keeps a decoded router param that climbs the path inside its segment", async () => {
+ // react-router decodes "..%2F..%2Fusers%2Fu1%3F" to this before a page sees it.
+ const spy = fakeFetch({ name: "x", desiredState: "Stopped" });
+ vi.stubGlobal("fetch", spy);
+ await api.stop("../../users/u1?");
+ expect(lastURL(spy)).toBe("/servers/..%2F..%2Fusers%2Fu1%3F/stop");
+ });
+
+ it("refuses dot segments and empty ones before sending anything", async () => {
+ const spy = fakeFetch({});
+ vi.stubGlobal("fetch", spy);
+ for (const bad of ["..", ".", ""]) {
+ await expect(api.stop(bad)).rejects.toMatchObject({ status: 0, code: "bad_path_param" });
+ await expect(api.deleteUser(bad)).rejects.toMatchObject({ code: "bad_path_param" });
+ }
+ expect(spy).not.toHaveBeenCalled();
+ expect(humanizeError({ status: 0, code: "bad_path_param" })).toMatch(/not valid/i);
+ });
+
+ it("does not encode a value twice", async () => {
+ const spy = fakeFetch({ ok: true });
+ vi.stubGlobal("fetch", spy);
+ await api.revokeUserSession("u1", "ab/cd");
+ expect(lastURL(spy)).toBe("/users/u1/sessions/ab%2Fcd");
+ await api.listServerFiles("lobby", "world/level.dat");
+ expect(lastURL(spy)).toBe("/servers/lobby/files?path=world%2Flevel.dat");
+ });
+});
+
+describe("responses that are not the API's JSON", () => {
+ beforeEach(() => vi.restoreAllMocks());
+ afterEach(() => vi.unstubAllGlobals());
+
+ function htmlFetch(status: number, statusText: string) {
+ return vi.fn(async () => ({
+ ok: status >= 200 && status < 300,
+ status,
+ statusText,
+ text: async () => "Bad gateway",
+ })) as unknown as typeof fetch;
+ }
+
+ it("turns an HTML 502 page into upstream_unavailable with the status kept", async () => {
+ vi.stubGlobal("fetch", htmlFetch(502, "Bad Gateway"));
+ const err = await api.status("lobby").catch((e) => e);
+ expect(err).toMatchObject({ status: 502, code: "upstream_unavailable" });
+ expect(humanizeError(err)).toMatch(/unavailable/i);
+ expect(humanizeError(err)).not.toMatch(/JSON|DOCTYPE/);
+ });
+
+ it("keeps the status of an HTML 401 so the session branch still runs", async () => {
+ const target = new EventTarget();
+ vi.stubGlobal("window", target);
+ let expired = 0;
+ target.addEventListener(SESSION_EXPIRED_EVENT, () => (expired += 1));
+ vi.stubGlobal("fetch", htmlFetch(401, "Unauthorized"));
+ const err = await api.status("lobby").catch((e) => e);
+ expect(err).toMatchObject({ status: 401 });
+ expect(humanizeError(err)).toMatch(/session/i);
+ expect(expired).toBe(1);
+ });
+
+ it("refuses a 200 whose body is not JSON", async () => {
+ vi.stubGlobal("fetch", htmlFetch(200, "OK"));
+ await expect(api.me()).rejects.toMatchObject({ status: 200, code: "upstream_unavailable" });
+ });
+
+ it("maps an unknown 5xx code to the unavailable line", () => {
+ expect(humanizeError({ status: 500, code: "internal", message: "internal error" })).toMatch(/unavailable/i);
+ expect(humanizeError({ status: 413, code: "error", message: "Payload Too Large" })).toMatch(/larger/i);
+ });
+});
+
+describe("session and connection signals", () => {
+ beforeEach(() => vi.restoreAllMocks());
+ afterEach(() => vi.unstubAllGlobals());
+
+ function listen(name: string) {
+ const target = new EventTarget();
+ vi.stubGlobal("window", target);
+ const seen: unknown[] = [];
+ target.addEventListener(name, (e) => seen.push((e as CustomEvent).detail ?? true));
+ return seen;
+ }
+
+ it("announces a 401 from a protected route", async () => {
+ const seen = listen(SESSION_EXPIRED_EVENT);
+ vi.stubGlobal("fetch", fakeFetch({ error: { code: "unauthorized", message: "x" } }, { ok: false, status: 401 }));
+ await expect(api.myServers()).rejects.toMatchObject({ status: 401 });
+ expect(seen).toHaveLength(1);
+ });
+
+ it("stays silent for /me and the sign-in doors", async () => {
+ const seen = listen(SESSION_EXPIRED_EVENT);
+ vi.stubGlobal("fetch", fakeFetch({ error: { code: "unauthorized", message: "x" } }, { ok: false, status: 401 }));
+ await expect(api.me()).rejects.toMatchObject({ status: 401 });
+ await expect(api.setupStatus()).rejects.toMatchObject({ status: 401 });
+ expect(seen).toHaveLength(0);
+ });
+
+ it("reports a fetch that got no response, and the next one that did", async () => {
+ const seen = listen(CONNECTION_EVENT);
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => {
+ throw new TypeError("Failed to fetch");
+ }),
+ );
+ const err = await api.myServers().catch((e) => e);
+ expect(err).toMatchObject({ status: 0, code: "network_error" });
+ expect(humanizeError(err)).toMatch(/Cloudflare Access/);
+ await api.myServers().catch(() => {});
+ expect(seen).toEqual([{ ok: false }]);
+ expect(isConnectionLost()).toBe(true);
+
+ vi.stubGlobal("fetch", fakeFetch({ servers: [] }));
+ await api.myServers();
+ expect(seen).toEqual([{ ok: false }, { ok: true }]);
+ expect(isConnectionLost()).toBe(false);
+ });
+});
diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts
index 82fd284..55631c0 100644
--- a/panel/src/lib/api.ts
+++ b/panel/src/lib/api.ts
@@ -38,12 +38,9 @@ import i18next from "i18next";
// service token, and the RCON password is never requested (spec §8).
function isApiError(x: unknown): x is { error: { code: string; message: string } } {
- return (
- typeof x === "object" &&
- x !== null &&
- "error" in x &&
- typeof (x as { error: unknown }).error === "object"
- );
+ if (typeof x !== "object" || x === null || !("error" in x)) return false;
+ const e = (x as { error: unknown }).error;
+ return typeof e === "object" && e !== null && typeof (e as { code?: unknown }).code === "string";
}
// A locked session — one that still owes the forced onboarding (passkey
@@ -61,57 +58,167 @@ function announceSetupRequired(err: ApiError): void {
window.dispatchEvent(new Event(SETUP_REQUIRED_EVENT));
}
-async function request(method: string, path: string, body?: unknown): Promise {
+// A 401 from a protected route means the session ended under the page (it
+// expired, or an admin revoked it). TierProvider hears this and re-reads /me,
+// and RequireAuth then sends the person to /login with the page to come back
+// to. /me is left out because it is that re-read, and /auth/* because the
+// sign-in doors run without a session by design.
+export const SESSION_EXPIRED_EVENT = "felis:session-expired";
+
+function announceSessionExpired(err: ApiError, path: string): void {
+ if (err.status !== 401 || typeof window === "undefined") return;
+ if (path === "/me" || path.startsWith("/auth/")) return;
+ window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT));
+}
+
+// CONNECTION_EVENT reports when requests stop reaching the API (detail.ok =
+// false) and when one gets through again (true). A fetch that rejects never
+// saw a response: the network is down, or Cloudflare Access redirected the
+// call to its cross-origin login page because the Access session expired,
+// which fetch reports as the same TypeError. AppShell shows a banner with a
+// reload, since only a full page load can go through the Access login.
+export const CONNECTION_EVENT = "felis:connection";
+let connectionLost = false;
+
+/** isConnectionLost reports whether the last call got no response. */
+export function isConnectionLost(): boolean {
+ return connectionLost;
+}
+
+function reportConnection(ok: boolean): void {
+ if (connectionLost === !ok) return;
+ connectionLost = !ok;
+ if (typeof window === "undefined") return;
+ window.dispatchEvent(new CustomEvent(CONNECTION_EVENT, { detail: { ok } }));
+}
+
+function networkError(e: unknown): ApiError {
+ return {
+ status: 0,
+ code: "network_error",
+ message: e instanceof Error ? e.message : String(e),
+ };
+}
+
+// urlPath builds an API path from literal text and route values, percent-
+// encoding each value as one path segment. Values come from router params and
+// form fields, and react-router hands params over decoded, so "%2F" arrives as
+// "/": interpolated raw, a crafted link could point a button at another
+// endpoint, carrying the viewer's cookie. encodeURIComponent covers "/", "?"
+// and "#"; "." and ".." survive it and fetch would still walk the path up, so
+// those and "" are refused before anything is sent.
+export function urlPath(strings: TemplateStringsArray, ...values: string[]): string {
+ let out = strings[0];
+ values.forEach((v, i) => {
+ const seg = String(v);
+ if (seg === "" || seg === "." || seg === "..") {
+ const err: ApiError = {
+ status: 0,
+ code: "bad_path_param",
+ message: `refusing ${JSON.stringify(seg)} as a path segment`,
+ };
+ throw err;
+ }
+ out += encodeURIComponent(seg) + strings[i + 1];
+ });
+ return out;
+}
+
+// fetchOK performs one call and returns the response when it is 2xx, else
+// throws an ApiError that always keeps the HTTP status. A body that is not the
+// API's JSON envelope (an HTML 502 or 524 page from the tunnel while the API
+// restarts, a bare 503 from the ingress) becomes `upstream_unavailable`, so the
+// setup and session branches still see the status and the person reads "try
+// again shortly" instead of a JSON parse error.
+async function fetchOK(path: string, init: RequestInit): Promise {
const { apiBase } = await loadConfig();
- const res = await fetch(`${apiBase}${path}`, {
+ let res: Response;
+ try {
+ res = await fetch(`${apiBase}${path}`, { ...init, credentials: "include" });
+ } catch (e) {
+ if (e instanceof DOMException && e.name === "AbortError") throw e;
+ reportConnection(false);
+ throw networkError(e);
+ }
+ reportConnection(true);
+ if (res.ok) return res;
+
+ let parsed: unknown = null;
+ try {
+ parsed = JSON.parse(await res.text());
+ } catch {
+ /* no body, or not JSON: an ingress or tunnel answered */
+ }
+ const err: ApiError = isApiError(parsed)
+ ? { status: res.status, code: parsed.error.code, message: parsed.error.message }
+ : {
+ status: res.status,
+ code: res.status >= 500 ? "upstream_unavailable" : "error",
+ message: res.statusText,
+ };
+ announceSetupRequired(err);
+ announceSessionExpired(err, path);
+ throw err;
+}
+
+// send returns a 2xx response's parsed JSON body (null for an empty one).
+async function send(path: string, init: RequestInit): Promise {
+ const res = await fetchOK(path, init);
+ let text: string;
+ try {
+ text = await res.text();
+ } catch (e) {
+ reportConnection(false);
+ throw networkError(e);
+ }
+ if (!text) return null as T;
+ try {
+ return JSON.parse(text) as T;
+ } catch {
+ const err: ApiError = {
+ status: res.status,
+ code: "upstream_unavailable",
+ message: "the response was not JSON",
+ };
+ throw err;
+ }
+}
+
+function request(method: string, path: string, body?: unknown): Promise {
+ return send(path, {
method,
- credentials: "include",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
-
- const text = await res.text();
- const parsed: unknown = text ? JSON.parse(text) : null;
-
- if (!res.ok) {
- const err: ApiError = {
- status: res.status,
- code: isApiError(parsed) ? parsed.error.code : "error",
- message: isApiError(parsed) ? parsed.error.message : res.statusText,
- };
- announceSetupRequired(err);
- throw err;
- }
- return parsed as T;
}
-async function requestRaw(
+function requestRaw(
method: string,
path: string,
body: Blob,
headers?: Record,
): Promise {
- const { apiBase } = await loadConfig();
- const res = await fetch(`${apiBase}${path}`, {
- method,
- credentials: "include",
- headers,
- body,
- });
+ return send(path, { method, headers, body });
+}
- const text = await res.text();
- const parsed: unknown = text ? JSON.parse(text) : null;
-
- if (!res.ok) {
- const err: ApiError = {
- status: res.status,
- code: isApiError(parsed) ? parsed.error.code : "error",
- message: isApiError(parsed) ? parsed.error.message : res.statusText,
- };
- announceSetupRequired(err);
- throw err;
+// rejectingSync turns a synchronous throw inside an api method (urlPath refusing
+// a segment) into a rejected promise, so every caller handles it the way it
+// handles any failed call.
+function rejectingSync>(methods: T): T {
+ const out: Record = {};
+ for (const [key, fn] of Object.entries(methods)) {
+ out[key] =
+ typeof fn === "function"
+ ? (...args: unknown[]) => {
+ try {
+ return fn(...args);
+ } catch (e) {
+ return Promise.reject(e);
+ }
+ }
+ : fn;
}
- return parsed as T;
+ return out as T;
}
// Setup bootstrap (spec §B). The one-time token from `felis setup` is redeemed for
@@ -127,7 +234,7 @@ export interface SetupState {
setup_required: boolean;
}
-export const api = {
+export const api = rejectingSync({
// 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
@@ -168,7 +275,7 @@ export const api = {
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)}`),
+ request<{ approved: boolean }>("GET", urlPath`/auth/op-login/status/${id}`),
opLoginFinish: (request_id: string, code: string) =>
request<{ user_id: string; role: string }>("POST", "/auth/op-login/finish", {
@@ -201,23 +308,23 @@ export const api = {
fleet: () =>
request<{ servers: FleetServer[] }>("GET", "/fleet").then((r) => r.servers ?? []),
- status: (name: string) => request("GET", `/servers/${name}/status`),
+ status: (name: string) => request("GET", urlPath`/servers/${name}/status`),
wake: (name: string) =>
- request<{ name: string; desiredState: string }>("POST", `/servers/${name}/wake`),
+ request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/wake`),
stop: (name: string) =>
- request<{ name: string; desiredState: string }>("POST", `/servers/${name}/stop`),
+ request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/stop`),
claim: (name: string) =>
- request<{ name: string; claimed: boolean }>("POST", `/servers/${name}/claim`),
+ request<{ name: string; claimed: boolean }>("POST", urlPath`/servers/${name}/claim`),
/** sendCommand runs one RCON command against a running server (spec §8 写=RCON).
* The backend strips a leading "/", rejects control characters (newline → 400)
* and caps the command at 1000 bytes. The reply is the server's plain-text
* response body. */
sendCommand: (name: string, command: string) =>
- request<{ output: string }>("POST", `/servers/${name}/command`, { command }),
+ request<{ output: string }>("POST", urlPath`/servers/${name}/command`, { command }),
// Access control (spec §7 access). The backend translates these STRUCTURED fields
// into RCON commands — every field is charset-validated server-side before it is
@@ -230,10 +337,10 @@ export const api = {
* Running server (the readiness gate covers the read, not just the writes), so
* callers must gate the fetch on phase === "Running". */
accessWhitelistList: (name: string) =>
- request("GET", `/servers/${name}/access/whitelist`),
+ request("GET", urlPath`/servers/${name}/access/whitelist`),
accessWhitelist: (name: string, action: "add" | "remove", player: string) =>
- request("POST", `/servers/${name}/access/whitelist`, {
+ request("POST", urlPath`/servers/${name}/access/whitelist`, {
action,
player,
}),
@@ -242,10 +349,10 @@ export const api = {
* requires a Running server (the readiness gate covers the read too), so callers
* gate the fetch on phase === "Running". */
accessBanList: (name: string) =>
- request("GET", `/servers/${name}/access/ban`),
+ request("GET", urlPath`/servers/${name}/access/ban`),
accessBan: (name: string, action: "ban" | "pardon", player: string) =>
- request("POST", `/servers/${name}/access/ban`, {
+ request("POST", urlPath`/servers/${name}/access/ban`, {
action,
player,
}),
@@ -254,10 +361,10 @@ export const api = {
* the count alone). Like accessWhitelistList this GET requires a Running server,
* so callers gate the fetch on phase === "Running". */
accessPlayers: (name: string) =>
- request("GET", `/servers/${name}/access/players`),
+ request("GET", urlPath`/servers/${name}/access/players`),
accessKick: (name: string, player: string) =>
- request("POST", `/servers/${name}/access/kick`, { player }),
+ request("POST", urlPath`/servers/${name}/access/kick`, { player }),
accessLuckPermsInfo: (name: string, player: string) =>
request<{
@@ -265,7 +372,7 @@ export const api = {
groups: string[];
permissions: { node: string; value: boolean; world?: string }[];
output: string;
- }>("GET", `/servers/${name}/access/luckperms/${player}`),
+ }>("GET", urlPath`/servers/${name}/access/luckperms/${player}`),
accessPermission: (
name: string,
@@ -277,7 +384,7 @@ export const api = {
) =>
request(
"POST",
- `/servers/${name}/access/permission`,
+ urlPath`/servers/${name}/access/permission`,
{ action, player, node, value, world }
),
@@ -289,7 +396,7 @@ export const api = {
) =>
request(
"POST",
- `/servers/${name}/access/group`,
+ urlPath`/servers/${name}/access/group`,
{ action, player, group }
),
@@ -306,10 +413,10 @@ export const api = {
request("POST", "/images/build", req),
getBuild: (id: string) =>
- request("GET", `/images/build/${id}`),
+ request("GET", urlPath`/images/build/${id}`),
cancelBuild: (id: string) =>
- request("POST", `/images/build/${id}/cancel`),
+ request("POST", urlPath`/images/build/${id}/cancel`),
createServer: (req: CreateServerRequest) =>
request<{ name: string; subdomain: string; desiredState: string }>(
@@ -337,7 +444,7 @@ export const api = {
}) =>
request<{ name: string; desiredState: string }>(
"PATCH",
- `/servers/${name}`,
+ urlPath`/servers/${name}`,
req,
),
@@ -368,7 +475,7 @@ export const api = {
if (!safetySnapshot) body.safety_snapshot = false;
return request<{ name: string; status: string; backup_id: string; safety_snapshot?: boolean }>(
"POST",
- `/servers/${name}/restore-backup`,
+ urlPath`/servers/${name}/restore-backup`,
Object.keys(body).length ? body : undefined,
);
},
@@ -380,7 +487,7 @@ export const api = {
// {name, status:"backing_up"}: the Job is enqueued, not done — watch
// serverJobs for the outcome.
backupNow: (name: string) =>
- request<{ name: string; status: string }>("POST", `/servers/${name}/backup`),
+ request<{ name: string; status: string }>("POST", urlPath`/servers/${name}/backup`),
// serverJobs lists the newest backup/restore Jobs of one server, newest first
// (GET /servers/{name}/jobs). Owner-or-admin gated server-side; a Job's
@@ -389,7 +496,7 @@ export const api = {
serverJobs: (name: string) =>
request<{ server: string; jobs: ServerJob[] }>(
"GET",
- `/servers/${name}/jobs`,
+ urlPath`/servers/${name}/jobs`,
).then((r) => r.jobs ?? []),
// Server file editor (spec §7). All three routes are owner-or-admin gated and
@@ -401,7 +508,7 @@ export const api = {
listServerFiles: (name: string, path: string) =>
request<{ path: string; entries: ServerFileEntry[]; truncated: boolean }>(
"GET",
- `/servers/${name}/files?path=${encodeURIComponent(path)}`,
+ urlPath`/servers/${name}/files` + `?path=${encodeURIComponent(path)}`,
),
// readServerFile returns one file's bytes (base64) and the sha256 of the file
@@ -410,7 +517,7 @@ export const api = {
readServerFile: (name: string, path: string) =>
request<{ path: string; content: string; sha256: string }>(
"GET",
- `/servers/${name}/file?path=${encodeURIComponent(path)}`,
+ urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`,
),
// writeServerFile atomically replaces a file's contents (creating it if
@@ -421,7 +528,7 @@ export const api = {
writeServerFile: (name: string, path: string, content: string, expectSha256?: string) =>
request<{ path: string; status: string; sha256: string }>(
"PUT",
- `/servers/${name}/file?path=${encodeURIComponent(path)}`,
+ urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`,
expectSha256 ? { content, expect_sha256: expectSha256 } : { content },
),
@@ -456,7 +563,7 @@ export const api = {
request<{ credentials: any[] }>("GET", "/account/passkey/credentials"),
passkeyDelete: (id: string) =>
- request("DELETE", `/account/passkey/credentials/${id}`),
+ request("DELETE", urlPath`/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
@@ -504,14 +611,14 @@ export const api = {
// expectedDigest is the sha256 of the context the reviewer looked at; the API
// refuses the approval (409 context_changed) when the upload has since changed.
approveSubmission: (id: string, expectedDigest: string) =>
- request("POST", `/submissions/${id}/approve`, { expected_digest: expectedDigest }),
+ request("POST", urlPath`/submissions/${id}/approve`, { expected_digest: expectedDigest }),
rejectSubmission: (id: string, reason: string) =>
- request("POST", `/submissions/${id}/reject`, { reason }),
+ request("POST", urlPath`/submissions/${id}/reject`, { reason }),
// Retire a submission outright (row + uploaded context) — the review queue's
// lifecycle valve, the only way an upload is reclaimed from the PVC.
- deleteSubmission: (id: string) => request("DELETE", `/submissions/${id}`),
+ deleteSubmission: (id: string) => request("DELETE", urlPath`/submissions/${id}`),
// The reviewer's read path to the uploaded build context: the executed
// Dockerfile lives inside the tarball, so approving without this would be
@@ -520,27 +627,7 @@ export const api = {
// Resolves to the sha256 the API vouched for while streaming these bytes (it
// aborts the transfer on a mismatch), so the approval can name what was read.
downloadSubmissionContext: async (id: string): Promise => {
- const { apiBase } = await loadConfig();
- const res = await fetch(`${apiBase}/submissions/${id}/context`, {
- method: "GET",
- credentials: "include",
- });
- if (!res.ok) {
- let code = "error";
- let message = res.statusText;
- try {
- const parsed = JSON.parse(await res.text()) as unknown;
- if (isApiError(parsed)) {
- code = parsed.error.code;
- message = parsed.error.message;
- }
- } catch {
- /* non-JSON error body (e.g. an ingress page): keep the status line */
- }
- const err: ApiError = { status: res.status, code, message };
- announceSetupRequired(err);
- throw err;
- }
+ const res = await fetchOK(urlPath`/submissions/${id}/context`, { method: "GET" });
const digest = res.headers.get("X-Felis-Context-Sha256")?.trim().toLowerCase() || null;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
@@ -559,13 +646,13 @@ export const api = {
request("POST", "/me/submissions", { display_name: displayName }),
uploadSubmissionContext: (id: string, file: Blob) =>
- requestRaw("POST", `/me/submissions/${id}/context`, file, {
+ requestRaw("POST", urlPath`/me/submissions/${id}/context`, file, {
"Content-Type": "application/x-gzip",
}),
// Retract the caller's own pending submission (and its uploaded context), which
// frees their pending slot and storage budget. Reviewed submissions are frozen.
- withdrawSubmission: (id: string) => request("DELETE", `/me/submissions/${id}`),
+ withdrawSubmission: (id: string) => request("DELETE", urlPath`/me/submissions/${id}`),
getUpdateWindow: () => request("GET", "/updates/window"),
@@ -596,54 +683,54 @@ export const api = {
).then((r) => ({ users: r.users ?? [], total: r.total ?? 0 }));
},
- getUser: (id: string) => request("GET", `/users/${id}`),
+ getUser: (id: string) => request("GET", urlPath`/users/${id}`),
createUser: (req: CreateUserRequest) =>
request("POST", "/users", req),
patchUser: (id: string, patch: PatchUserRequest) =>
- request("PATCH", `/users/${id}`, patch),
+ request("PATCH", urlPath`/users/${id}`, patch),
deleteUser: (id: string) =>
- request<{ deleted: boolean }>("DELETE", `/users/${id}`),
+ request<{ deleted: boolean }>("DELETE", urlPath`/users/${id}`),
disableUser: (id: string, disabled: boolean) =>
- request<{ id: string; disabled: boolean }>("POST", `/users/${id}/disable`, { disabled }),
+ request<{ id: string; disabled: boolean }>("POST", urlPath`/users/${id}/disable`, { disabled }),
- getUserQuotas: (id: string) => request("GET", `/users/${id}/quotas`),
+ getUserQuotas: (id: string) => request("GET", urlPath`/users/${id}/quotas`),
setUserQuotas: (id: string, quotas: QuotaInput) =>
- request("PUT", `/users/${id}/quotas`, quotas),
+ request("PUT", urlPath`/users/${id}/quotas`, quotas),
listUserSessions: (id: string) =>
- request<{ sessions: SessionView[] }>("GET", `/users/${id}/sessions`).then((r) => r.sessions ?? []),
+ request<{ sessions: SessionView[] }>("GET", urlPath`/users/${id}/sessions`).then((r) => r.sessions ?? []),
revokeUserSessions: (id: string) =>
- request<{ ok: boolean }>("DELETE", `/users/${id}/sessions`),
+ request<{ ok: boolean }>("DELETE", urlPath`/users/${id}/sessions`),
revokeUserSession: (id: string, hash: string) =>
- request<{ ok: boolean }>("DELETE", `/users/${id}/sessions/${encodeURIComponent(hash)}`),
+ request<{ ok: boolean }>("DELETE", urlPath`/users/${id}/sessions/${hash}`),
// unbindUserPasskeys severs EVERY passkey the user holds (owner-tier account
// remediation for a lost or compromised authenticator). It is deliberately not
// a lockout — the account keeps its other doors (email OTP, in-game op-login
// re-enrollment). Unbinding an account that holds no passkeys is a 200 no-op.
unbindUserPasskeys: (id: string) =>
- request<{ ok: boolean }>("DELETE", `/users/${id}/passkeys`),
+ request<{ ok: boolean }>("DELETE", urlPath`/users/${id}/passkeys`),
linkAccount: (id: string, mcUuid: string, authSource?: string) =>
request<{ ok: boolean; mc_uuid: string; auth_source: string }>(
"POST",
- `/users/${id}/links`,
+ urlPath`/users/${id}/links`,
{ mc_uuid: mcUuid, auth_source: authSource ?? "mojang" },
),
unlinkAccount: (id: string, mcUuid: string) =>
request<{ ok: boolean; mc_uuid: string }>(
"DELETE",
- `/users/${id}/links/${encodeURIComponent(mcUuid)}`,
+ urlPath`/users/${id}/links/${mcUuid}`,
),
-};
+});
/**
* consoleStreamURL builds the §8 read-side SSE endpoint for a server. It mirrors
@@ -867,9 +954,20 @@ export function humanizeError(e: unknown): string {
return t("auth_unavailable");
case "setup_token_invalid":
return t("setup_token_invalid");
+ // The request never got a response: the network is down, or Cloudflare
+ // Access sent it to its login page because the Access session expired.
+ case "network_error":
+ return t("network_error");
+ // A proxy or the tunnel answered for the API (it is restarting or down).
+ case "upstream_unavailable":
+ return t("upstream_unavailable");
+ case "bad_path_param":
+ return t("bad_path_param");
default:
if (err.status === 401) return t("session_expired");
if (err.status === 403) return t("forbidden");
+ if (err.status === 413) return t("payload_too_large");
+ if (err.status !== undefined && err.status >= 500) return t("upstream_unavailable");
return err.message ?? t("generic");
}
}
diff --git a/panel/src/lib/auth.test.ts b/panel/src/lib/auth.test.ts
index 132d9fc..c7d45dd 100644
--- a/panel/src/lib/auth.test.ts
+++ b/panel/src/lib/auth.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { deriveAuth, isUnauthorized } from "./auth";
+import { deriveAuth, isUnauthorized, loginReturnPath } from "./auth";
import type { Identity } from "./types";
// deriveAuth is the load-bearing auth decision: it decides who is bounced to /login
@@ -69,3 +69,16 @@ describe("deriveAuth", () => {
expect(s.unauthenticated).toBe(false);
});
});
+
+describe("loginReturnPath", () => {
+ it("returns an in-app path as given", () => {
+ expect(loginReturnPath("/servers/lobby/files?x=1")).toBe("/servers/lobby/files?x=1");
+ expect(loginReturnPath("/admin/users/abc")).toBe("/admin/users/abc");
+ });
+
+ it("falls back to the dashboard for anything that could leave the site or loop", () => {
+ for (const next of [null, "", "servers", "https://evil.test", "//evil.test", "/\\evil.test", "/\t/evil.test", "/login", "/login?next=/x", "/setup"]) {
+ expect(loginReturnPath(next), JSON.stringify(next)).toBe("/");
+ }
+ });
+});
diff --git a/panel/src/lib/auth.ts b/panel/src/lib/auth.ts
index d157ad3..883b725 100644
--- a/panel/src/lib/auth.ts
+++ b/panel/src/lib/auth.ts
@@ -50,3 +50,16 @@ export function deriveAuth(
unauthenticated: !loading && identity === null && isUnauthorized(error),
};
}
+
+/** loginReturnPath reads the ?next= a sign-in redirect carried and returns the
+ * in-app path to land on afterwards. Only a same-origin absolute path counts:
+ * "//host" is a protocol-relative URL, and a browser reads a backslash as a
+ * slash and drops tabs and newlines, so "/\host" or "//host" would be one
+ * too; the sign-in pages themselves would loop. Each falls back to the
+ * dashboard. */
+export function loginReturnPath(next: string | null): string {
+ if (!next || !next.startsWith("/") || next.startsWith("//")) return "/";
+ if (/[\\\u0000-\u001f]/.test(next)) return "/";
+ if (/^\/(login|setup)(\/|\?|$)/.test(next)) return "/";
+ return next;
+}
diff --git a/panel/src/lib/params.test.ts b/panel/src/lib/params.test.ts
new file mode 100644
index 0000000..ad0d8dd
--- /dev/null
+++ b/panel/src/lib/params.test.ts
@@ -0,0 +1,24 @@
+import { describe, it, expect } from "vitest";
+import { SERVER_NAME_PARAM, USER_ID_PARAM } from "./params";
+
+describe("route param shapes", () => {
+ it("accepts real server names", () => {
+ for (const name of ["lobby", "login", "test-one", "a1", "x"]) {
+ expect(SERVER_NAME_PARAM.test(name), name).toBe(true);
+ }
+ });
+
+ it("refuses names a crafted link would carry", () => {
+ for (const name of ["..", ".", "", "a/b", "../users/x", "a?b", "a#b", "-a", "a-", "Lobby", "a b", "a".repeat(64)]) {
+ expect(SERVER_NAME_PARAM.test(name), JSON.stringify(name)).toBe(false);
+ }
+ });
+
+ it("accepts hex and UUID user ids, refuses path characters", () => {
+ expect(USER_ID_PARAM.test("0123456789abcdef0123456789abcdef")).toBe(true);
+ expect(USER_ID_PARAM.test("3f2b8c1e-9a4d-4c7e-8b1a-2d3e4f5a6b7c")).toBe(true);
+ for (const id of ["..", ".", "", "a/b", "a?b", "a%2Fb", "a.b"]) {
+ expect(USER_ID_PARAM.test(id), JSON.stringify(id)).toBe(false);
+ }
+ });
+});
diff --git a/panel/src/lib/params.ts b/panel/src/lib/params.ts
new file mode 100644
index 0000000..7f82311
--- /dev/null
+++ b/panel/src/lib/params.ts
@@ -0,0 +1,12 @@
+// Shapes of the URL parameters routes accept. react-router hands a param over
+// decoded ("%2F" becomes "/"), and pages build API paths from it, so a route
+// whose param does not fit renders "not found" before any request is made
+// (components/ValidParam.tsx). api.ts's urlPath encodes and refuses on its own;
+// this keeps a crafted link from reaching a page at all.
+
+/** Server names are DNS-1123 labels; internal/naming holds the stricter rule a
+ * new server's name must meet, so every existing server fits this one. */
+export const SERVER_NAME_PARAM = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
+
+/** User ids are opaque: 32 hex characters or a UUID today. */
+export const USER_ID_PARAM = /^[A-Za-z0-9_-]{1,128}$/;
diff --git a/panel/src/lib/tier.tsx b/panel/src/lib/tier.tsx
index 1a09fca..cd85748 100644
--- a/panel/src/lib/tier.tsx
+++ b/panel/src/lib/tier.tsx
@@ -8,11 +8,11 @@ import {
type ReactNode,
} from "react";
import type { Identity } from "./types";
-import { api } from "./api";
-import { deriveAuth, type AuthState } from "./auth";
+import { api, SESSION_EXPIRED_EVENT } from "./api";
+import { deriveAuth, isUnauthorized, type AuthState } from "./auth";
// TierProvider fetches GET /me at boot and re-fetches on demand (refresh), exposing
-// the result through context. Three design rules, all load-bearing:
+// the result through context. Four design rules, all load-bearing:
//
// 1. Fail-closed: `isAdmin` is `identity?.is_admin === true`. While /me is in
// flight (`identity === null`) or after it rejects, isAdmin is false — admin
@@ -29,6 +29,12 @@ import { deriveAuth, type AuthState } from "./auth";
// 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.
//
+// 4. Session-aware while open: a 401 from any protected call (api.ts announces
+// SESSION_EXPIRED_EVENT), the tab coming back into view, or the window
+// regaining focus re-reads /me through `revalidate()`. It leaves `loading`
+// alone, so the app stays mounted during the check; only a 401 changes what
+// is shown, and `sessionEnded` tells the login page why the person landed there.
+//
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
export interface TierState extends AuthState {
@@ -38,8 +44,17 @@ export interface TierState extends AuthState {
/** Re-fetch /me and recompute the auth state. Awaitable so callers can sequence a
* navigation after the context has settled (login → refresh → redirect). */
refresh: () => Promise;
+ /** Re-read /me in the background, keeping the app mounted. A 401 signs the
+ * person out (with sessionEnded set); any other failure changes nothing. */
+ revalidate: () => Promise;
+ /** True once a signed-in session was found to have ended (expired or revoked). */
+ sessionEnded: boolean;
}
+// A tab returning to view re-checks /me at most this often; a 401 from a real
+// call re-checks at once.
+const REVALIDATE_EVERY_MS = 60_000;
+
const TierContext = createContext({
identity: null,
loading: true,
@@ -47,6 +62,8 @@ const TierContext = createContext({
isOwner: false,
unauthenticated: false,
refresh: async () => {},
+ revalidate: async () => {},
+ sessionEnded: false,
});
export function TierProvider({ children }: { children: ReactNode }) {
@@ -58,24 +75,67 @@ export function TierProvider({ children }: { children: ReactNode }) {
// resolving after a subsequent login's): only the latest call commits its result.
const seq = useRef(0);
+ const [sessionEnded, setSessionEnded] = useState(false);
+ const loadingRef = useRef(true);
+ const identityRef = useRef(null);
+ const revalidating = useRef(false);
+ const lastCheck = useRef(0);
+
const refresh = useCallback(async () => {
const ticket = ++seq.current;
+ loadingRef.current = true;
setLoading(true);
try {
const id = await api.me();
if (ticket === seq.current) {
+ identityRef.current = id;
setIdentity(id);
setError(null);
+ setSessionEnded(false);
}
} catch (e) {
// Keep the error so deriveAuth can tell a 401 (→ login) from a transient
// failure (→ stay functional). Identity is cleared either way.
if (ticket === seq.current) {
+ identityRef.current = null;
setIdentity(null);
setError(e);
}
} finally {
- if (ticket === seq.current) setLoading(false);
+ if (ticket === seq.current) {
+ loadingRef.current = false;
+ setLoading(false);
+ }
+ lastCheck.current = Date.now();
+ }
+ }, []);
+
+ const revalidate = useCallback(async () => {
+ // A refresh in flight answers the same question and owns the loading flag;
+ // bumping the ticket under it would leave the spinner up for good.
+ if (loadingRef.current || revalidating.current) return;
+ revalidating.current = true;
+ const ticket = ++seq.current;
+ try {
+ const id = await api.me();
+ if (ticket === seq.current) {
+ // An unchanged identity keeps its object so nothing re-renders.
+ if (JSON.stringify(id) !== JSON.stringify(identityRef.current)) {
+ identityRef.current = id;
+ setIdentity(id);
+ }
+ setError(null);
+ }
+ } catch (e) {
+ if (ticket === seq.current && isUnauthorized(e)) {
+ if (identityRef.current !== null) setSessionEnded(true);
+ identityRef.current = null;
+ setIdentity(null);
+ setError(e);
+ }
+ } finally {
+ revalidating.current = false;
+ lastCheck.current = Date.now();
}
}, []);
@@ -83,6 +143,23 @@ export function TierProvider({ children }: { children: ReactNode }) {
void refresh();
}, [refresh]);
+ useEffect(() => {
+ const onExpired = () => void revalidate();
+ const onReturn = () => {
+ if (document.visibilityState !== "visible") return;
+ if (Date.now() - lastCheck.current < REVALIDATE_EVERY_MS) return;
+ void revalidate();
+ };
+ window.addEventListener(SESSION_EXPIRED_EVENT, onExpired);
+ window.addEventListener("focus", onReturn);
+ document.addEventListener("visibilitychange", onReturn);
+ return () => {
+ window.removeEventListener(SESSION_EXPIRED_EVENT, onExpired);
+ window.removeEventListener("focus", onReturn);
+ document.removeEventListener("visibilitychange", onReturn);
+ };
+ }, [revalidate]);
+
const state = deriveAuth(identity, error, loading);
// isOwner is separate from isAdmin: an owner implicitly passes isAdmin (the
// backend grades by operation), but isOwner gates user management. Both are
@@ -90,7 +167,7 @@ export function TierProvider({ children }: { children: ReactNode }) {
const isOwner = identity?.is_owner === true;
return (
-
+
{children}
);
diff --git a/panel/src/lib/useLogStream.ts b/panel/src/lib/useLogStream.ts
index 6fb8ab2..4a19cfe 100644
--- a/panel/src/lib/useLogStream.ts
+++ b/panel/src/lib/useLogStream.ts
@@ -5,6 +5,7 @@ import {
type StreamStatus,
type LogLine,
} from "./logstream";
+import { useTier } from "./tier";
// browserEventSource is the production factory: a native EventSource with
// credentials, so the Cloudflare Access cookie rides along exactly as api.ts's
@@ -46,6 +47,14 @@ export function useLogStream(url: string): UseLogStream {
const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
+ // EventSource reports a refused stream (a 401 included) only as an error, so
+ // an ended stream re-checks the session: an expired one takes the person to
+ // sign in, and otherwise the console shows the stream as ended.
+ const { revalidate } = useTier();
+ useEffect(() => {
+ if (snapshot.status === "ended") void revalidate();
+ }, [snapshot.status, revalidate]);
+
const clear = useCallback(() => controller.clear(), [controller]);
const reconnect = useCallback(() => controller.reconnect(), [controller]);
diff --git a/panel/src/pages/Login.tsx b/panel/src/pages/Login.tsx
index c07466f..41c4943 100644
--- a/panel/src/pages/Login.tsx
+++ b/panel/src/pages/Login.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect, type FormEvent } from "react";
-import { Navigate, useNavigate } from "react-router-dom";
+import { Navigate, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { Loader2, KeyRound, Mail, Fingerprint, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/components/AuthLayout";
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTier } from "@/lib/tier";
+import { loginReturnPath } from "@/lib/auth";
import { api, humanizeError } from "@/lib/api";
import { loadConfig } from "@/lib/config";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
@@ -20,11 +21,17 @@ import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
// re-evaluates and land on the dashboard.
//
// Reaching this page already-authenticated (e.g. typing /login while signed in)
-// short-circuits to the dashboard rather than showing the form.
+// short-circuits to the dashboard rather than showing the form. RequireAuth sends
+// a signed-out visitor here with ?next= (the page they were on) and, when their
+// session ended under an open page, state.sessionEnded, which shows why; after
+// signing in they land back on that page.
export function Login() {
const { loading, identity, refresh } = useTier();
const navigate = useNavigate();
const { t } = useTranslation("auth");
+ const [searchParams] = useSearchParams();
+ const next = loginReturnPath(searchParams.get("next"));
+ const sessionEnded = (useLocation().state as { sessionEnded?: boolean } | null)?.sessionEnded === true;
const [activeTab, setActiveTab] = useState<"main" | "bind" | "op">("main");
const [email, setEmail] = useState("");
@@ -89,7 +96,7 @@ export function Login() {
);
}
- if (identity) return ;
+ if (identity) return ;
async function handleBindSubmit(e: FormEvent) {
e.preventDefault();
@@ -100,7 +107,7 @@ export function Login() {
try {
await api.bind(code);
await refresh();
- navigate("/", { replace: true });
+ navigate(next, { replace: true });
} catch (err) {
setError(humanizeError(err));
setSubmitting(false);
@@ -130,7 +137,7 @@ export function Login() {
try {
await api.authEmailVerify(email.trim(), otpCode.trim());
await refresh();
- navigate("/", { replace: true });
+ navigate(next, { replace: true });
} catch (err) {
setError(humanizeError(err));
setSubmitting(false);
@@ -220,7 +227,7 @@ export function Login() {
}
await refresh();
- navigate("/", { replace: true });
+ navigate(next, { replace: true });
} catch (err: any) {
setError(humanizeError(err));
} finally {
@@ -251,7 +258,7 @@ export function Login() {
try {
await api.opLoginFinish(opRequestId, opCode.trim());
await refresh();
- navigate("/", { replace: true });
+ navigate(next, { replace: true });
} catch (err) {
setError(humanizeError(err));
setSubmitting(false);
@@ -268,6 +275,14 @@ export function Login() {
title={t("login_title")}
subtitle={t(isOpHost ? "login_subtitle_op" : "login_subtitle")}
>
+ {sessionEnded && (
+
+ {t("session_ended_notice")}
+
+ )}
{activeTab === "main" && (