} />
+
+ }>
+ users page} />
+
+
+ ,
+ );
+}
+
+const retryButton = () => screen.getByRole("button", { name: t("common:try_again") });
+
+beforeEach(() => {
+ Object.assign(tier, { loading: false, isAdmin: false, isOwner: false, identityError: null });
+ tier.revalidate.mockReset();
+ tier.revalidate.mockResolvedValue(undefined);
+});
+
+describe.each([
+ ["RequireAdmin", "/admin", "admin page", "isAdmin"],
+ ["RequireOwner", "/admin/users", "users page", "isOwner"],
+] as const)("%s", (_name, path, page, flag) => {
+ it("says the access check failed, with why, when /me failed with anything but a 401", () => {
+ tier.identityError = err503;
+ renderAt(path);
+
+ expect(screen.getByText(t("common:access_unknown_title"))).toBeTruthy();
+ expect(screen.getByRole("alert").textContent).toBe(humanizeError(err503));
+ expect(retryButton()).toBeTruthy();
+ expect(screen.queryByText(t("common:not_authorized_title"))).toBeNull();
+ expect(screen.queryByText(page)).toBeNull();
+ });
+
+ it("re-reads /me in place from the retry, and holds the button until it answers", async () => {
+ tier.identityError = err503;
+ let answer!: () => void;
+ tier.revalidate.mockReturnValue(new Promise((resolve) => (answer = resolve)));
+ renderAt(path);
+
+ fireEvent.click(retryButton());
+ expect(tier.revalidate).toHaveBeenCalledTimes(1);
+ expect((retryButton() as HTMLButtonElement).disabled).toBe(true);
+ fireEvent.click(retryButton());
+ expect(tier.revalidate).toHaveBeenCalledTimes(1);
+
+ await act(async () => answer());
+ expect((retryButton() as HTMLButtonElement).disabled).toBe(false);
+ });
+
+ it("still says not authorized to a known account without the role", () => {
+ renderAt(path);
+
+ expect(screen.getByText(t("common:not_authorized_title"))).toBeTruthy();
+ expect(screen.queryByText(t("common:access_unknown_title"))).toBeNull();
+ });
+
+ it("renders the page for an account with the role", () => {
+ tier[flag] = true;
+ renderAt(path);
+
+ expect(screen.getByText(page)).toBeTruthy();
+ });
+});
diff --git a/panel/src/components/RequireAdmin.tsx b/panel/src/components/RequireAdmin.tsx
index 9e4d4c3..4f476c4 100644
--- a/panel/src/components/RequireAdmin.tsx
+++ b/panel/src/components/RequireAdmin.tsx
@@ -1,18 +1,20 @@
import { Outlet } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useTier } from "@/lib/tier";
-import { Loading, NotAuthorized } from "@/components/States";
+import { AccessUnknown, Loading, NotAuthorized } from "@/components/States";
// RequireAdmin is the single route wrapper for BOTH /admin/* and /ops/* (the two
// admin-tier concerns). It is belt-and-suspenders: it spares non-admins a wall of
// 403s, but the server enforces the boundary independently on every data call.
//
// While /me is still loading we show a spinner rather than NotAuthorized, so a
-// genuine admin is never briefly told "not authorized" on a slow boot. Once
-// settled, isAdmin is the fail-closed verdict (false on any /me failure).
+// genuine admin is never briefly told "not authorized" on a slow boot. A /me that
+// failed with anything but a 401 leaves the answer unknown, so the page says the
+// check failed and offers a retry. Otherwise isAdmin is the fail-closed verdict.
export function RequireAdmin() {
- const { isAdmin, loading } = useTier();
+ const { isAdmin, loading, identityError } = useTier();
const { t } = useTranslation("common");
if (loading) return ;
+ if (identityError) return ;
return isAdmin ? : ;
}
diff --git a/panel/src/components/RequireOwner.tsx b/panel/src/components/RequireOwner.tsx
index 8b79f23..d2ae54e 100644
--- a/panel/src/components/RequireOwner.tsx
+++ b/panel/src/components/RequireOwner.tsx
@@ -1,14 +1,19 @@
import { Outlet } from "react-router-dom";
import { useTier } from "@/lib/tier";
-import { NotAuthorized } from "@/components/States";
-import { Loading } from "@/components/States";
+import { AccessUnknown, Loading, NotAuthorized } from "@/components/States";
+// RequireOwner gates user management, one level above admin. Like RequireAdmin
+// it waits for /me, and a /me that failed with anything but a 401 gets a retry:
+// the owner is not told "not authorized" on a flaky read.
export function RequireOwner() {
- const { loading, isOwner } = useTier();
+ const { loading, isOwner, identityError } = useTier();
if (loading) {
return ;
}
+ if (identityError) {
+ return ;
+ }
if (!isOwner) {
return ;
}
diff --git a/panel/src/components/States.tsx b/panel/src/components/States.tsx
index c462111..f232b90 100644
--- a/panel/src/components/States.tsx
+++ b/panel/src/components/States.tsx
@@ -1,9 +1,12 @@
-import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react";
+import { useState } from "react";
+import { Loader2, AlertTriangle, Inbox, ShieldX, ShieldQuestion, Construction, Moon, SearchX, RefreshCw } from "lucide-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { MAX_AUTO_RESTARTS, shownPhase, type StartFailure } from "@/components/PhaseBadge";
import { PowerButton } from "@/components/PowerButton";
+import { Button } from "@/components/ui/button";
import { humanizeError } from "@/lib/api";
+import { useTier } from "@/lib/tier";
import type { Phase, RetireState } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -94,6 +97,41 @@ export function NotAuthorized() {
);
}
+// AccessUnknown stands in for an admin or owner page while /me has failed with
+// anything but a 401: whether this account may see the page is unknown, so it
+// says the check failed and why, and retries in place (the app stays mounted).
+export function AccessUnknown({ error }: { error: unknown }) {
+ const { t } = useTranslation("common");
+ const { revalidate } = useTier();
+ const [retrying, setRetrying] = useState(false);
+
+ async function retry() {
+ setRetrying(true);
+ try {
+ await revalidate();
+ } finally {
+ setRetrying(false);
+ }
+ }
+
+ return (
+
+
+
+
{t("access_unknown_title")}
+
{t("access_unknown_body")}
+
+ {humanizeError(error)}
+
+
+
+
+ );
+}
+
export interface NotFoundProps {
title?: string;
body?: string;
diff --git a/panel/src/i18n/resources/en-US/common.json b/panel/src/i18n/resources/en-US/common.json
index aab9826..c9b2264 100644
--- a/panel/src/i18n/resources/en-US/common.json
+++ b/panel/src/i18n/resources/en-US/common.json
@@ -12,6 +12,8 @@
"not_wired_yet": "Not wired yet",
"pending_backend_prefix": "pending backend: ",
"checking_access": "Checking access…",
+ "access_unknown_title": "Couldn't check your access",
+ "access_unknown_body": "Your account details didn't load, so this page stays hidden until they do.",
"loading_config": "Loading config…",
"brand_name": "Felis",
"brand_tagline": "K8s-native Minecraft orchestration",
diff --git a/panel/src/i18n/resources/zh-CN/common.json b/panel/src/i18n/resources/zh-CN/common.json
index c00c5f9..43eb832 100644
--- a/panel/src/i18n/resources/zh-CN/common.json
+++ b/panel/src/i18n/resources/zh-CN/common.json
@@ -12,6 +12,8 @@
"not_wired_yet": "功能尚未接入",
"pending_backend_prefix": "待接入接口:",
"checking_access": "正在验证权限…",
+ "access_unknown_title": "暂时无法确认你的权限",
+ "access_unknown_body": "账户信息没有读到,读到之前这一页先不显示。",
"loading_config": "正在加载配置…",
"brand_name": "Felis",
"brand_tagline": "Kubernetes 原生的 Minecraft 管理平台",
diff --git a/panel/src/lib/auth.test.ts b/panel/src/lib/auth.test.ts
index c7d45dd..53c3b37 100644
--- a/panel/src/lib/auth.test.ts
+++ b/panel/src/lib/auth.test.ts
@@ -68,6 +68,19 @@ describe("deriveAuth", () => {
expect(s.isAdmin).toBe(false);
expect(s.unauthenticated).toBe(false);
});
+
+ it("keeps a settled non-401 failure as identityError, so a gate can offer a retry", () => {
+ expect(deriveAuth(null, err500, false).identityError).toBe(err500);
+ const offline = new TypeError("Failed to fetch");
+ expect(deriveAuth(null, offline, false).identityError).toBe(offline);
+ });
+
+ it("has no identityError for a 401, while loading, or once an identity is known", () => {
+ expect(deriveAuth(null, err401, false).identityError).toBeNull();
+ expect(deriveAuth(null, err500, true).identityError).toBeNull();
+ expect(deriveAuth(admin, err500, false).identityError).toBeNull();
+ expect(deriveAuth(null, null, false).identityError).toBeNull();
+ });
});
describe("loginReturnPath", () => {
diff --git a/panel/src/lib/auth.ts b/panel/src/lib/auth.ts
index 883b725..3f6e0cc 100644
--- a/panel/src/lib/auth.ts
+++ b/panel/src/lib/auth.ts
@@ -20,6 +20,11 @@ export interface AuthState {
/** True ONLY when /me returned 401 — no/expired session, route to /login. A
* transient or 5xx failure leaves this false so the app keeps rendering. */
unauthenticated: boolean;
+ /** The settled /me failure that left the identity unknown (anything but a
+ * 401), else null. The app keeps running, but an admin cannot be told from a
+ * user until a retry succeeds, so a gate says so and offers the retry instead
+ * of "not authorized". */
+ identityError: unknown;
}
/** isUnauthorized reports whether a caught error is the request() 401 envelope —
@@ -48,6 +53,7 @@ export function deriveAuth(
loading,
isAdmin: identity?.is_admin === true,
unauthenticated: !loading && identity === null && isUnauthorized(error),
+ identityError: !loading && identity === null && error != null && !isUnauthorized(error) ? error : null,
};
}
diff --git a/panel/src/lib/tier.test.tsx b/panel/src/lib/tier.test.tsx
new file mode 100644
index 0000000..9e1262f
--- /dev/null
+++ b/panel/src/lib/tier.test.tsx
@@ -0,0 +1,69 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { act, render, screen } from "@testing-library/react";
+import { TierProvider, useTier } from "./tier";
+import type { Identity } from "./types";
+
+const calls = vi.hoisted(() => ({ me: vi.fn() }));
+vi.mock("./api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, api: { ...actual.api, ...calls } };
+});
+
+const admin: Identity = { user_id: "u1", email: "a@b.c", role: "admin", is_admin: true, is_owner: false };
+const err503 = { status: 503, code: "unavailable", message: "restarting" };
+
+let tier: ReturnType;
+function Probe() {
+ tier = useTier();
+ return
{tier.isAdmin ? "admin" : "not admin"}
;
+}
+
+async function boot() {
+ render(
+
+
+ ,
+ );
+ await act(async () => {});
+}
+
+beforeEach(() => {
+ calls.me.mockReset();
+});
+
+describe("TierProvider after a /me that failed with anything but a 401", () => {
+ it("exposes the failure, and a revalidate that succeeds clears it without a reload", async () => {
+ calls.me.mockRejectedValueOnce(err503);
+ await boot();
+ expect(tier.identityError).toBe(err503);
+ expect(tier.unauthenticated).toBe(false);
+
+ let answer!: (id: Identity) => void;
+ calls.me.mockReturnValueOnce(new Promise((resolve) => (answer = resolve)));
+ let retry!: Promise;
+ act(() => {
+ retry = tier.revalidate();
+ });
+ // Mid-check the app stays mounted: loading never comes back.
+ expect(tier.loading).toBe(false);
+
+ await act(async () => {
+ answer(admin);
+ await retry;
+ });
+ expect(tier.identityError).toBeNull();
+ expect(screen.getByText("admin")).toBeTruthy();
+ });
+
+ it("keeps the failure when the retry fails the same way", async () => {
+ calls.me.mockRejectedValueOnce(err503);
+ await boot();
+
+ calls.me.mockRejectedValueOnce(new TypeError("Failed to fetch"));
+ await act(() => tier.revalidate());
+
+ expect(tier.identityError).toBe(err503);
+ expect(tier.unauthenticated).toBe(false);
+ });
+});
diff --git a/panel/src/lib/tier.tsx b/panel/src/lib/tier.tsx
index cd85748..6755a04 100644
--- a/panel/src/lib/tier.tsx
+++ b/panel/src/lib/tier.tsx
@@ -25,6 +25,8 @@ import { deriveAuth, isUnauthorized, type AuthState } from "./auth";
// principal whose /me momentarily fails still gets the full User-Side app; they
// simply don't see admin surfaces. (The backend 403s admin data calls
// independently, so this is safe.) Only a genuine 401 sets `unauthenticated`.
+// The failure itself is `identityError`: the admin and owner gates show it
+// with a retry, since "not authorized" would be a guess.
//
// 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.
@@ -61,6 +63,7 @@ const TierContext = createContext({
isAdmin: false,
isOwner: false,
unauthenticated: false,
+ identityError: null,
refresh: async () => {},
revalidate: async () => {},
sessionEnded: false,