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" && (