fix(auth): align session lifecycle with Keycloak

This commit is contained in:
2026-08-05 17:10:59 +08:00
parent 5d9c40e454
commit e496fbe4b7
20 changed files with 339 additions and 24 deletions
+1
View File
@@ -1,6 +1,7 @@
KEYCLOAK_CLIENT_ID="tjwater" KEYCLOAK_CLIENT_ID="tjwater"
KEYCLOAK_CLIENT_SECRET="replace-with-keycloak-client-secret" KEYCLOAK_CLIENT_SECRET="replace-with-keycloak-client-secret"
KEYCLOAK_ISSUER="https://keycloak.example.com/realms/tjwater" KEYCLOAK_ISSUER="https://keycloak.example.com/realms/tjwater"
KEYCLOAK_POST_LOGOUT_REDIRECT_URI="https://frontend.example.com/login"
NEXTAUTH_SECRET="replace-with-nextauth-secret" NEXTAUTH_SECRET="replace-with-nextauth-secret"
NEXTAUTH_URL="https://frontend.example.com/" NEXTAUTH_URL="https://frontend.example.com/"
+1
View File
@@ -18,6 +18,7 @@ services:
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID} KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID}
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET} KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER} KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER}
KEYCLOAK_POST_LOGOUT_REDIRECT_URI: ${KEYCLOAK_POST_LOGOUT_REDIRECT_URI}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_URL: ${NEXTAUTH_URL} NEXTAUTH_URL: ${NEXTAUTH_URL}
NODE_ENV: production NODE_ENV: production
+20 -3
View File
@@ -17,10 +17,12 @@ import { ColorModeContextProvider } from "@contexts/color-mode";
import { dataProvider } from "@providers/data-provider"; import { dataProvider } from "@providers/data-provider";
import { ProjectProvider } from "@/contexts/ProjectContext"; import { ProjectProvider } from "@/contexts/ProjectContext";
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard"; import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
import { SessionExpiryDialog } from "@/components/auth/SessionExpiryDialog";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import { useAccessStore } from "@/store/accessStore"; import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
import { permissionCodes, resourcePermissions } from "@/lib/permissions"; import { permissionCodes, resourcePermissions } from "@/lib/permissions";
import { config } from "@config/config"; import { config } from "@config/config";
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider"; import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
@@ -57,6 +59,8 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
const { data, status } = useSession(); const { data, status } = useSession();
const to = usePathname(); const to = usePathname();
const setAccessToken = useAuthStore((state) => state.setAccessToken); const setAccessToken = useAuthStore((state) => state.setAccessToken);
const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
const currentProjectId = useProjectStore((state) => state.currentProjectId); const currentProjectId = useProjectStore((state) => state.currentProjectId);
const permissions = useAccessStore((state) => state.permissions); const permissions = useAccessStore((state) => state.permissions);
const setAccessContext = useAccessStore((state) => state.setContext); const setAccessContext = useAccessStore((state) => state.setContext);
@@ -70,6 +74,20 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
); );
}, [data?.accessToken, setAccessToken]); }, [data?.accessToken, setAccessToken]);
useEffect(() => {
if (data?.error === "SessionExpired") {
markSessionExpired("session_max_age");
return;
}
if (data?.error === "RefreshAccessTokenError") {
markSessionExpired("refresh_failed");
return;
}
if (status === "authenticated") {
clearSessionExpired();
}
}, [clearSessionExpired, data?.error, markSessionExpired, status]);
useEffect(() => { useEffect(() => {
if (status !== "authenticated") { if (status !== "authenticated") {
resetAccess(); resetAccess();
@@ -150,13 +168,11 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
if (data?.user?.id) { if (data?.user?.id) {
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`); sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
} }
clearSessionRecoveryDrafts();
window.location.assign("/api/auth/keycloak-logout"); window.location.assign("/api/auth/keycloak-logout");
return { success: true }; return { success: true };
}, },
onError: async (error) => { onError: async (error) => {
if (error.response?.status === 401) {
return { logout: true };
}
return { error }; return { error };
}, },
check: async () => check: async () =>
@@ -351,6 +367,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
warnWhenUnsavedChanges: true, warnWhenUnsavedChanges: true,
}} }}
> >
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
<RoutePermissionGuard>{props.children}</RoutePermissionGuard> <RoutePermissionGuard>{props.children}</RoutePermissionGuard>
<RefineKbar /> <RefineKbar />
</Refine> </Refine>
+29 -1
View File
@@ -3,6 +3,9 @@ import { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak"; import KeycloakProvider from "next-auth/providers/keycloak";
import Avatar from "@assets/avatar/avatar-small.jpeg"; import Avatar from "@assets/avatar/avatar-small.jpeg";
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000;
type KeycloakTokenResponse = { type KeycloakTokenResponse = {
access_token: string; access_token: string;
expires_in: number; expires_in: number;
@@ -50,6 +53,7 @@ const refreshAccessToken = async (token: JWT): Promise<JWT> => {
return { return {
...token, ...token,
accessToken: refreshed.access_token, accessToken: refreshed.access_token,
accessTokenIssuedAt: Date.now(),
accessTokenExpires: Date.now() + refreshed.expires_in * 1000, accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
refreshToken: refreshed.refresh_token ?? token.refreshToken, refreshToken: refreshed.refresh_token ?? token.refreshToken,
error: undefined, error: undefined,
@@ -88,8 +92,10 @@ const authOptions: NextAuthOptions = {
} }
if (account) { if (account) {
token.sessionExpiresAt = Date.now() + SESSION_MAX_AGE_SECONDS * 1000;
if (account.access_token) { if (account.access_token) {
token.accessToken = account.access_token; token.accessToken = account.access_token;
token.accessTokenIssuedAt = Date.now();
} }
if (account.refresh_token) { if (account.refresh_token) {
token.refreshToken = account.refresh_token; token.refreshToken = account.refresh_token;
@@ -104,7 +110,19 @@ const authOptions: NextAuthOptions = {
return token; return token;
} }
if (typeof token.accessTokenExpires === "number" && Date.now() < token.accessTokenExpires - 30_000) { if (
typeof token.sessionExpiresAt === "number" &&
Date.now() >= token.sessionExpiresAt
) {
return { ...token, error: "SessionExpired" };
}
const accessTokenIsFresh =
typeof token.accessTokenExpires === "number" &&
typeof token.accessTokenIssuedAt === "number" &&
Date.now() < token.accessTokenExpires - ACCESS_TOKEN_REFRESH_SKEW_MS;
if (accessTokenIsFresh) {
return token; return token;
} }
@@ -123,9 +141,19 @@ const authOptions: NextAuthOptions = {
if (token.error) { if (token.error) {
session.error = token.error; session.error = token.error;
} }
if (typeof token.sessionExpiresAt === "number") {
session.sessionExpiresAt = token.sessionExpiresAt;
}
return session; return session;
}, },
}, },
session: {
strategy: "jwt",
maxAge: SESSION_MAX_AGE_SECONDS,
},
jwt: {
maxAge: SESSION_MAX_AGE_SECONDS,
},
}; };
export default authOptions; export default authOptions;
+15
View File
@@ -57,6 +57,7 @@ import {
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { config } from "@config/config"; import { config } from "@config/config";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
type MetadataUser = { type MetadataUser = {
@@ -479,6 +480,20 @@ export const SystemAdminPanel = () => {
const [databaseForms, setDatabaseForms] = useState(createDefaultDatabaseForms); const [databaseForms, setDatabaseForms] = useState(createDefaultDatabaseForms);
const [databaseHealth, setDatabaseHealth] = useState(createEmptyDatabaseHealth); const [databaseHealth, setDatabaseHealth] = useState(createEmptyDatabaseHealth);
const recoveryDraft = useMemo(
() => ({ tab, projectId, memberForm, projectForm, createProjectOpen, createProjectForm }),
[createProjectForm, createProjectOpen, memberForm, projectForm, projectId, tab],
);
const restoreRecoveryDraft = useCallback((draft: typeof recoveryDraft) => {
setTab(draft.tab);
setProjectId(draft.projectId);
setMemberForm(draft.memberForm);
setProjectForm(draft.projectForm);
setCreateProjectOpen(draft.createProjectOpen);
setCreateProjectForm(draft.createProjectForm);
}, []);
useSessionRecoveryDraft("system-admin", recoveryDraft, restoreRecoveryDraft);
useEffect(() => { useEffect(() => {
openNotificationRef.current = openNotification; openNotificationRef.current = openNotification;
}, [openNotification]); }, [openNotification]);
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { signIn } from "next-auth/react";
import AccessTimeOutlinedIcon from "@mui/icons-material/AccessTimeOutlined";
import {
Alert,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
Typography,
} from "@mui/material";
import { useAuthStore } from "@/store/authStore";
const WARNING_WINDOW_MS = 15 * 60 * 1000;
type SessionExpiryDialogProps = {
expiresAt?: number;
};
export const SessionExpiryDialog = ({
expiresAt,
}: SessionExpiryDialogProps) => {
const sessionExpired = useAuthStore((state) => state.sessionExpired);
const reason = useAuthStore((state) => state.sessionExpiryReason);
const [now, setNow] = useState(() => Date.now());
const [warningDismissed, setWarningDismissed] = useState(false);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 30_000);
return () => window.clearInterval(timer);
}, []);
const isExpiringSoon = useMemo(
() =>
!sessionExpired &&
!warningDismissed &&
typeof expiresAt === "number" &&
expiresAt > now &&
expiresAt - now <= WARNING_WINDOW_MS,
[expiresAt, now, sessionExpired, warningDismissed],
);
const handleReauthenticate = () => {
const callbackUrl = `${window.location.pathname}${window.location.search}`;
void signIn("keycloak", { callbackUrl, redirect: true });
};
const isOpen = sessionExpired || isExpiringSoon;
const title = sessionExpired ? "登录已过期" : "登录即将到期";
const detail = sessionExpired
? reason === "session_max_age"
? "已达到 12 小时的最长连续登录时间。请重新认证后继续。"
: "无法续期当前登录。请重新认证后继续。"
: "当前登录将在 15 分钟内到期。请先保存正在编辑的内容。";
return (
<Dialog
open={isOpen}
disableEscapeKeyDown={sessionExpired}
onClose={sessionExpired ? undefined : () => setWarningDismissed(true)}
aria-labelledby="session-expiry-dialog-title"
>
<DialogTitle id="session-expiry-dialog-title">{title}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ pt: 0.5 }}>
<Alert icon={<AccessTimeOutlinedIcon />} severity={sessionExpired ? "warning" : "info"}>
{detail}
</Alert>
<Typography variant="body2" color="text.secondary">
</Typography>
</Stack>
</DialogContent>
<DialogActions>
{!sessionExpired && (
<Button onClick={() => setWarningDismissed(true)}></Button>
)}
<Button variant="contained" onClick={handleReauthenticate}>
</Button>
</DialogActions>
</Dialog>
);
};
@@ -20,6 +20,7 @@ import "dayjs/locale/zh-cn";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { NETWORK_NAME } from "@config/config"; import { NETWORK_NAME } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { BurstDetectionResult } from "./types"; import { BurstDetectionResult } from "./types";
interface Props { interface Props {
@@ -121,6 +122,12 @@ const AnalysisParameters: React.FC<Props> = ({
} = parametersState; } = parametersState;
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [frequencyLoading, setFrequencyLoading] = useState(false); const [frequencyLoading, setFrequencyLoading] = useState(false);
useSessionRecoveryDraft("burst-detection", parametersState, (draft) =>
setParametersState({
...draft,
targetTime: draft.targetTime ? dayjs(draft.targetTime) : null,
}),
);
useEffect(() => { useEffect(() => {
if (samplingIntervalSource !== "metadata") return; if (samplingIntervalSource !== "metadata") return;
@@ -26,6 +26,7 @@ import "dayjs/locale/zh-cn";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { NETWORK_NAME, config } from "@config/config"; import { NETWORK_NAME, config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
import { BurstLocationResult } from "./types"; import { BurstLocationResult } from "./types";
import { getBurstLocationErrorNotice } from "./burstLocationError"; import { getBurstLocationErrorNotice } from "./burstLocationError";
@@ -105,6 +106,13 @@ const AnalysisParameters: React.FC<Props> = ({
} = parametersState; } = parametersState;
const [schemeLoading, setSchemeLoading] = useState(false); const [schemeLoading, setSchemeLoading] = useState(false);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
useSessionRecoveryDraft("burst-location", parametersState, (draft) =>
setParametersState({
...draft,
burstStartTime: draft.burstStartTime ? dayjs(draft.burstStartTime) : null,
burstEndTime: draft.burstEndTime ? dayjs(draft.burstEndTime) : null,
}),
);
const isSimulationMode = dataSource === "simulation"; const isSimulationMode = dataSource === "simulation";
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => { const applySchemeTimeRange = useCallback((scheme: SchemeItem) => {
@@ -29,6 +29,7 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config"; import { config, NETWORK_NAME } from "@/config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { along, lineString, length, toMercator } from "@turf/turf"; import { along, lineString, length, toMercator } from "@turf/turf";
import { Point } from "ol/geom"; import { Point } from "ol/geom";
import { toLonLat } from "ol/proj"; import { toLonLat } from "ol/proj";
@@ -74,6 +75,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
); );
const { pipePoints, startTime, duration, schemeName, network } = const { pipePoints, startTime, duration, schemeName, network } =
parametersState; parametersState;
useSessionRecoveryDraft("burst-simulation", parametersState, (draft) =>
setParametersState({
...draft,
startTime: draft.startTime ? dayjs(draft.startTime) : null,
}),
);
const [isSelecting, setIsSelecting] = useState<boolean>(false); const [isSelecting, setIsSelecting] = useState<boolean>(false);
const [highlightLayer, setHighlightLayer] = const [highlightLayer, setHighlightLayer] =
@@ -29,6 +29,7 @@ import {
queryFeaturesByIds, queryFeaturesByIds,
} from "@/utils/mapQueryService"; } from "@/utils/mapQueryService";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
export interface ContaminantAnalysisParametersState { export interface ContaminantAnalysisParametersState {
schemeName: string; schemeName: string;
@@ -62,7 +63,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
const { open } = useNotification(); const { open } = useNotification();
const network = NETWORK_NAME; const network = NETWORK_NAME;
const [parametersState, , setFormField] = useControllableObjectState( const [parametersState, setParametersState, setFormField] = useControllableObjectState(
state, state,
onStateChange, onStateChange,
createContaminantAnalysisParametersState(), createContaminantAnalysisParametersState(),
@@ -75,6 +76,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
duration, duration,
pattern, pattern,
} = parametersState; } = parametersState;
useSessionRecoveryDraft("contaminant-simulation", parametersState, (draft) =>
setParametersState({
...draft,
startTime: draft.startTime ? dayjs(draft.startTime) : null,
}),
);
const [isSelecting, setIsSelecting] = useState<boolean>(false); const [isSelecting, setIsSelecting] = useState<boolean>(false);
const [submitting, setSubmitting] = useState<boolean>(false); const [submitting, setSubmitting] = useState<boolean>(false);
@@ -20,6 +20,7 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { config } from "@config/config"; import { config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { LeakageResultDetail } from "./types"; import { LeakageResultDetail } from "./types";
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
@@ -58,7 +59,7 @@ const AnalysisParameters: React.FC<Props> = ({
onStateChange, onStateChange,
}) => { }) => {
const { open } = useNotification(); const { open } = useNotification();
const [parametersState, , setFormField] = useControllableObjectState( const [parametersState, setParametersState, setFormField] = useControllableObjectState(
state, state,
onStateChange, onStateChange,
createDMALeakAnalysisParametersState(), createDMALeakAnalysisParametersState(),
@@ -73,6 +74,13 @@ const AnalysisParameters: React.FC<Props> = ({
qSum, qSum,
advancedOpen, advancedOpen,
} = parametersState; } = parametersState;
useSessionRecoveryDraft("dma-leak-detection", parametersState, (draft) =>
setParametersState({
...draft,
startTime: draft.startTime ? dayjs(draft.startTime) : null,
endTime: draft.endTime ? dayjs(draft.endTime) : null,
}),
);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [qSumInput, setQSumInput] = useState(() => String(qSum)); const [qSumInput, setQSumInput] = useState(() => String(qSum));
@@ -35,6 +35,7 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config"; import { config, NETWORK_NAME } from "@/config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
export interface ValveItem { export interface ValveItem {
@@ -80,6 +81,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
); );
const { schemeName, valves, drainageNode, startTime, flushFlow, duration } = const { schemeName, valves, drainageNode, startTime, flushFlow, duration } =
parametersState; parametersState;
useSessionRecoveryDraft("flushing-analysis", parametersState, (draft) =>
setParametersState({
...draft,
startTime: draft.startTime ? dayjs(draft.startTime) : null,
}),
);
const [valveFeatures, setValveFeatures] = useState<Feature[]>([]); const [valveFeatures, setValveFeatures] = useState<Feature[]>([]);
const [drainageFeature, setDrainageFeature] = useState<Feature | null>(null); const [drainageFeature, setDrainageFeature] = useState<Feature | null>(null);
+21
View File
@@ -1,4 +1,5 @@
import { resolveRequestUrl } from "@/lib/api"; import { resolveRequestUrl } from "@/lib/api";
import { useAuthStore } from "@/store/authStore";
describe("resolveRequestUrl", () => { describe("resolveRequestUrl", () => {
it("does not prepend baseURL to an absolute request URL", () => { it("does not prepend baseURL to an absolute request URL", () => {
@@ -19,3 +20,23 @@ describe("resolveRequestUrl", () => {
).toBe("http://localhost:8000/api/v1/schemes"); ).toBe("http://localhost:8000/api/v1/schemes");
}); });
}); });
describe("authentication lifecycle state", () => {
beforeEach(() => {
useAuthStore.setState({
accessToken: "access-token",
sessionExpired: false,
sessionExpiryReason: null,
});
});
it("clears only the local access token when a request becomes unauthorized", () => {
useAuthStore.getState().markSessionExpired("unauthorized");
expect(useAuthStore.getState()).toMatchObject({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: "unauthorized",
});
});
});
+1 -8
View File
@@ -1,6 +1,5 @@
import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios"; import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios";
import { config } from "@config/config"; import { config } from "@config/config";
import { signOut } from "next-auth/react";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import { import {
applyAuthContextHeaders, applyAuthContextHeaders,
@@ -13,8 +12,6 @@ export const api = axios.create({
baseURL: API_URL, baseURL: API_URL,
}); });
let isSigningOut = false;
export const resolveRequestUrl = (request: { export const resolveRequestUrl = (request: {
baseURL?: string; baseURL?: string;
url?: string; url?: string;
@@ -63,11 +60,7 @@ api.interceptors.response.use(
}, },
async (error) => { async (error) => {
if (error?.response?.status === 401 && typeof window !== "undefined") { if (error?.response?.status === 401 && typeof window !== "undefined") {
useAuthStore.getState().setAccessToken(null); useAuthStore.getState().markSessionExpired("unauthorized");
if (!isSigningOut) {
isSigningOut = true;
await signOut({ redirect: true, callbackUrl: "/login" });
}
} }
return Promise.reject(error); return Promise.reject(error);
}, },
+1 -8
View File
@@ -1,12 +1,9 @@
import { signOut } from "next-auth/react";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import { import {
applyAuthContextHeaders, applyAuthContextHeaders,
type AuthContextHeaderOptions, type AuthContextHeaderOptions,
} from "@/lib/requestHeaders"; } from "@/lib/requestHeaders";
let isSigningOut = false;
const unwrapPage = async (response: Response) => { const unwrapPage = async (response: Response) => {
if ( if (
!response.headers.get("content-type")?.includes("application/json") !response.headers.get("content-type")?.includes("application/json")
@@ -58,11 +55,7 @@ export const apiFetch = async (
const response = await fetch(input, requestInit); const response = await fetch(input, requestInit);
if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) { if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) {
useAuthStore.getState().setAccessToken(null); useAuthStore.getState().markSessionExpired("unauthorized");
if (!isSigningOut) {
isSigningOut = true;
await signOut({ redirect: true, callbackUrl: "/login" });
}
} }
return unwrapPage(response); return unwrapPage(response);
+3
View File
@@ -42,6 +42,9 @@ export const getAccessToken = async () => {
setAccessToken(null); setAccessToken(null);
} }
const session = await getSession(); const session = await getSession();
if (session?.error) {
return null;
}
const token = typeof session?.accessToken === "string" ? session.accessToken : null; const token = typeof session?.accessToken === "string" ? session.accessToken : null;
if (token && !isTokenExpired(token)) { if (token && !isTokenExpired(token)) {
setAccessToken(token); setAccessToken(token);
+41
View File
@@ -0,0 +1,41 @@
import { act, render } from "@testing-library/react";
import { useState } from "react";
import { useSessionRecoveryDraft } from "./sessionRecoveryDraft";
import { useAuthStore } from "@/store/authStore";
const DraftFixture = ({ initial }: { initial: string }) => {
const [value, setValue] = useState(initial);
useSessionRecoveryDraft("fixture", { value }, (draft) => setValue(draft.value));
return <output>{value}</output>;
};
describe("useSessionRecoveryDraft", () => {
beforeEach(() => {
sessionStorage.clear();
useAuthStore.setState({
accessToken: null,
sessionExpired: false,
sessionExpiryReason: null,
});
});
it("saves an in-progress value when authentication expires and restores it once", () => {
const first = render(<DraftFixture initial="in-progress" />);
act(() => {
useAuthStore.getState().markSessionExpired("unauthorized");
});
expect(sessionStorage.getItem("tjwater-session-recovery:fixture")).toBe(
JSON.stringify({ value: "in-progress" }),
);
first.unmount();
useAuthStore.getState().clearSessionExpired();
const restored = render(<DraftFixture initial="empty" />);
expect(restored.getByText("in-progress")).toBeInTheDocument();
expect(sessionStorage.getItem("tjwater-session-recovery:fixture")).toBeNull();
});
});
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useRef } from "react";
import { useAuthStore } from "@/store/authStore";
const STORAGE_PREFIX = "tjwater-session-recovery:";
const storageKey = (key: string) => `${STORAGE_PREFIX}${key}`;
export const clearSessionRecoveryDrafts = () => {
if (typeof window === "undefined") return;
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
const key = sessionStorage.key(index);
if (key?.startsWith(STORAGE_PREFIX)) sessionStorage.removeItem(key);
}
};
export const useSessionRecoveryDraft = <T,>(
key: string,
value: T,
restore: (value: T) => void,
) => {
const sessionExpired = useAuthStore((state) => state.sessionExpired);
const restoredRef = useRef(false);
useEffect(() => {
if (restoredRef.current || typeof window === "undefined") return;
restoredRef.current = true;
const raw = sessionStorage.getItem(storageKey(key));
if (!raw) return;
try {
restore(JSON.parse(raw) as T);
sessionStorage.removeItem(storageKey(key));
} catch {
sessionStorage.removeItem(storageKey(key));
}
}, [key, restore]);
useEffect(() => {
if (!sessionExpired || typeof window === "undefined") return;
try {
sessionStorage.setItem(storageKey(key), JSON.stringify(value));
} catch {
// Recovery is best-effort. Do not block re-authentication when storage is unavailable.
}
}, [key, sessionExpired, value]);
};
+17
View File
@@ -2,10 +2,27 @@ import { create } from "zustand";
interface AuthState { interface AuthState {
accessToken: string | null; accessToken: string | null;
sessionExpired: boolean;
sessionExpiryReason: "refresh_failed" | "session_max_age" | "unauthorized" | null;
setAccessToken: (token: string | null) => void; setAccessToken: (token: string | null) => void;
markSessionExpired: (
reason: Exclude<AuthState["sessionExpiryReason"], null>,
) => void;
clearSessionExpired: () => void;
} }
export const useAuthStore = create<AuthState>((set) => ({ export const useAuthStore = create<AuthState>((set) => ({
accessToken: null, accessToken: null,
sessionExpired: false,
sessionExpiryReason: null,
setAccessToken: (token) => set({ accessToken: token }), setAccessToken: (token) => set({ accessToken: token }),
markSessionExpired: (reason) => set({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: reason,
}),
clearSessionExpired: () => set({
sessionExpired: false,
sessionExpiryReason: null,
}),
})); }));
+5 -2
View File
@@ -4,7 +4,8 @@ import "next-auth/jwt";
declare module "next-auth" { declare module "next-auth" {
interface Session { interface Session {
accessToken?: string; accessToken?: string;
error?: "RefreshAccessTokenError"; error?: "RefreshAccessTokenError" | "SessionExpired";
sessionExpiresAt?: number;
user?: { user?: {
id?: string; id?: string;
username?: string; username?: string;
@@ -26,7 +27,9 @@ declare module "next-auth/jwt" {
username?: string; username?: string;
accessToken?: string; accessToken?: string;
refreshToken?: string; refreshToken?: string;
accessTokenIssuedAt?: number;
accessTokenExpires?: number; accessTokenExpires?: number;
error?: "RefreshAccessTokenError"; sessionExpiresAt?: number;
error?: "RefreshAccessTokenError" | "SessionExpired";
} }
} }