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

This commit is contained in:
2026-11-04 13:51:05 +08:00
parent 6ff5322353
commit 80f05e481b
5 changed files with 231 additions and 38 deletions
+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>