diff --git a/panel/src/components/AppShell.test.tsx b/panel/src/components/AppShell.test.tsx new file mode 100644 index 0000000..2821172 --- /dev/null +++ b/panel/src/components/AppShell.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import i18next from "i18next"; +import { AppShell } from "./AppShell"; +import type { Identity } from "@/lib/types"; + +const tier = vi.hoisted(() => ({ + identity: null as Identity | null, + isAdmin: false, + isOwner: false, + loading: false, + refresh: () => Promise.resolve(), +})); +vi.mock("@/lib/tier", () => ({ useTier: () => tier })); +vi.mock("@/lib/config", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadConfig: () => Promise.resolve({}) }; +}); + +afterEach(() => { + tier.identity = null; + return i18next.changeLanguage("en-US"); +}); + +function renderShell() { + render( + + + }> + page

} /> +
+
+
, + ); +} + +// The line under the email in the sidebar's user card. +function roleLine(email: string) { + return screen.getByText(email).nextElementSibling?.textContent; +} + +describe("AppShell user card", () => { + it.each([ + ["en-US", "owner", "Owner"], + ["en-US", "user", "User"], + ["zh-CN", "admin", "管理员"], + ["zh-CN", "user", "普通用户"], + ] as const)("names the role in %s: %s is %s", async (lang, role, shown) => { + await i18next.changeLanguage(lang); + tier.identity = { user_id: "u-1", email: "a@example.test", role } as Identity; + renderShell(); + expect(roleLine("a@example.test")).toBe(shown); + }); + + it("names no role before it has read one", () => { + renderShell(); + const dashes = screen.getAllByText("—"); + expect(dashes).toHaveLength(2); + expect(screen.queryByText(/^user$/i)).toBeNull(); + }); +}); diff --git a/panel/src/components/AppShell.tsx b/panel/src/components/AppShell.tsx index a416558..8fe7d08 100644 --- a/panel/src/components/AppShell.tsx +++ b/panel/src/components/AppShell.tsx @@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button"; import { ConfigBanner, VersionBadge } from "@/components/RuntimeStatus"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { Loading } from "@/components/States"; +import { ROLE_LABEL_KEY } from "@/components/RoleBadge"; function SectionGroup({ section, @@ -71,9 +72,9 @@ const FOOT_ICON_BTN = function UserStrip() { // The sidebar foot identifies the principal and exposes one action — sign out. - // identity?.email is the only display-safe field (user_id is a UUID, role is - // server-truth not display). While /me is loading or has failed we render a - // muted placeholder rather than a broken row, so the strip never flashes empty. + // It shows the email and the role by its name in the UI language (user_id is a + // UUID). While /me is loading or has failed both lines hold a muted dash, so + // the strip never flashes empty and never names a role it has not read. const { identity, refresh } = useTier(); const { t } = useTranslation("account"); const [signingOut, setSigningOut] = useState(false); @@ -99,8 +100,8 @@ function UserStrip() { > {identity?.email ?? —} -
- {identity?.role ?? "user"} +
+ {identity ? t(`admin:${ROLE_LABEL_KEY[identity.role]}`) : —}