Compare commits

...
Author SHA1 Message Date
jiang a191d81c29 fix(ui): remove avatar hover scaling
Generic Container CI/CD / test-build-publish (push) Successful in 59s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 59s
2026-11-04 14:39:16 +08:00
jiang d4c8fc9db8 fix(auth): preserve intentional logout across browser return
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
Keep a bounded per-tab logout intent and broadcast it to already open tabs so back navigation after a normal logout is not mistaken for renewal failure. Preserve real refresh and session expiry signals, and keep IdP navigation reachable when local cleanup fails.
2026-11-04 14:34:18 +08:00
jiang 95ec090f2e fix(ui): keep account avatar static 2026-11-04 14:34:01 +08:00
9 changed files with 307 additions and 8 deletions
+65
View File
@@ -3,12 +3,17 @@ import type { ReactNode } from "react";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
import {
clearIntentionalLogout,
markIntentionalLogout,
} from "@/lib/logoutIntent";
import { App } from "./RefineContext";
let mockSessionState: {
data: {
accessToken: string;
user: { id: string; name: string };
error?: "RefreshAccessTokenError" | "SessionExpired";
} | null;
status: "authenticated" | "unauthenticated";
} = {
@@ -70,6 +75,7 @@ describe("RefineContext access authentication", () => {
const originalRequest = global.Request;
beforeEach(() => {
clearIntentionalLogout();
mockPathname = "/network-simulation";
mockProjectProviderBlocks = false;
mockSessionState = {
@@ -210,4 +216,63 @@ describe("RefineContext access authentication", () => {
});
});
});
it("does not treat returning to a protected route after logout as session expiry", async () => {
markIntentionalLogout();
mockSessionState = { data: null, status: "unauthenticated" };
useAuthStore.setState({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: "refresh_failed",
});
render(
<App>
<div>应用内容</div>
</App>,
);
await waitFor(() => {
expect(useAuthStore.getState()).toMatchObject({
sessionExpired: false,
sessionExpiryReason: null,
});
});
});
it("does not hide a real refresh failure after an intentional logout", async () => {
markIntentionalLogout();
mockSessionState = {
data: {
accessToken: "expired-access-token",
user: { id: "user-1", name: "Test User" },
error: "RefreshAccessTokenError",
},
status: "authenticated",
};
const { rerender } = render(
<App>
<div>应用内容</div>
</App>,
);
await waitFor(() => {
expect(useAuthStore.getState()).toMatchObject({
sessionExpired: true,
sessionExpiryReason: "refresh_failed",
});
});
mockSessionState = { data: null, status: "unauthenticated" };
rerender(
<App>
<div>应用内容</div>
</App>,
);
await waitFor(() => {
expect(useAuthStore.getState().sessionExpired).toBe(true);
});
});
});
+8 -1
View File
@@ -24,6 +24,10 @@ import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore";
import { apiFetch } from "@/lib/apiFetch";
import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow";
import {
clearIntentionalLogout,
isIntentionalLogout,
} from "@/lib/logoutIntent";
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
import { config } from "@config/config";
@@ -88,15 +92,17 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
useEffect(() => {
if (data?.error === "SessionExpired") {
clearIntentionalLogout();
markSessionExpired("session_max_age");
return;
}
if (data?.error === "RefreshAccessTokenError") {
clearIntentionalLogout();
markSessionExpired("refresh_failed");
return;
}
if (status === "unauthenticated") {
if (to === "/login") {
if (to === "/login" || isIntentionalLogout()) {
clearSessionExpired();
return;
}
@@ -104,6 +110,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
return;
}
if (status === "authenticated") {
clearIntentionalLogout();
clearSessionExpired();
}
}, [clearSessionExpired, data?.error, markSessionExpired, status, to]);
+1 -1
View File
@@ -191,7 +191,7 @@ export const Header: React.FC<HeaderProps> = ({
}`,
transition: "transform 0.3s ease",
".MuiButtonBase-root:hover &": {
transform: "rotate(5deg) scale(1.05)",
transform: "rotate(5deg)",
borderColor: "primary.main",
},
}}
+25
View File
@@ -1,4 +1,8 @@
import { completeLogout, reportLogoutAudit } from "./logoutFlow";
import {
clearIntentionalLogout,
isIntentionalLogout,
} from "./logoutIntent";
describe("reportLogoutAudit", () => {
it("starts an authenticated keepalive request", async () => {
@@ -28,6 +32,10 @@ describe("reportLogoutAudit", () => {
});
describe("completeLogout", () => {
beforeEach(() => {
clearIntentionalLogout();
});
it("navigates immediately without waiting for audit reporting", async () => {
const reportAudit = jest.fn(
() => new Promise<unknown>(() => undefined),
@@ -44,6 +52,7 @@ describe("completeLogout", () => {
await Promise.resolve();
expect(reportAudit).toHaveBeenCalledTimes(1);
expect(isIntentionalLogout()).toBe(true);
expect(clearLocalState).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
await expect(result).resolves.toEqual({ success: true });
@@ -79,4 +88,20 @@ describe("completeLogout", () => {
expect(clearLocalState).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
});
it("still navigates when local cleanup throws", async () => {
const navigate = jest.fn();
await expect(
completeLogout({
reportAudit: jest.fn().mockResolvedValue(undefined),
clearLocalState: () => {
throw new DOMException("Storage unavailable", "SecurityError");
},
navigate,
}),
).resolves.toEqual({ success: true });
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
});
});
+8 -1
View File
@@ -1,3 +1,5 @@
import { markIntentionalLogout } from "@/lib/logoutIntent";
const KEYCLOAK_LOGOUT_PATH = "/api/auth/keycloak-logout";
type CompleteLogoutOptions = {
@@ -41,7 +43,12 @@ export const completeLogout = async ({
// Synchronous reporting errors must not block logout either.
}
clearLocalState();
markIntentionalLogout();
try {
clearLocalState();
} catch {
// Browser storage cleanup failures must not prevent the IdP logout.
}
navigate(KEYCLOAK_LOGOUT_PATH);
return { success: true as const };
};
+57
View File
@@ -0,0 +1,57 @@
import {
clearIntentionalLogout,
isIntentionalLogout,
markIntentionalLogout,
} from "./logoutIntent";
describe("intentional logout marker", () => {
afterEach(() => {
jest.restoreAllMocks();
clearIntentionalLogout();
});
it("stores a timestamp only for the current tab", () => {
jest.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
markIntentionalLogout();
expect(sessionStorage.getItem("tjwater:intentional-logout")).toBe(
"1700000000000",
);
expect(localStorage.getItem("tjwater:intentional-logout")).toBeNull();
expect(isIntentionalLogout()).toBe(true);
});
it("accepts an intentional logout event from another open tab", () => {
window.dispatchEvent(
new StorageEvent("storage", {
key: "tjwater:intentional-logout-event",
newValue: JSON.stringify({
type: "mark",
markedAt: Date.now(),
}),
}),
);
expect(isIntentionalLogout()).toBe(true);
});
it("does not broadcast a clear event when the current tab has no marker", () => {
clearIntentionalLogout();
const setItem = jest.spyOn(Storage.prototype, "setItem");
expect(isIntentionalLogout()).toBe(false);
expect(setItem).not.toHaveBeenCalled();
});
it("expires a stale marker", () => {
const now = 1_700_000_000_000;
const nowSpy = jest.spyOn(Date, "now").mockReturnValue(now);
markIntentionalLogout();
nowSpy.mockReturnValue(now + 30 * 60 * 1000 + 1);
expect(isIntentionalLogout()).toBe(false);
expect(sessionStorage.getItem("tjwater:intentional-logout")).toBeNull();
});
});
+81
View File
@@ -0,0 +1,81 @@
const INTENTIONAL_LOGOUT_STORAGE_KEY = "tjwater:intentional-logout";
const INTENTIONAL_LOGOUT_EVENT_KEY = "tjwater:intentional-logout-event";
const INTENTIONAL_LOGOUT_TTL_MS = 30 * 60 * 1000;
let memoryMarkedAt: number | null = null;
const withStorage = (
storageName: "localStorage" | "sessionStorage",
callback: (storage: Storage) => void,
) => {
if (typeof window === "undefined") return;
try {
callback(window[storageName]);
} catch {
// Logout must still proceed when browser storage is unavailable.
}
};
const setLocalMarker = (markedAt: number | null) => {
memoryMarkedAt = markedAt;
withStorage("sessionStorage", (storage) => {
if (markedAt === null) {
storage.removeItem(INTENTIONAL_LOGOUT_STORAGE_KEY);
} else {
storage.setItem(INTENTIONAL_LOGOUT_STORAGE_KEY, String(markedAt));
}
});
};
const broadcastIntent = (
payload: { type: "mark"; markedAt: number } | { type: "clear" },
) => {
withStorage("localStorage", (storage) => {
storage.setItem(INTENTIONAL_LOGOUT_EVENT_KEY, JSON.stringify(payload));
storage.removeItem(INTENTIONAL_LOGOUT_EVENT_KEY);
});
};
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key !== INTENTIONAL_LOGOUT_EVENT_KEY || !event.newValue) return;
try {
const payload = JSON.parse(event.newValue) as {
type?: unknown;
markedAt?: unknown;
};
if (payload.type === "mark" && typeof payload.markedAt === "number") {
setLocalMarker(payload.markedAt);
} else if (payload.type === "clear") {
setLocalMarker(null);
}
} catch {
// Ignore malformed cross-tab events.
}
});
}
export const markIntentionalLogout = () => {
const markedAt = Date.now();
setLocalMarker(markedAt);
broadcastIntent({ type: "mark", markedAt });
};
export const clearIntentionalLogout = () => {
setLocalMarker(null);
broadcastIntent({ type: "clear" });
};
export const isIntentionalLogout = () => {
let markedAt = memoryMarkedAt;
withStorage("sessionStorage", (storage) => {
const stored = Number(storage.getItem(INTENTIONAL_LOGOUT_STORAGE_KEY));
if (Number.isFinite(stored) && stored > 0) markedAt = stored;
});
if (markedAt === null) return false;
const age = Date.now() - markedAt;
if (age < 0 || age > INTENTIONAL_LOGOUT_TTL_MS) {
setLocalMarker(null);
return false;
}
return true;
};
+45
View File
@@ -0,0 +1,45 @@
import {
clearIntentionalLogout,
markIntentionalLogout,
} from "@/lib/logoutIntent";
import { useAuthStore } from "./authStore";
describe("authStore intentional logout boundary", () => {
beforeEach(() => {
clearIntentionalLogout();
useAuthStore.setState({
accessToken: "access-token",
sessionExpired: false,
sessionExpiryReason: null,
});
});
afterEach(() => {
clearIntentionalLogout();
});
it("ignores late expiry signals after an intentional logout", () => {
markIntentionalLogout();
useAuthStore.getState().markSessionExpired("unauthorized");
expect(useAuthStore.getState()).toMatchObject({
accessToken: null,
sessionExpired: false,
sessionExpiryReason: null,
});
});
it("keeps real refresh failures visible after an intentional logout", () => {
markIntentionalLogout();
useAuthStore.getState().markSessionExpired("refresh_failed");
expect(useAuthStore.getState()).toMatchObject({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: "refresh_failed",
});
});
});
+17 -5
View File
@@ -1,5 +1,7 @@
import { create } from "zustand";
import { isIntentionalLogout } from "@/lib/logoutIntent";
interface AuthState {
accessToken: string | null;
sessionExpired: boolean;
@@ -16,11 +18,21 @@ export const useAuthStore = create<AuthState>((set) => ({
sessionExpired: false,
sessionExpiryReason: null,
setAccessToken: (token) => set({ accessToken: token }),
markSessionExpired: (reason) => set({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: reason,
}),
markSessionExpired: (reason) => {
if (reason === "unauthorized" && isIntentionalLogout()) {
set({
accessToken: null,
sessionExpired: false,
sessionExpiryReason: null,
});
return;
}
set({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: reason,
});
},
clearSessionExpired: () => set({
sessionExpired: false,
sessionExpiryReason: null,