Unverified Commit 4291d08e authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(tier): /me 临时失败时管理页显示权限确认失败和重试,不再误报无权访问

parent e519549c
Loading
Loading
Loading
Loading
+89 −0
Changes for panel/src/components/RequireAdmin.test.tsx: 89 added lines, 0 removed lines.
Original line number Diff line number Diff line
// @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();
  });
});
+6 −4
Changes for panel/src/components/RequireAdmin.tsx: 6 added lines, 4 removed lines.
Original line number Diff line number Diff line
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 />;
}
+8 −3
Changes for panel/src/components/RequireOwner.tsx: 8 added lines, 3 removed lines.
Original line number Diff line number Diff line
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 />;
  }
+39 −1
Changes for panel/src/components/States.tsx: 39 added lines, 1 removed line.
Original line number Diff line number Diff line
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;
+2 −0
Changes for panel/src/i18n/resources/en-US/common.json: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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",
Loading