Compare commits

...
Author SHA1 Message Date
jiang 80f05e481b fix(auth): keep logout and project recovery on access denial
Generic Container CI/CD / test-build-publish (push) Successful in 1m3s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m3s
2026-11-04 13:51:05 +08:00
5 changed files with 231 additions and 38 deletions
+6
View File
@@ -31,6 +31,12 @@ test("已认证但未授权的账号会看到明确说明", async ({ page }) =>
await expect(
page.getByText("身份认证已成功,但当前账号尚未由系统管理员启用"),
).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 }) => {
+26
View File
@@ -120,6 +120,7 @@ describe("RefineContext access authentication", () => {
ok: false,
status: 403,
headers: new Headers(),
json: async () => ({ detail: "User authorization pending" }),
} as Response);
render(
@@ -131,6 +132,10 @@ describe("RefineContext access authentication", () => {
expect(
await screen.findByText("账号尚未获得访问权限"),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "退出登录" }),
).toBeInTheDocument();
expect(screen.getByText("Test User")).toBeInTheDocument();
expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
expect(useAuthStore.getState()).toMatchObject({
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 () => {
mockSessionState = { data: null, status: "unauthenticated" };
+49 -24
View File
@@ -68,6 +68,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
const currentProjectId = useProjectStore((state) => state.currentProjectId);
const setCurrentProject = useProjectStore((state) => state.setCurrentProject);
const currentProjectCode = useProjectStore(
(state) => state.currentProjectCode,
);
@@ -75,7 +76,9 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
const setAccessContext = useAccessStore((state) => state.setContext);
const setAccessLoading = useAccessStore((state) => state.setLoading);
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);
useEffect(() => {
@@ -104,13 +107,13 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
useEffect(() => {
if (status !== "authenticated") {
setAccessDenied(false);
setAccessDeniedReason(null);
resetAccess();
return;
}
let cancelled = false;
setAccessDenied(false);
setAccessDeniedReason(null);
setAccessLoading(true);
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
projectHeaderMode: currentProjectId ? "include" : "omit",
@@ -118,7 +121,17 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
.then(async (response) => {
if (cancelled) return;
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();
return;
}
@@ -126,7 +139,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
resetAccess();
return;
}
setAccessDenied(false);
setAccessDeniedReason(null);
setAccessContext(await response.json());
})
.catch(() => {
@@ -166,6 +179,32 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
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 = {
login: async () => {
signIn("keycloak", {
@@ -174,24 +213,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
});
return { success: true };
},
logout: () =>
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),
}),
logout: handleLogout,
onError: async (error) => {
return { error };
},
@@ -403,7 +425,10 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
<RoutePermissionGuard
authenticated={status === "authenticated"}
accessDenied={accessDenied}
accessDeniedReason={accessDeniedReason}
userName={data?.user?.name ?? data?.user?.username}
onLogout={() => void handleLogout()}
onChooseAnotherProject={handleChooseAnotherProject}
>
{props.children}
</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 { useAuthStore } from "@/store/authStore";
@@ -52,6 +52,46 @@ describe("RoutePermissionGuard", () => {
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", () => {
render(
<RoutePermissionGuard authenticated={false}>
+109 -13
View File
@@ -1,10 +1,23 @@
"use client";
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 type { ReactNode } from "react";
import { Title } from "@/components/title";
import { permissionForPath } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
@@ -12,11 +25,17 @@ import { useAuthStore } from "@/store/authStore";
export const RoutePermissionGuard = ({
children,
authenticated,
accessDenied = false,
accessDeniedReason = null,
userName,
onLogout,
onChooseAnotherProject,
}: {
children: ReactNode;
authenticated: boolean;
accessDenied?: boolean;
accessDeniedReason?: "account" | "project" | null;
userName?: string | null;
onLogout?: () => void;
onChooseAnotherProject?: () => void;
}) => {
const pathname = usePathname();
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 (
<Box sx={{ p: { xs: 2, md: 4 } }}>
<Alert severity="warning" icon={<LockOutlinedIcon />}>
<Stack spacing={0.5}>
<Typography fontWeight={700}>账号尚未获得访问权限</Typography>
<Typography variant="body2">
身份认证已成功,但当前账号尚未由系统管理员启用,或无权访问所选项目。请联系系统管理员完成授权后重试。
</Typography>
</Stack>
</Alert>
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<AppBar
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
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>
);
}