fix(auth): keep logout and project recovery on access denial
This commit is contained in:
@@ -31,6 +31,12 @@ test("已认证但未授权的账号会看到明确说明", async ({ page }) =>
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByText("身份认证已成功,但当前账号尚未由系统管理员启用"),
|
page.getByText("身份认证已成功,但当前账号尚未由系统管理员启用"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("TJWater 智慧水务平台")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await expect(page.getByText("账号尚未获得访问权限")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("系统管理员可以手动同步 Keycloak 用户目录", async ({ page }) => {
|
test("系统管理员可以手动同步 Keycloak 用户目录", async ({ page }) => {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ describe("RefineContext access authentication", () => {
|
|||||||
ok: false,
|
ok: false,
|
||||||
status: 403,
|
status: 403,
|
||||||
headers: new Headers(),
|
headers: new Headers(),
|
||||||
|
json: async () => ({ detail: "User authorization pending" }),
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -131,6 +132,10 @@ describe("RefineContext access authentication", () => {
|
|||||||
expect(
|
expect(
|
||||||
await screen.findByText("账号尚未获得访问权限"),
|
await screen.findByText("账号尚未获得访问权限"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
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,
|
||||||
@@ -138,6 +143,27 @@ describe("RefineContext access authentication", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps project recovery available when the selected project is denied", async () => {
|
||||||
|
global.fetch = jest.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
headers: new Headers(),
|
||||||
|
json: async () => ({ detail: "No access to project" }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<App>
|
||||||
|
<div>应用内容</div>
|
||||||
|
</App>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("无权访问当前项目")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "切换项目" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("prioritizes an unauthenticated session over route permissions", async () => {
|
it("prioritizes an unauthenticated session over route permissions", async () => {
|
||||||
mockSessionState = { data: null, status: "unauthenticated" };
|
mockSessionState = { data: null, status: "unauthenticated" };
|
||||||
|
|
||||||
|
|||||||
+49
-24
@@ -68,6 +68,7 @@ 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,
|
||||||
);
|
);
|
||||||
@@ -75,7 +76,9 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
const setAccessContext = useAccessStore((state) => state.setContext);
|
const setAccessContext = useAccessStore((state) => state.setContext);
|
||||||
const setAccessLoading = useAccessStore((state) => state.setLoading);
|
const setAccessLoading = useAccessStore((state) => state.setLoading);
|
||||||
const resetAccess = useAccessStore((state) => state.reset);
|
const resetAccess = useAccessStore((state) => state.reset);
|
||||||
const [accessDenied, setAccessDenied] = useState(false);
|
const [accessDeniedReason, setAccessDeniedReason] = useState<
|
||||||
|
"account" | "project" | null
|
||||||
|
>(null);
|
||||||
const can = (permission: string) => permissions.includes(permission);
|
const can = (permission: string) => permissions.includes(permission);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -104,13 +107,13 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== "authenticated") {
|
if (status !== "authenticated") {
|
||||||
setAccessDenied(false);
|
setAccessDeniedReason(null);
|
||||||
resetAccess();
|
resetAccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setAccessDenied(false);
|
setAccessDeniedReason(null);
|
||||||
setAccessLoading(true);
|
setAccessLoading(true);
|
||||||
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
|
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
|
||||||
projectHeaderMode: currentProjectId ? "include" : "omit",
|
projectHeaderMode: currentProjectId ? "include" : "omit",
|
||||||
@@ -118,7 +121,17 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
setAccessDenied(true);
|
let detail = "";
|
||||||
|
try {
|
||||||
|
const payload = await response.json();
|
||||||
|
detail = typeof payload?.detail === "string" ? payload.detail : "";
|
||||||
|
} catch {
|
||||||
|
// Treat an unparseable 403 as a project access failure so the user
|
||||||
|
// keeps a recovery path instead of being trapped in the account shell.
|
||||||
|
}
|
||||||
|
setAccessDeniedReason(
|
||||||
|
detail === "User authorization pending" ? "account" : "project",
|
||||||
|
);
|
||||||
resetAccess();
|
resetAccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -126,7 +139,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
resetAccess();
|
resetAccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAccessDenied(false);
|
setAccessDeniedReason(null);
|
||||||
setAccessContext(await response.json());
|
setAccessContext(await response.json());
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -166,6 +179,32 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
return <span>loading...</span>;
|
return <span>loading...</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLogout = () =>
|
||||||
|
completeLogout({
|
||||||
|
reportAudit: () =>
|
||||||
|
reportLogoutAudit({
|
||||||
|
endpoint: `${config.BACKEND_URL}/api/v1/audit-events`,
|
||||||
|
accessToken:
|
||||||
|
typeof data?.accessToken === "string"
|
||||||
|
? data.accessToken
|
||||||
|
: useAuthStore.getState().accessToken,
|
||||||
|
}),
|
||||||
|
clearLocalState: () => {
|
||||||
|
if (data?.user?.id) {
|
||||||
|
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
||||||
|
}
|
||||||
|
clearSessionRecoveryDrafts();
|
||||||
|
},
|
||||||
|
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", {
|
||||||
@@ -174,24 +213,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
});
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
logout: () =>
|
logout: handleLogout,
|
||||||
completeLogout({
|
|
||||||
reportAudit: () =>
|
|
||||||
reportLogoutAudit({
|
|
||||||
endpoint: `${config.BACKEND_URL}/api/v1/audit-events`,
|
|
||||||
accessToken:
|
|
||||||
typeof data?.accessToken === "string"
|
|
||||||
? data.accessToken
|
|
||||||
: useAuthStore.getState().accessToken,
|
|
||||||
}),
|
|
||||||
clearLocalState: () => {
|
|
||||||
if (data?.user?.id) {
|
|
||||||
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
|
||||||
}
|
|
||||||
clearSessionRecoveryDrafts();
|
|
||||||
},
|
|
||||||
navigate: (path) => window.location.assign(path),
|
|
||||||
}),
|
|
||||||
onError: async (error) => {
|
onError: async (error) => {
|
||||||
return { error };
|
return { error };
|
||||||
},
|
},
|
||||||
@@ -403,7 +425,10 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
||||||
<RoutePermissionGuard
|
<RoutePermissionGuard
|
||||||
authenticated={status === "authenticated"}
|
authenticated={status === "authenticated"}
|
||||||
accessDenied={accessDenied}
|
accessDeniedReason={accessDeniedReason}
|
||||||
|
userName={data?.user?.name ?? data?.user?.username}
|
||||||
|
onLogout={() => void handleLogout()}
|
||||||
|
onChooseAnotherProject={handleChooseAnotherProject}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</RoutePermissionGuard>
|
</RoutePermissionGuard>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, 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";
|
||||||
@@ -52,6 +52,46 @@ describe("RoutePermissionGuard", () => {
|
|||||||
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
|
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a minimal identity header and logout action for pending accounts", () => {
|
||||||
|
const onLogout = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RoutePermissionGuard
|
||||||
|
authenticated
|
||||||
|
accessDeniedReason="account"
|
||||||
|
userName="zjb"
|
||||||
|
onLogout={onLogout}
|
||||||
|
>
|
||||||
|
<div>受保护内容</div>
|
||||||
|
</RoutePermissionGuard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("TJWater 智慧水务平台")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("当前登录账号")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("zjb")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "退出登录" }));
|
||||||
|
expect(onLogout).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.queryByText("受保护内容")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps project recovery available when only the selected project is denied", () => {
|
||||||
|
const onChooseAnotherProject = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RoutePermissionGuard
|
||||||
|
authenticated
|
||||||
|
accessDeniedReason="project"
|
||||||
|
onChooseAnotherProject={onChooseAnotherProject}
|
||||||
|
>
|
||||||
|
<div>受保护内容</div>
|
||||||
|
</RoutePermissionGuard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("无权访问当前项目")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "切换项目" }));
|
||||||
|
expect(onChooseAnotherProject).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("checks authentication before the expired state effect runs", () => {
|
it("checks authentication before the expired state effect runs", () => {
|
||||||
render(
|
render(
|
||||||
<RoutePermissionGuard authenticated={false}>
|
<RoutePermissionGuard authenticated={false}>
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||||
import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material";
|
import LogoutIcon from "@mui/icons-material/Logout";
|
||||||
|
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AppBar,
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Stack,
|
||||||
|
Toolbar,
|
||||||
|
Typography,
|
||||||
|
} 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 { 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";
|
||||||
@@ -12,11 +25,17 @@ import { useAuthStore } from "@/store/authStore";
|
|||||||
export const RoutePermissionGuard = ({
|
export const RoutePermissionGuard = ({
|
||||||
children,
|
children,
|
||||||
authenticated,
|
authenticated,
|
||||||
accessDenied = false,
|
accessDeniedReason = null,
|
||||||
|
userName,
|
||||||
|
onLogout,
|
||||||
|
onChooseAnotherProject,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
accessDenied?: boolean;
|
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);
|
||||||
@@ -39,17 +58,94 @@ export const RoutePermissionGuard = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accessDenied) {
|
if (accessDeniedReason) {
|
||||||
|
const displayName = userName?.trim() || "已登录账号";
|
||||||
|
const accountPending = accessDeniedReason === "account";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: { xs: 2, md: 4 } }}>
|
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
|
||||||
<Alert severity="warning" icon={<LockOutlinedIcon />}>
|
<AppBar
|
||||||
<Stack spacing={0.5}>
|
color="inherit"
|
||||||
<Typography fontWeight={700}>账号尚未获得访问权限</Typography>
|
elevation={0}
|
||||||
<Typography variant="body2">
|
position="static"
|
||||||
身份认证已成功,但当前账号尚未由系统管理员启用,或无权访问所选项目。请联系系统管理员完成授权后重试。
|
sx={{ borderBottom: 1, borderColor: "divider" }}
|
||||||
</Typography>
|
>
|
||||||
</Stack>
|
<Toolbar sx={{ gap: 2, justifyContent: "space-between" }}>
|
||||||
</Alert>
|
<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
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 840,
|
||||||
|
mx: "auto",
|
||||||
|
px: { xs: 2, sm: 3 },
|
||||||
|
py: { xs: 4, md: 8 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Alert severity="warning" icon={<LockOutlinedIcon />}>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography fontWeight={700}>
|
||||||
|
{accountPending ? "账号尚未获得访问权限" : "无权访问当前项目"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{accountPending
|
||||||
|
? "身份认证已成功,但当前账号尚未由系统管理员启用。请联系系统管理员完成授权后重试。"
|
||||||
|
: "当前账号无法访问所选项目。请选择其他已授权项目,或联系系统管理员调整项目权限。"}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user