Compare commits

...
Author SHA1 Message Date
jiang eff59c5a7e feat(profile): publish stable default avatar asset
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
2026-11-04 15:20:50 +08:00
jiang d98fa359ae fix(auth): redirect expired routes into login flow
The global route guard previously rendered redirect copy without navigating and also blocked the login route itself. Allow the login page through, redirect protected unauthenticated routes, and avoid a /login callback loop.
2026-11-04 15:20:46 +08:00
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
12 changed files with 381 additions and 14 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+101 -1
View File
@@ -1,14 +1,20 @@
import { render, screen, waitFor } from "@testing-library/react";
import { signIn } from "next-auth/react";
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";
} = {
@@ -20,6 +26,8 @@ let mockSessionState: {
};
let mockPathname = "/network-simulation";
let mockProjectProviderBlocks = false;
let capturedLogin: (() => Promise<unknown>) | null = null;
const replaceMock = jest.fn();
jest.mock("next-auth/react", () => ({
SessionProvider: ({ children }: { children: ReactNode }) => children,
@@ -29,10 +37,20 @@ jest.mock("next-auth/react", () => ({
jest.mock("next/navigation", () => ({
usePathname: () => mockPathname,
useRouter: () => ({ replace: replaceMock }),
}));
jest.mock("@refinedev/core", () => ({
Refine: ({ children }: { children: ReactNode }) => children,
Refine: ({
children,
authProvider,
}: {
children: ReactNode;
authProvider: { login: (params: Record<string, never>) => Promise<unknown> };
}) => {
capturedLogin = () => authProvider.login({});
return children;
},
}));
jest.mock("@refinedev/kbar", () => ({
@@ -70,8 +88,12 @@ describe("RefineContext access authentication", () => {
const originalRequest = global.Request;
beforeEach(() => {
clearIntentionalLogout();
mockPathname = "/network-simulation";
mockProjectProviderBlocks = false;
capturedLogin = null;
replaceMock.mockReset();
jest.mocked(signIn).mockClear();
mockSessionState = {
data: {
accessToken: "expired-access-token",
@@ -210,4 +232,82 @@ describe("RefineContext access authentication", () => {
});
});
});
it("starts login with a safe post-authentication callback from the login route", async () => {
mockPathname = "/login";
mockSessionState = { data: null, status: "unauthenticated" };
render(
<App>
<div>登录页面</div>
</App>,
);
expect(capturedLogin).not.toBeNull();
await capturedLogin!();
expect(signIn).toHaveBeenCalledWith("keycloak", {
callbackUrl: "/",
redirect: true,
});
});
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);
});
});
});
+9 -2
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]);
@@ -207,7 +214,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
const authProvider: AuthProvider = {
login: async () => {
signIn("keycloak", {
callbackUrl: to ? to.toString() : "/",
callbackUrl: to && to !== "/login" ? to.toString() : "/",
redirect: true,
});
return { success: true };
@@ -4,8 +4,12 @@ import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
import { RoutePermissionGuard } from "./RoutePermissionGuard";
const replaceMock = jest.fn();
let mockPathname = "/network-simulation";
jest.mock("next/navigation", () => ({
usePathname: () => "/network-simulation",
usePathname: () => mockPathname,
useRouter: () => ({ replace: replaceMock }),
}));
jest.mock("@/components/header", () => ({
@@ -16,6 +20,8 @@ jest.mock("@/components/header", () => ({
describe("RoutePermissionGuard", () => {
beforeEach(() => {
replaceMock.mockReset();
mockPathname = "/network-simulation";
useAccessStore.setState({
context: null,
permissions: [],
@@ -86,7 +92,7 @@ describe("RoutePermissionGuard", () => {
expect(screen.getByTestId("access-header")).toHaveTextContent("project");
});
it("checks authentication before the expired state effect runs", () => {
it("redirects an unauthenticated protected route to login", () => {
render(
<RoutePermissionGuard authenticated={false}>
<div>受保护内容</div>
@@ -95,5 +101,20 @@ describe("RoutePermissionGuard", () => {
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
expect(replaceMock).toHaveBeenCalledWith("/login");
});
it("allows the login route to render while unauthenticated", () => {
mockPathname = "/login";
render(
<RoutePermissionGuard authenticated={false}>
<div>登录页面</div>
</RoutePermissionGuard>,
);
expect(screen.getByText("登录页面")).toBeInTheDocument();
expect(screen.queryByText("登录状态已失效")).not.toBeInTheDocument();
expect(replaceMock).not.toHaveBeenCalled();
});
});
+14 -2
View File
@@ -8,8 +8,8 @@ import {
Stack,
Typography,
} from "@mui/material";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
import { type ReactNode, useEffect } from "react";
import { Header } from "@/components/header";
import { permissionForPath } from "@/lib/permissions";
@@ -26,10 +26,22 @@ export const RoutePermissionGuard = ({
accessDeniedReason?: "account" | "project" | null;
}) => {
const pathname = usePathname();
const router = useRouter();
const permissions = useAccessStore((state) => state.permissions);
const loading = useAccessStore((state) => state.loading);
const sessionExpired = useAuthStore((state) => state.sessionExpired);
const requiredPermission = permissionForPath(pathname);
const isLoginRoute = pathname === "/login";
useEffect(() => {
if (!authenticated && !isLoginRoute) {
router.replace("/login");
}
}, [authenticated, isLoginRoute, router]);
if (isLoginRoute) {
return children;
}
if (!authenticated || sessionExpired) {
return (
+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");
});
});
+7
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.
}
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",
});
});
});
+14 -2
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({
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,