From e496fbe4b72d1ff4a3d46f9e5e770e1c7a38f366 Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 5 Aug 2026 17:10:59 +0800 Subject: [PATCH] fix(auth): align session lifecycle with Keycloak --- .env.example | 1 + docker-compose.yml | 1 + src/app/RefineContext.tsx | 23 ++++- src/app/api/auth/[...nextauth]/options.ts | 30 ++++++- src/components/admin/SystemAdminPanel.tsx | 15 ++++ src/components/auth/SessionExpiryDialog.tsx | 89 +++++++++++++++++++ .../BurstDetection/AnalysisParameters.tsx | 7 ++ .../BurstLocation/AnalysisParameters.tsx | 8 ++ .../BurstSimulation/AnalysisParameters.tsx | 7 ++ .../AnalysisParameters.tsx | 9 +- .../DMALeakDetection/AnalysisParameters.tsx | 10 ++- .../FlushingAnalysis/AnalysisParameters.tsx | 7 ++ src/lib/api.test.ts | 21 +++++ src/lib/api.ts | 9 +- src/lib/apiFetch.ts | 9 +- src/lib/authToken.ts | 3 + src/lib/sessionRecoveryDraft.test.tsx | 41 +++++++++ src/lib/sessionRecoveryDraft.ts | 49 ++++++++++ src/store/authStore.ts | 17 ++++ src/types/next-auth.d.ts | 7 +- 20 files changed, 339 insertions(+), 24 deletions(-) create mode 100644 src/components/auth/SessionExpiryDialog.tsx create mode 100644 src/lib/sessionRecoveryDraft.test.tsx create mode 100644 src/lib/sessionRecoveryDraft.ts diff --git a/.env.example b/.env.example index 9a1b3c2..072dda2 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ KEYCLOAK_CLIENT_ID="tjwater" KEYCLOAK_CLIENT_SECRET="replace-with-keycloak-client-secret" 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_URL="https://frontend.example.com/" diff --git a/docker-compose.yml b/docker-compose.yml index 77938c1..adae578 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID} KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET} KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER} + KEYCLOAK_POST_LOGOUT_REDIRECT_URI: ${KEYCLOAK_POST_LOGOUT_REDIRECT_URI} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} NEXTAUTH_URL: ${NEXTAUTH_URL} NODE_ENV: production diff --git a/src/app/RefineContext.tsx b/src/app/RefineContext.tsx index 3fb3bb7..7bc8f62 100644 --- a/src/app/RefineContext.tsx +++ b/src/app/RefineContext.tsx @@ -17,10 +17,12 @@ import { ColorModeContextProvider } from "@contexts/color-mode"; import { dataProvider } from "@providers/data-provider"; import { ProjectProvider } from "@/contexts/ProjectContext"; import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard"; +import { SessionExpiryDialog } from "@/components/auth/SessionExpiryDialog"; import { useAuthStore } from "@/store/authStore"; import { useAccessStore } from "@/store/accessStore"; import { useProjectStore } from "@/store/projectStore"; import { apiFetch } from "@/lib/apiFetch"; +import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft"; import { permissionCodes, resourcePermissions } from "@/lib/permissions"; import { config } from "@config/config"; import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider"; @@ -57,6 +59,8 @@ const App = (props: React.PropsWithChildren) => { const { data, status } = useSession(); const to = usePathname(); 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 permissions = useAccessStore((state) => state.permissions); const setAccessContext = useAccessStore((state) => state.setContext); @@ -70,6 +74,20 @@ const App = (props: React.PropsWithChildren) => { ); }, [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(() => { if (status !== "authenticated") { resetAccess(); @@ -150,13 +168,11 @@ const App = (props: React.PropsWithChildren) => { if (data?.user?.id) { sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`); } + clearSessionRecoveryDrafts(); window.location.assign("/api/auth/keycloak-logout"); return { success: true }; }, onError: async (error) => { - if (error.response?.status === 401) { - return { logout: true }; - } return { error }; }, check: async () => @@ -351,6 +367,7 @@ const App = (props: React.PropsWithChildren) => { warnWhenUnsavedChanges: true, }} > + {props.children} diff --git a/src/app/api/auth/[...nextauth]/options.ts b/src/app/api/auth/[...nextauth]/options.ts index aae95c3..97c98e0 100644 --- a/src/app/api/auth/[...nextauth]/options.ts +++ b/src/app/api/auth/[...nextauth]/options.ts @@ -3,6 +3,9 @@ import { JWT } from "next-auth/jwt"; import KeycloakProvider from "next-auth/providers/keycloak"; 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 = { access_token: string; expires_in: number; @@ -50,6 +53,7 @@ const refreshAccessToken = async (token: JWT): Promise => { return { ...token, accessToken: refreshed.access_token, + accessTokenIssuedAt: Date.now(), accessTokenExpires: Date.now() + refreshed.expires_in * 1000, refreshToken: refreshed.refresh_token ?? token.refreshToken, error: undefined, @@ -88,8 +92,10 @@ const authOptions: NextAuthOptions = { } if (account) { + token.sessionExpiresAt = Date.now() + SESSION_MAX_AGE_SECONDS * 1000; if (account.access_token) { token.accessToken = account.access_token; + token.accessTokenIssuedAt = Date.now(); } if (account.refresh_token) { token.refreshToken = account.refresh_token; @@ -104,7 +110,19 @@ const authOptions: NextAuthOptions = { 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; } @@ -123,9 +141,19 @@ const authOptions: NextAuthOptions = { if (token.error) { session.error = token.error; } + if (typeof token.sessionExpiresAt === "number") { + session.sessionExpiresAt = token.sessionExpiresAt; + } return session; }, }, + session: { + strategy: "jwt", + maxAge: SESSION_MAX_AGE_SECONDS, + }, + jwt: { + maxAge: SESSION_MAX_AGE_SECONDS, + }, }; export default authOptions; diff --git a/src/components/admin/SystemAdminPanel.tsx b/src/components/admin/SystemAdminPanel.tsx index 4cc2efa..46009fc 100644 --- a/src/components/admin/SystemAdminPanel.tsx +++ b/src/components/admin/SystemAdminPanel.tsx @@ -57,6 +57,7 @@ import { import { useNotification } from "@refinedev/core"; import { config } from "@config/config"; import { apiFetch } from "@/lib/apiFetch"; +import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; import { useProjectStore } from "@/store/projectStore"; type MetadataUser = { @@ -479,6 +480,20 @@ export const SystemAdminPanel = () => { const [databaseForms, setDatabaseForms] = useState(createDefaultDatabaseForms); 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(() => { openNotificationRef.current = openNotification; }, [openNotification]); diff --git a/src/components/auth/SessionExpiryDialog.tsx b/src/components/auth/SessionExpiryDialog.tsx new file mode 100644 index 0000000..56bac19 --- /dev/null +++ b/src/components/auth/SessionExpiryDialog.tsx @@ -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 ( + setWarningDismissed(true)} + aria-labelledby="session-expiry-dialog-title" + > + {title} + + + } severity={sessionExpired ? "warning" : "info"}> + {detail} + + + 重新认证不会自动重放已失败的写入请求;请在返回后确认内容并再次提交。 + + + + + {!sessionExpired && ( + + )} + + + + ); +}; diff --git a/src/components/olmap/BurstDetection/AnalysisParameters.tsx b/src/components/olmap/BurstDetection/AnalysisParameters.tsx index 48a9b7b..72bd7e0 100644 --- a/src/components/olmap/BurstDetection/AnalysisParameters.tsx +++ b/src/components/olmap/BurstDetection/AnalysisParameters.tsx @@ -20,6 +20,7 @@ import "dayjs/locale/zh-cn"; import { api } from "@/lib/api"; import { NETWORK_NAME } from "@config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; +import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; import { BurstDetectionResult } from "./types"; interface Props { @@ -121,6 +122,12 @@ const AnalysisParameters: React.FC = ({ } = parametersState; const [running, setRunning] = useState(false); const [frequencyLoading, setFrequencyLoading] = useState(false); + useSessionRecoveryDraft("burst-detection", parametersState, (draft) => + setParametersState({ + ...draft, + targetTime: draft.targetTime ? dayjs(draft.targetTime) : null, + }), + ); useEffect(() => { if (samplingIntervalSource !== "metadata") return; diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index ac5f105..4409759 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -26,6 +26,7 @@ import "dayjs/locale/zh-cn"; import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; +import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; import { BurstLocationResult } from "./types"; import { getBurstLocationErrorNotice } from "./burstLocationError"; @@ -105,6 +106,13 @@ const AnalysisParameters: React.FC = ({ } = parametersState; const [schemeLoading, setSchemeLoading] = 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 applySchemeTimeRange = useCallback((scheme: SchemeItem) => { diff --git a/src/components/olmap/BurstSimulation/AnalysisParameters.tsx b/src/components/olmap/BurstSimulation/AnalysisParameters.tsx index d1c27f6..537ef33 100644 --- a/src/components/olmap/BurstSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstSimulation/AnalysisParameters.tsx @@ -29,6 +29,7 @@ import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { config, NETWORK_NAME } from "@/config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; +import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; import { along, lineString, length, toMercator } from "@turf/turf"; import { Point } from "ol/geom"; import { toLonLat } from "ol/proj"; @@ -74,6 +75,12 @@ const AnalysisParameters: React.FC = ({ ); const { pipePoints, startTime, duration, schemeName, network } = parametersState; + useSessionRecoveryDraft("burst-simulation", parametersState, (draft) => + setParametersState({ + ...draft, + startTime: draft.startTime ? dayjs(draft.startTime) : null, + }), + ); const [isSelecting, setIsSelecting] = useState(false); const [highlightLayer, setHighlightLayer] = diff --git a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx index df39f84..ad9f625 100644 --- a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx @@ -29,6 +29,7 @@ import { queryFeaturesByIds, } from "@/utils/mapQueryService"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; +import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; export interface ContaminantAnalysisParametersState { schemeName: string; @@ -62,7 +63,7 @@ const AnalysisParameters: React.FC = ({ const { open } = useNotification(); const network = NETWORK_NAME; - const [parametersState, , setFormField] = useControllableObjectState( + const [parametersState, setParametersState, setFormField] = useControllableObjectState( state, onStateChange, createContaminantAnalysisParametersState(), @@ -75,6 +76,12 @@ const AnalysisParameters: React.FC = ({ duration, pattern, } = parametersState; + useSessionRecoveryDraft("contaminant-simulation", parametersState, (draft) => + setParametersState({ + ...draft, + startTime: draft.startTime ? dayjs(draft.startTime) : null, + }), + ); const [isSelecting, setIsSelecting] = useState(false); const [submitting, setSubmitting] = useState(false); diff --git a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx index 71a1c72..8a10424 100644 --- a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx +++ b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx @@ -20,6 +20,7 @@ import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { config } from "@config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; +import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; import { LeakageResultDetail } from "./types"; import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; @@ -58,7 +59,7 @@ const AnalysisParameters: React.FC = ({ onStateChange, }) => { const { open } = useNotification(); - const [parametersState, , setFormField] = useControllableObjectState( + const [parametersState, setParametersState, setFormField] = useControllableObjectState( state, onStateChange, createDMALeakAnalysisParametersState(), @@ -73,6 +74,13 @@ const AnalysisParameters: React.FC = ({ qSum, advancedOpen, } = 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 [qSumInput, setQSumInput] = useState(() => String(qSum)); diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx index 5d599cb..b7601d4 100644 --- a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -35,6 +35,7 @@ import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; import { config, NETWORK_NAME } from "@/config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; +import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; export interface ValveItem { @@ -80,6 +81,12 @@ const AnalysisParameters: React.FC = ({ ); const { schemeName, valves, drainageNode, startTime, flushFlow, duration } = parametersState; + useSessionRecoveryDraft("flushing-analysis", parametersState, (draft) => + setParametersState({ + ...draft, + startTime: draft.startTime ? dayjs(draft.startTime) : null, + }), + ); const [valveFeatures, setValveFeatures] = useState([]); const [drainageFeature, setDrainageFeature] = useState(null); diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 6711ad1..b2aec75 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -1,4 +1,5 @@ import { resolveRequestUrl } from "@/lib/api"; +import { useAuthStore } from "@/store/authStore"; describe("resolveRequestUrl", () => { it("does not prepend baseURL to an absolute request URL", () => { @@ -19,3 +20,23 @@ describe("resolveRequestUrl", () => { ).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", + }); + }); +}); diff --git a/src/lib/api.ts b/src/lib/api.ts index 004a1fb..6c68c01 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,6 +1,5 @@ import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios"; import { config } from "@config/config"; -import { signOut } from "next-auth/react"; import { useAuthStore } from "@/store/authStore"; import { applyAuthContextHeaders, @@ -13,8 +12,6 @@ export const api = axios.create({ baseURL: API_URL, }); -let isSigningOut = false; - export const resolveRequestUrl = (request: { baseURL?: string; url?: string; @@ -63,11 +60,7 @@ api.interceptors.response.use( }, async (error) => { if (error?.response?.status === 401 && typeof window !== "undefined") { - useAuthStore.getState().setAccessToken(null); - if (!isSigningOut) { - isSigningOut = true; - await signOut({ redirect: true, callbackUrl: "/login" }); - } + useAuthStore.getState().markSessionExpired("unauthorized"); } return Promise.reject(error); }, diff --git a/src/lib/apiFetch.ts b/src/lib/apiFetch.ts index edc324f..2b75bf4 100644 --- a/src/lib/apiFetch.ts +++ b/src/lib/apiFetch.ts @@ -1,12 +1,9 @@ -import { signOut } from "next-auth/react"; import { useAuthStore } from "@/store/authStore"; import { applyAuthContextHeaders, type AuthContextHeaderOptions, } from "@/lib/requestHeaders"; -let isSigningOut = false; - const unwrapPage = async (response: Response) => { if ( !response.headers.get("content-type")?.includes("application/json") @@ -58,11 +55,7 @@ export const apiFetch = async ( const response = await fetch(input, requestInit); if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) { - useAuthStore.getState().setAccessToken(null); - if (!isSigningOut) { - isSigningOut = true; - await signOut({ redirect: true, callbackUrl: "/login" }); - } + useAuthStore.getState().markSessionExpired("unauthorized"); } return unwrapPage(response); diff --git a/src/lib/authToken.ts b/src/lib/authToken.ts index ee0f15c..45919ac 100644 --- a/src/lib/authToken.ts +++ b/src/lib/authToken.ts @@ -42,6 +42,9 @@ export const getAccessToken = async () => { setAccessToken(null); } const session = await getSession(); + if (session?.error) { + return null; + } const token = typeof session?.accessToken === "string" ? session.accessToken : null; if (token && !isTokenExpired(token)) { setAccessToken(token); diff --git a/src/lib/sessionRecoveryDraft.test.tsx b/src/lib/sessionRecoveryDraft.test.tsx new file mode 100644 index 0000000..e13395c --- /dev/null +++ b/src/lib/sessionRecoveryDraft.test.tsx @@ -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 {value}; +}; + +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(); + + 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(); + + expect(restored.getByText("in-progress")).toBeInTheDocument(); + expect(sessionStorage.getItem("tjwater-session-recovery:fixture")).toBeNull(); + }); +}); diff --git a/src/lib/sessionRecoveryDraft.ts b/src/lib/sessionRecoveryDraft.ts new file mode 100644 index 0000000..a01e668 --- /dev/null +++ b/src/lib/sessionRecoveryDraft.ts @@ -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 = ( + 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]); +}; diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 3a6af58..64e2561 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -2,10 +2,27 @@ import { create } from "zustand"; interface AuthState { accessToken: string | null; + sessionExpired: boolean; + sessionExpiryReason: "refresh_failed" | "session_max_age" | "unauthorized" | null; setAccessToken: (token: string | null) => void; + markSessionExpired: ( + reason: Exclude, + ) => void; + clearSessionExpired: () => void; } export const useAuthStore = create((set) => ({ accessToken: null, + sessionExpired: false, + sessionExpiryReason: null, setAccessToken: (token) => set({ accessToken: token }), + markSessionExpired: (reason) => set({ + accessToken: null, + sessionExpired: true, + sessionExpiryReason: reason, + }), + clearSessionExpired: () => set({ + sessionExpired: false, + sessionExpiryReason: null, + }), })); diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index e2a0580..f46619c 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -4,7 +4,8 @@ import "next-auth/jwt"; declare module "next-auth" { interface Session { accessToken?: string; - error?: "RefreshAccessTokenError"; + error?: "RefreshAccessTokenError" | "SessionExpired"; + sessionExpiresAt?: number; user?: { id?: string; username?: string; @@ -26,7 +27,9 @@ declare module "next-auth/jwt" { username?: string; accessToken?: string; refreshToken?: string; + accessTokenIssuedAt?: number; accessTokenExpires?: number; - error?: "RefreshAccessTokenError"; + sessionExpiresAt?: number; + error?: "RefreshAccessTokenError" | "SessionExpired"; } }