fix(tier): /me 临时失败时管理页显示权限确认失败和重试,不再误报无权访问
This commit is contained in:
10 files changed
+237
-8
No files matched your search
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import i18next from "i18next";
|
||||
import { humanizeError } from "@/lib/api";
|
||||
import { RequireAdmin } from "./RequireAdmin";
|
||||
import { RequireOwner } from "./RequireOwner";
|
||||
|
||||
const tier = vi.hoisted(() => ({
|
||||
loading: false,
|
||||
isAdmin: false,
|
||||
isOwner: false,
|
||||
identityError: null as unknown,
|
||||
revalidate: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/tier", () => ({ useTier: () => tier }));
|
||||
|
||||
const t = (key: string) => i18next.t(key);
|
||||
const err503 = { status: 503, code: "unavailable", message: "database is restarting" };
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route element={<RequireAdmin />}>
|
||||
<Route path="/admin" element={<p>admin page</p>} />
|
||||
</Route>
|
||||
<Route element={<RequireOwner />}>
|
||||
<Route path="/admin/users" element={<p>users page</p>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
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<void>((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();
|
||||
});
|
||||
});
|
||||
@@ -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 <Loading label={t("checking_access")} />;
|
||||
if (identityError) return <AccessUnknown error={identityError} />;
|
||||
return isAdmin ? <Outlet /> : <NotAuthorized />;
|
||||
}
|
||||
@@ -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 <Loading />;
|
||||
}
|
||||
if (identityError) {
|
||||
return <AccessUnknown error={identityError} />;
|
||||
}
|
||||
if (!isOwner) {
|
||||
return <NotAuthorized />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center">
|
||||
<ShieldQuestion className="h-8 w-8 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">{t("access_unknown_title")}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("access_unknown_body")}</p>
|
||||
<p role="alert" className="mt-2 text-xs text-muted-foreground/80">
|
||||
{humanizeError(error)}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => void retry()} disabled={retrying}>
|
||||
{retrying ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
{t("try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface NotFoundProps {
|
||||
title?: string;
|
||||
body?: string;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 管理平台",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof import("./api")>();
|
||||
return { ...actual, api: { ...actual.api, ...calls } };
|
||||
});
|
||||
|
||||
const admin: Identity = { user_id: "u1", email: "[email protected]", role: "admin", is_admin: true, is_owner: false };
|
||||
const err503 = { status: 503, code: "unavailable", message: "restarting" };
|
||||
|
||||
let tier: ReturnType<typeof useTier>;
|
||||
function Probe() {
|
||||
tier = useTier();
|
||||
return <p>{tier.isAdmin ? "admin" : "not admin"}</p>;
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
render(
|
||||
<TierProvider>
|
||||
<Probe />
|
||||
</TierProvider>,
|
||||
);
|
||||
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<Identity>((resolve) => (answer = resolve)));
|
||||
let retry!: Promise<void>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<TierState>({
|
||||
isAdmin: false,
|
||||
isOwner: false,
|
||||
unauthenticated: false,
|
||||
identityError: null,
|
||||
refresh: async () => {},
|
||||
revalidate: async () => {},
|
||||
sessionEnded: false,
|
||||
|
||||
Reference in new issue
Block a user