fix(panel): 侧栏用户卡按界面语言显示角色名,身份未读到时显示横线
This commit is contained in:
3 files changed
+72
-7
No files matched your search
@@ -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<typeof import("@/lib/config")>();
|
||||||
|
return { ...actual, loadConfig: () => Promise.resolve({}) };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
tier.identity = null;
|
||||||
|
return i18next.changeLanguage("en-US");
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderShell() {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<AppShell />}>
|
||||||
|
<Route index element={<p>page</p>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: "[email protected]", role } as Identity;
|
||||||
|
renderShell();
|
||||||
|
expect(roleLine("[email protected]")).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { ConfigBanner, VersionBadge } from "@/components/RuntimeStatus";
|
import { ConfigBanner, VersionBadge } from "@/components/RuntimeStatus";
|
||||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
import { Loading } from "@/components/States";
|
import { Loading } from "@/components/States";
|
||||||
|
import { ROLE_LABEL_KEY } from "@/components/RoleBadge";
|
||||||
|
|
||||||
function SectionGroup({
|
function SectionGroup({
|
||||||
section,
|
section,
|
||||||
@@ -71,9 +72,9 @@ const FOOT_ICON_BTN =
|
|||||||
|
|
||||||
function UserStrip() {
|
function UserStrip() {
|
||||||
// The sidebar foot identifies the principal and exposes one action — sign out.
|
// 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
|
// It shows the email and the role by its name in the UI language (user_id is a
|
||||||
// server-truth not display). While /me is loading or has failed we render a
|
// UUID). While /me is loading or has failed both lines hold a muted dash, so
|
||||||
// muted placeholder rather than a broken row, so the strip never flashes empty.
|
// the strip never flashes empty and never names a role it has not read.
|
||||||
const { identity, refresh } = useTier();
|
const { identity, refresh } = useTier();
|
||||||
const { t } = useTranslation("account");
|
const { t } = useTranslation("account");
|
||||||
const [signingOut, setSigningOut] = useState(false);
|
const [signingOut, setSigningOut] = useState(false);
|
||||||
@@ -99,8 +100,8 @@ function UserStrip() {
|
|||||||
>
|
>
|
||||||
{identity?.email ?? <span className="text-muted-foreground/40">—</span>}
|
{identity?.email ?? <span className="text-muted-foreground/40">—</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground/70 capitalize font-medium">
|
<div className="text-[10px] text-muted-foreground/70 font-medium">
|
||||||
{identity?.role ?? "user"}
|
{identity ? t(`admin:${ROLE_LABEL_KEY[identity.role]}`) : <span className="text-muted-foreground/40">—</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const STYLE_MAP = {
|
|||||||
user: "bg-muted text-muted-foreground",
|
user: "bg-muted text-muted-foreground",
|
||||||
};
|
};
|
||||||
|
|
||||||
const I18N_KEY = {
|
// The role's name in the UI language, under the admin namespace.
|
||||||
|
export const ROLE_LABEL_KEY = {
|
||||||
owner: "users_role_owner",
|
owner: "users_role_owner",
|
||||||
admin: "users_role_admin",
|
admin: "users_role_admin",
|
||||||
user: "users_role_user",
|
user: "users_role_user",
|
||||||
@@ -27,7 +28,7 @@ export function RoleBadge({ role }: { role: "owner" | "admin" | "user" }) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-3 w-3" />
|
<Icon className="h-3 w-3" />
|
||||||
{t(I18N_KEY[role])}
|
{t(ROLE_LABEL_KEY[role])}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user