fix(auth): align restricted header and logout state
This commit is contained in:
@@ -17,6 +17,7 @@ test("已登录管理员可以打开审计日志页面", async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("已认证但未授权的账号会看到明确说明", async ({ page }) => {
|
test("已认证但未授权的账号会看到明确说明", async ({ page }) => {
|
||||||
|
await page.addInitScript(() => window.localStorage.clear());
|
||||||
await page.route("**/api/v1/access-context", (route) =>
|
await page.route("**/api/v1/access-context", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 403,
|
status: 403,
|
||||||
@@ -31,12 +32,44 @@ test("已认证但未授权的账号会看到明确说明", async ({ page }) =>
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByText("身份认证已成功,但当前账号尚未由系统管理员启用"),
|
page.getByText("身份认证已成功,但当前账号尚未由系统管理员启用"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.getByText("TJWater 智慧水务平台")).toBeVisible();
|
const accountMenu = page.getByRole("button", {
|
||||||
await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible();
|
name: "E2E 测试用户 账号菜单",
|
||||||
|
});
|
||||||
|
await expect(accountMenu).toBeVisible();
|
||||||
|
await accountMenu.click();
|
||||||
|
await expect(page.getByRole("menuitem", { name: "登出" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("menuitem", { name: "切换项目" })).toHaveCount(0);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await expect(page.getByText("账号尚未获得访问权限")).toBeVisible();
|
await expect(page.getByText("账号尚未获得访问权限")).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible();
|
await expect(accountMenu).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("无权访问项目时可以在窄屏切换项目", async ({ page }) => {
|
||||||
|
await page.route("**/api/v1/access-context", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 403,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "No access to project" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expect(page.getByText("无权访问当前项目")).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "E2E 测试用户 账号菜单" })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("menuitem", { name: "切换项目" }).click();
|
||||||
|
|
||||||
|
const projectDialog = page.getByRole("dialog");
|
||||||
|
await expect(projectDialog).toBeVisible();
|
||||||
|
const bounds = await projectDialog.boundingBox();
|
||||||
|
expect(bounds).not.toBeNull();
|
||||||
|
expect(bounds!.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("系统管理员可以手动同步 Keycloak 用户目录", async ({ page }) => {
|
test("系统管理员可以手动同步 Keycloak 用户目录", async ({ page }) => {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ let mockSessionState: {
|
|||||||
},
|
},
|
||||||
status: "authenticated",
|
status: "authenticated",
|
||||||
};
|
};
|
||||||
|
let mockPathname = "/network-simulation";
|
||||||
|
let mockProjectProviderBlocks = false;
|
||||||
|
|
||||||
jest.mock("next-auth/react", () => ({
|
jest.mock("next-auth/react", () => ({
|
||||||
SessionProvider: ({ children }: { children: ReactNode }) => children,
|
SessionProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
@@ -26,7 +28,7 @@ jest.mock("next-auth/react", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("next/navigation", () => ({
|
jest.mock("next/navigation", () => ({
|
||||||
usePathname: () => "/network-simulation",
|
usePathname: () => mockPathname,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@refinedev/core", () => ({
|
jest.mock("@refinedev/core", () => ({
|
||||||
@@ -43,6 +45,11 @@ jest.mock("@refinedev/mui", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@refinedev/nextjs-router", () => ({}));
|
jest.mock("@refinedev/nextjs-router", () => ({}));
|
||||||
|
jest.mock("@/components/header", () => ({
|
||||||
|
Header: ({ accessMode }: { accessMode: string }) => (
|
||||||
|
<div data-testid="access-header">{accessMode}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
jest.mock("@providers/data-provider", () => ({ dataProvider: {} }));
|
jest.mock("@providers/data-provider", () => ({ dataProvider: {} }));
|
||||||
jest.mock("@/providers/notification-provider/useAppNotificationProvider", () => ({
|
jest.mock("@/providers/notification-provider/useAppNotificationProvider", () => ({
|
||||||
useAppNotificationProvider: {},
|
useAppNotificationProvider: {},
|
||||||
@@ -51,7 +58,8 @@ jest.mock("@contexts/color-mode", () => ({
|
|||||||
ColorModeContextProvider: ({ children }: { children: ReactNode }) => children,
|
ColorModeContextProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
}));
|
}));
|
||||||
jest.mock("@/contexts/ProjectContext", () => ({
|
jest.mock("@/contexts/ProjectContext", () => ({
|
||||||
ProjectProvider: ({ children }: { children: ReactNode }) => children,
|
ProjectProvider: ({ children }: { children: ReactNode }) =>
|
||||||
|
mockProjectProviderBlocks ? <div>请选择项目环境</div> : children,
|
||||||
}));
|
}));
|
||||||
jest.mock("@/lib/authToken", () => ({
|
jest.mock("@/lib/authToken", () => ({
|
||||||
getAccessToken: jest.fn().mockResolvedValue("expired-access-token"),
|
getAccessToken: jest.fn().mockResolvedValue("expired-access-token"),
|
||||||
@@ -62,6 +70,8 @@ describe("RefineContext access authentication", () => {
|
|||||||
const originalRequest = global.Request;
|
const originalRequest = global.Request;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mockPathname = "/network-simulation";
|
||||||
|
mockProjectProviderBlocks = false;
|
||||||
mockSessionState = {
|
mockSessionState = {
|
||||||
data: {
|
data: {
|
||||||
accessToken: "expired-access-token",
|
accessToken: "expired-access-token",
|
||||||
@@ -116,6 +126,7 @@ describe("RefineContext access authentication", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shows an authorization message when access-context rejects the account", async () => {
|
it("shows an authorization message when access-context rejects the account", async () => {
|
||||||
|
mockProjectProviderBlocks = true;
|
||||||
global.fetch = jest.fn().mockResolvedValue({
|
global.fetch = jest.fn().mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 403,
|
status: 403,
|
||||||
@@ -132,10 +143,7 @@ describe("RefineContext access authentication", () => {
|
|||||||
expect(
|
expect(
|
||||||
await screen.findByText("账号尚未获得访问权限"),
|
await screen.findByText("账号尚未获得访问权限"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(screen.getByTestId("access-header")).toHaveTextContent("account");
|
||||||
screen.getByRole("button", { name: "退出登录" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Test User")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
|
expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
|
||||||
expect(useAuthStore.getState()).toMatchObject({
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
sessionExpired: false,
|
sessionExpired: false,
|
||||||
@@ -159,8 +167,9 @@ describe("RefineContext access authentication", () => {
|
|||||||
|
|
||||||
expect(await screen.findByText("无权访问当前项目")).toBeInTheDocument();
|
expect(await screen.findByText("无权访问当前项目")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "切换项目" }),
|
screen.queryByRole("button", { name: "切换项目" }),
|
||||||
).toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("access-header")).toHaveTextContent("project");
|
||||||
expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
|
expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,4 +192,22 @@ describe("RefineContext access authentication", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not treat an intentional logout landing on login as session expiry", async () => {
|
||||||
|
mockPathname = "/login";
|
||||||
|
mockSessionState = { data: null, status: "unauthenticated" };
|
||||||
|
|
||||||
|
render(
|
||||||
|
<App>
|
||||||
|
<div>登录页面</div>
|
||||||
|
</App>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+35
-39
@@ -68,7 +68,6 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
|
const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
|
||||||
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
|
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
|
||||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
const setCurrentProject = useProjectStore((state) => state.setCurrentProject);
|
|
||||||
const currentProjectCode = useProjectStore(
|
const currentProjectCode = useProjectStore(
|
||||||
(state) => state.currentProjectCode,
|
(state) => state.currentProjectCode,
|
||||||
);
|
);
|
||||||
@@ -97,13 +96,17 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (status === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
|
if (to === "/login") {
|
||||||
|
clearSessionExpired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
markSessionExpired("unauthorized");
|
markSessionExpired("unauthorized");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (status === "authenticated") {
|
if (status === "authenticated") {
|
||||||
clearSessionExpired();
|
clearSessionExpired();
|
||||||
}
|
}
|
||||||
}, [clearSessionExpired, data?.error, markSessionExpired, status]);
|
}, [clearSessionExpired, data?.error, markSessionExpired, status, to]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== "authenticated") {
|
if (status !== "authenticated") {
|
||||||
@@ -190,6 +193,9 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
: useAuthStore.getState().accessToken,
|
: useAuthStore.getState().accessToken,
|
||||||
}),
|
}),
|
||||||
clearLocalState: () => {
|
clearLocalState: () => {
|
||||||
|
const authStore = useAuthStore.getState();
|
||||||
|
authStore.setAccessToken(null);
|
||||||
|
authStore.clearSessionExpired();
|
||||||
if (data?.user?.id) {
|
if (data?.user?.id) {
|
||||||
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
||||||
}
|
}
|
||||||
@@ -198,13 +204,6 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
navigate: (path) => window.location.assign(path),
|
navigate: (path) => window.location.assign(path),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleChooseAnotherProject = () => {
|
|
||||||
setCurrentProject(null, null);
|
|
||||||
localStorage.removeItem("MAP_WORKSPACE");
|
|
||||||
localStorage.removeItem("MAP_EXTENT");
|
|
||||||
window.location.assign("/");
|
|
||||||
};
|
|
||||||
|
|
||||||
const authProvider: AuthProvider = {
|
const authProvider: AuthProvider = {
|
||||||
login: async () => {
|
login: async () => {
|
||||||
signIn("keycloak", {
|
signIn("keycloak", {
|
||||||
@@ -406,37 +405,34 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProjectProvider>
|
<RefineKbarProvider>
|
||||||
<RefineKbarProvider>
|
<ColorModeContextProvider defaultMode={props.defaultMode}>
|
||||||
<ColorModeContextProvider defaultMode={props.defaultMode}>
|
<RefineSnackbarProvider>
|
||||||
<RefineSnackbarProvider>
|
<Refine
|
||||||
<Refine
|
routerProvider={appRouterProvider}
|
||||||
routerProvider={appRouterProvider}
|
dataProvider={dataProvider}
|
||||||
dataProvider={dataProvider}
|
notificationProvider={useAppNotificationProvider}
|
||||||
notificationProvider={useAppNotificationProvider}
|
authProvider={authProvider}
|
||||||
authProvider={authProvider}
|
accessControlProvider={accessControlProvider}
|
||||||
accessControlProvider={accessControlProvider}
|
resources={resources}
|
||||||
resources={resources}
|
options={{
|
||||||
options={{
|
syncWithLocation: true,
|
||||||
syncWithLocation: true,
|
warnWhenUnsavedChanges: true,
|
||||||
warnWhenUnsavedChanges: true,
|
}}
|
||||||
}}
|
>
|
||||||
|
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
||||||
|
<RoutePermissionGuard
|
||||||
|
authenticated={status === "authenticated"}
|
||||||
|
accessDeniedReason={accessDeniedReason}
|
||||||
>
|
>
|
||||||
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
<ProjectProvider>
|
||||||
<RoutePermissionGuard
|
|
||||||
authenticated={status === "authenticated"}
|
|
||||||
accessDeniedReason={accessDeniedReason}
|
|
||||||
userName={data?.user?.name ?? data?.user?.username}
|
|
||||||
onLogout={() => void handleLogout()}
|
|
||||||
onChooseAnotherProject={handleChooseAnotherProject}
|
|
||||||
>
|
|
||||||
{props.children}
|
{props.children}
|
||||||
</RoutePermissionGuard>
|
</ProjectProvider>
|
||||||
<RefineKbar />
|
</RoutePermissionGuard>
|
||||||
</Refine>
|
<RefineKbar />
|
||||||
</RefineSnackbarProvider>
|
</Refine>
|
||||||
</ColorModeContextProvider>
|
</RefineSnackbarProvider>
|
||||||
</RefineKbarProvider>
|
</ColorModeContextProvider>
|
||||||
</ProjectProvider>
|
</RefineKbarProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
import authOptions from "./options";
|
import authOptions from "./options";
|
||||||
|
|
||||||
|
describe("Keycloak profile mapping", () => {
|
||||||
|
const mapProfile = authOptions.providers[0].profile as (profile: {
|
||||||
|
sub: string;
|
||||||
|
preferred_username: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
picture?: string;
|
||||||
|
}) => { image?: string | null };
|
||||||
|
|
||||||
|
it("uses the Keycloak picture instead of a shared local avatar", () => {
|
||||||
|
expect(
|
||||||
|
mapProfile({
|
||||||
|
sub: "user-1",
|
||||||
|
preferred_username: "zjb",
|
||||||
|
picture: "https://keycloak.example/avatar/zjb.png",
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
image: "https://keycloak.example/avatar/zjb.png",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the image empty when Keycloak has no picture", () => {
|
||||||
|
const profile = mapProfile({
|
||||||
|
sub: "user-1",
|
||||||
|
preferred_username: "zjb",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile.image).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("NextAuth access token refresh", () => {
|
describe("NextAuth access token refresh", () => {
|
||||||
it("forces a Keycloak refresh for an agent credential request", async () => {
|
it("forces a Keycloak refresh for an agent credential request", async () => {
|
||||||
const previousEnv = {
|
const previousEnv = {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextAuthOptions } from "next-auth";
|
import { NextAuthOptions } from "next-auth";
|
||||||
import { JWT } from "next-auth/jwt";
|
import { JWT } from "next-auth/jwt";
|
||||||
import KeycloakProvider from "next-auth/providers/keycloak";
|
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||||
import Avatar from "@assets/avatar/avatar-small.jpeg";
|
|
||||||
|
|
||||||
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
|
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
|
||||||
const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000;
|
const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000;
|
||||||
@@ -86,12 +85,16 @@ const authOptions: NextAuthOptions = {
|
|||||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
|
||||||
issuer: process.env.KEYCLOAK_ISSUER ?? "",
|
issuer: process.env.KEYCLOAK_ISSUER ?? "",
|
||||||
profile(profile) {
|
profile(profile) {
|
||||||
|
const picture =
|
||||||
|
typeof (profile as { picture?: unknown }).picture === "string"
|
||||||
|
? String((profile as { picture: string }).picture).trim()
|
||||||
|
: "";
|
||||||
return {
|
return {
|
||||||
id: profile.sub,
|
id: profile.sub,
|
||||||
username: profile.preferred_username,
|
username: profile.preferred_username,
|
||||||
name: profile.name ?? profile.preferred_username,
|
name: profile.name ?? profile.preferred_username,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
image: Avatar.src,
|
image: picture || null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
import { useAccessStore } from "@/store/accessStore";
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
@@ -8,6 +8,12 @@ jest.mock("next/navigation", () => ({
|
|||||||
usePathname: () => "/network-simulation",
|
usePathname: () => "/network-simulation",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/components/header", () => ({
|
||||||
|
Header: ({ accessMode }: { accessMode: string }) => (
|
||||||
|
<div data-testid="access-header">{accessMode}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("RoutePermissionGuard", () => {
|
describe("RoutePermissionGuard", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useAccessStore.setState({
|
useAccessStore.setState({
|
||||||
@@ -53,43 +59,31 @@ describe("RoutePermissionGuard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a minimal identity header and logout action for pending accounts", () => {
|
it("keeps a minimal identity header and logout action for pending accounts", () => {
|
||||||
const onLogout = jest.fn();
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<RoutePermissionGuard
|
<RoutePermissionGuard
|
||||||
authenticated
|
authenticated
|
||||||
accessDeniedReason="account"
|
accessDeniedReason="account"
|
||||||
userName="zjb"
|
|
||||||
onLogout={onLogout}
|
|
||||||
>
|
>
|
||||||
<div>受保护内容</div>
|
<div>受保护内容</div>
|
||||||
</RoutePermissionGuard>,
|
</RoutePermissionGuard>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("TJWater 智慧水务平台")).toBeInTheDocument();
|
expect(screen.getByTestId("access-header")).toHaveTextContent("account");
|
||||||
expect(screen.getByText("当前登录账号")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("zjb")).toBeInTheDocument();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "退出登录" }));
|
|
||||||
expect(onLogout).toHaveBeenCalledTimes(1);
|
|
||||||
expect(screen.queryByText("受保护内容")).not.toBeInTheDocument();
|
expect(screen.queryByText("受保护内容")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps project recovery available when only the selected project is denied", () => {
|
it("keeps project recovery available when only the selected project is denied", () => {
|
||||||
const onChooseAnotherProject = jest.fn();
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<RoutePermissionGuard
|
<RoutePermissionGuard
|
||||||
authenticated
|
authenticated
|
||||||
accessDeniedReason="project"
|
accessDeniedReason="project"
|
||||||
onChooseAnotherProject={onChooseAnotherProject}
|
|
||||||
>
|
>
|
||||||
<div>受保护内容</div>
|
<div>受保护内容</div>
|
||||||
</RoutePermissionGuard>,
|
</RoutePermissionGuard>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("无权访问当前项目")).toBeInTheDocument();
|
expect(screen.getByText("无权访问当前项目")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "切换项目" }));
|
expect(screen.getByTestId("access-header")).toHaveTextContent("project");
|
||||||
expect(onChooseAnotherProject).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks authentication before the expired state effect runs", () => {
|
it("checks authentication before the expired state effect runs", () => {
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||||
import LogoutIcon from "@mui/icons-material/Logout";
|
|
||||||
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AppBar,
|
|
||||||
Avatar,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Stack,
|
Stack,
|
||||||
Toolbar,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { Title } from "@/components/title";
|
import { Header } from "@/components/header";
|
||||||
import { permissionForPath } from "@/lib/permissions";
|
import { permissionForPath } from "@/lib/permissions";
|
||||||
import { useAccessStore } from "@/store/accessStore";
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
@@ -26,16 +20,10 @@ export const RoutePermissionGuard = ({
|
|||||||
children,
|
children,
|
||||||
authenticated,
|
authenticated,
|
||||||
accessDeniedReason = null,
|
accessDeniedReason = null,
|
||||||
userName,
|
|
||||||
onLogout,
|
|
||||||
onChooseAnotherProject,
|
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
accessDeniedReason?: "account" | "project" | null;
|
accessDeniedReason?: "account" | "project" | null;
|
||||||
userName?: string | null;
|
|
||||||
onLogout?: () => void;
|
|
||||||
onChooseAnotherProject?: () => void;
|
|
||||||
}) => {
|
}) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const permissions = useAccessStore((state) => state.permissions);
|
const permissions = useAccessStore((state) => state.permissions);
|
||||||
@@ -59,70 +47,11 @@ export const RoutePermissionGuard = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (accessDeniedReason) {
|
if (accessDeniedReason) {
|
||||||
const displayName = userName?.trim() || "已登录账号";
|
|
||||||
const accountPending = accessDeniedReason === "account";
|
const accountPending = accessDeniedReason === "account";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
|
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
|
||||||
<AppBar
|
<Header accessMode={accessDeniedReason} />
|
||||||
color="inherit"
|
|
||||||
elevation={0}
|
|
||||||
position="static"
|
|
||||||
sx={{ borderBottom: 1, borderColor: "divider" }}
|
|
||||||
>
|
|
||||||
<Toolbar sx={{ gap: 2, justifyContent: "space-between" }}>
|
|
||||||
<Title />
|
|
||||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
|
||||||
<Avatar sx={{ width: 32, height: 32, bgcolor: "primary.main" }}>
|
|
||||||
{displayName.slice(0, 1).toUpperCase()}
|
|
||||||
</Avatar>
|
|
||||||
<Box sx={{ display: { xs: "none", sm: "block" } }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
当前登录账号
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" fontWeight={600}>
|
|
||||||
{displayName}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
{!accountPending && (
|
|
||||||
<Button
|
|
||||||
aria-label="切换项目"
|
|
||||||
color="inherit"
|
|
||||||
onClick={onChooseAnotherProject}
|
|
||||||
sx={{
|
|
||||||
gap: { xs: 0, sm: 1 },
|
|
||||||
minWidth: { xs: 40, sm: "auto" },
|
|
||||||
px: { xs: 1, sm: 2 },
|
|
||||||
"&:active": { transform: "translateY(1px)" },
|
|
||||||
}}
|
|
||||||
variant="outlined"
|
|
||||||
>
|
|
||||||
<SwapHorizIcon fontSize="small" />
|
|
||||||
<Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
|
|
||||||
切换项目
|
|
||||||
</Box>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
aria-label="退出登录"
|
|
||||||
color="inherit"
|
|
||||||
onClick={onLogout}
|
|
||||||
sx={{
|
|
||||||
gap: { xs: 0, sm: 1 },
|
|
||||||
minWidth: { xs: 40, sm: "auto" },
|
|
||||||
px: { xs: 1, sm: 2 },
|
|
||||||
"&:active": { transform: "translateY(1px)" },
|
|
||||||
}}
|
|
||||||
variant="outlined"
|
|
||||||
>
|
|
||||||
<LogoutIcon fontSize="small" />
|
|
||||||
<Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
|
|
||||||
退出登录
|
|
||||||
</Box>
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Toolbar>
|
|
||||||
</AppBar>
|
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { ColorModeContext } from "@contexts/color-mode";
|
import { ColorModeContext } from "@contexts/color-mode";
|
||||||
import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
|
import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
|
||||||
import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
|
import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
|
||||||
|
import { FiUser } from "react-icons/fi";
|
||||||
import { IoChatbubbleEllipsesOutline } from "react-icons/io5";
|
import { IoChatbubbleEllipsesOutline } from "react-icons/io5";
|
||||||
import Logout from "@mui/icons-material/Logout";
|
import Logout from "@mui/icons-material/Logout";
|
||||||
import SwapHoriz from "@mui/icons-material/SwapHoriz";
|
import SwapHoriz from "@mui/icons-material/SwapHoriz";
|
||||||
@@ -37,8 +38,13 @@ type IUser = {
|
|||||||
avatar?: string;
|
avatar?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
type HeaderProps = RefineThemedLayoutHeaderProps & {
|
||||||
|
accessMode?: "default" | "account" | "project";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Header: React.FC<HeaderProps> = ({
|
||||||
sticky = true,
|
sticky = true,
|
||||||
|
accessMode = "default",
|
||||||
}) => {
|
}) => {
|
||||||
const { mode, setMode } = useContext(ColorModeContext);
|
const { mode, setMode } = useContext(ColorModeContext);
|
||||||
const { mutate: logout } = useLogout();
|
const { mutate: logout } = useLogout();
|
||||||
@@ -51,6 +57,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { data: user } = useGetIdentity<IUser>();
|
const { data: user } = useGetIdentity<IUser>();
|
||||||
|
const restricted = accessMode !== "default";
|
||||||
|
|
||||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
setAnchorEl(event.currentTarget);
|
setAnchorEl(event.currentTarget);
|
||||||
@@ -92,7 +99,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
|||||||
justifyContent="flex-end"
|
justifyContent="flex-end"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
>
|
>
|
||||||
<HamburgerMenu />
|
{!restricted && <HamburgerMenu />}
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -110,20 +117,26 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
|||||||
|
|
||||||
{(user?.avatar || user?.name) && (
|
{(user?.avatar || user?.name) && (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
{!restricted && (
|
||||||
color="inherit"
|
<IconButton
|
||||||
onClick={() => setShowChatbox(true)}
|
color="inherit"
|
||||||
sx={{ mr: 1 }}
|
onClick={() => setShowChatbox(true)}
|
||||||
>
|
sx={{ mr: 1 }}
|
||||||
<IoChatbubbleEllipsesOutline />
|
>
|
||||||
</IconButton>
|
<IoChatbubbleEllipsesOutline />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
<ButtonBase
|
<ButtonBase
|
||||||
|
aria-label={
|
||||||
|
user?.name ? `${user.name} 账号菜单` : "账号菜单"
|
||||||
|
}
|
||||||
onClick={handleMenuOpen}
|
onClick={handleMenuOpen}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: "30px",
|
borderRadius: "30px",
|
||||||
padding: "6px 12px",
|
padding: "6px 12px",
|
||||||
marginLeft: "8px",
|
marginLeft: "8px",
|
||||||
transition: "all 0.3s ease",
|
transition:
|
||||||
|
"background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease",
|
||||||
border: "1px solid transparent",
|
border: "1px solid transparent",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
@@ -182,7 +195,9 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
|||||||
borderColor: "primary.main",
|
borderColor: "primary.main",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{!user?.avatar && <FiUser aria-hidden="true" />}
|
||||||
|
</Avatar>
|
||||||
</Stack>
|
</Stack>
|
||||||
</ButtonBase>
|
</ButtonBase>
|
||||||
<Menu
|
<Menu
|
||||||
@@ -208,13 +223,15 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem onClick={handleSwitchProjectClick}>
|
{accessMode !== "account" && (
|
||||||
<ListItemIcon>
|
<MenuItem onClick={handleSwitchProjectClick}>
|
||||||
<SwapHoriz fontSize="small" />
|
<ListItemIcon>
|
||||||
</ListItemIcon>
|
<SwapHoriz fontSize="small" />
|
||||||
<ListItemText>切换项目</ListItemText>
|
</ListItemIcon>
|
||||||
</MenuItem>
|
<ListItemText>切换项目</ListItemText>
|
||||||
<Divider />
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{accessMode !== "account" && <Divider />}
|
||||||
<MenuItem onClick={() => logout()}>
|
<MenuItem onClick={() => logout()}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<Logout fontSize="small" />
|
<Logout fontSize="small" />
|
||||||
@@ -222,17 +239,21 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
|||||||
<ListItemText>登出</ListItemText>
|
<ListItemText>登出</ListItemText>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
<ProjectSelector
|
{accessMode !== "account" && (
|
||||||
open={showProjectSelector}
|
<ProjectSelector
|
||||||
onSelect={handleProjectSelect}
|
open={showProjectSelector}
|
||||||
onClose={() => setShowProjectSelector(false)}
|
onSelect={handleProjectSelect}
|
||||||
/>
|
onClose={() => setShowProjectSelector(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<GlobalChatbox
|
{!restricted && (
|
||||||
open={showChatbox}
|
<GlobalChatbox
|
||||||
onClose={() => setShowChatbox(false)}
|
open={showChatbox}
|
||||||
/>
|
onClose={() => setShowChatbox(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|||||||
@@ -132,8 +132,11 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
|
|||||||
paper: {
|
paper: {
|
||||||
sx: {
|
sx: {
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
padding: 2,
|
boxSizing: "border-box",
|
||||||
minWidth: 400,
|
margin: { xs: 2, sm: 4 },
|
||||||
|
padding: { xs: 1.5, sm: 2 },
|
||||||
|
width: { xs: "calc(100vw - 32px)", sm: 400 },
|
||||||
|
maxWidth: "calc(100vw - 32px)",
|
||||||
background: "rgba(255, 255, 255, 0.95)",
|
background: "rgba(255, 255, 255, 0.95)",
|
||||||
backdropFilter: "blur(10px)",
|
backdropFilter: "blur(10px)",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
|||||||
Reference in New Issue
Block a user