fix(auth): keep logout and project recovery on access denial
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user