diff --git a/web/dashboard/next.config.ts b/web/dashboard/next.config.ts index f2ddb4b..81b634b 100644 --- a/web/dashboard/next.config.ts +++ b/web/dashboard/next.config.ts @@ -12,8 +12,26 @@ const withPWA = withPWAInit({ disable: process.env.NODE_ENV === "development", workboxOptions: { disableDevLogs: true, + // Pick up a new deploy promptly and never serve a stale shell that points + // at JS chunks the new build replaced (which looked like being logged out + // after every update). + skipWaiting: true, + clientsClaim: true, + cleanupOutdatedCaches: true, runtimeCaching: [ - // App shell: cache-first, very long TTL + // HTML navigations: always try the network first so the document and its + // chunk references match the currently-deployed build; fall back to cache + // only when offline. + { + urlPattern: ({ request }: { request: Request }) => request.mode === "navigate", + handler: "NetworkFirst", + options: { + cacheName: "pages", + networkTimeoutSeconds: 5, + expiration: { maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 }, + }, + }, + // Hashed static chunks are immutable per build — cache-first is safe and fast. { urlPattern: /\/_next\/static\//, handler: "CacheFirst", diff --git a/web/dashboard/src/lib/api/client.ts b/web/dashboard/src/lib/api/client.ts index 85b50ac..89de57a 100644 --- a/web/dashboard/src/lib/api/client.ts +++ b/web/dashboard/src/lib/api/client.ts @@ -36,12 +36,17 @@ api.interceptors.request.use((config) => { * Shared in-flight refresh promise so that a burst of concurrent 401s triggers * exactly one POST /api/auth/refresh instead of one per failed request. */ -let refreshPromise: Promise | null = null; +let refreshPromise: Promise | null = null; -async function refreshAccessToken(): Promise { - if (typeof window === "undefined") return null; +/** Result of a refresh attempt. `sessionInvalid` is true ONLY when the session + * is definitively gone (no/expired/revoked refresh token) — never for a + * transient failure like the API being briefly down during a deploy. */ +type RefreshResult = { token: string | null; sessionInvalid: boolean }; + +async function refreshAccessToken(): Promise { + if (typeof window === "undefined") return { token: null, sessionInvalid: false }; const refreshToken = localStorage.getItem("meezi_refresh_token"); - if (!refreshToken) return null; + if (!refreshToken) return { token: null, sessionInvalid: true }; try { // Bare axios call (not `api`) to avoid recursing through this interceptor. const { data } = await axios.post>( @@ -49,21 +54,44 @@ async function refreshAccessToken(): Promise { { refreshToken }, { headers: { "Content-Type": "application/json" } } ); - if (!data.success || !data.data) return null; - useAuthStore.getState().setAuth(data.data); - return data.data.accessToken; - } catch { - return null; + if (data.success && data.data) { + useAuthStore.getState().setAuth(data.data); + return { token: data.data.accessToken, sessionInvalid: false }; + } + // Server answered but rejected the token → session truly invalid. + return { token: null, sessionInvalid: true }; + } catch (err) { + // Only a definitive 4xx from /auth/refresh means the session is gone. A + // network error, timeout, or 5xx (e.g. the API restarting mid-deploy) is + // transient — do NOT log the user out for it. + const status = (err as AxiosError)?.response?.status; + const sessionInvalid = status === 400 || status === 401 || status === 403; + return { token: null, sessionInvalid }; } } +function forceLogout(): void { + if (typeof window === "undefined") return; + const path = window.location.pathname; + const isPublicGuest = path.startsWith("/q"); + const isAdmin = path.includes("/admin"); + if (isPublicGuest || isAdmin) return; + localStorage.removeItem("meezi_access_token"); + localStorage.removeItem("meezi_refresh_token"); + localStorage.removeItem("meezi_auth"); + const locale = path.split("/")[1] ?? "fa"; + window.location.href = `/${locale}/login`; +} + api.interceptors.response.use( (response) => response, async (error: AxiosError>) => { const status = error.response?.status; + const apiError = error.response?.data?.error; const original = error.config as | (InternalAxiosRequestConfig & { _retry?: boolean }) | undefined; + const onAuthPath = original?.url?.includes("/api/auth/") ?? false; // Expired access token → try a one-time refresh, then replay the request. if ( @@ -71,35 +99,32 @@ api.interceptors.response.use( original && !original._retry && typeof window !== "undefined" && - !original.url?.includes("/api/auth/") + !onAuthPath ) { original._retry = true; refreshPromise ??= refreshAccessToken().finally(() => { refreshPromise = null; }); - const newToken = await refreshPromise; - if (newToken) { - original.headers.Authorization = `Bearer ${newToken}`; + const { token, sessionInvalid } = await refreshPromise; + if (token) { + original.headers.Authorization = `Bearer ${token}`; return api(original); } + // Refresh couldn't get a new token. Log out ONLY if the session is + // definitively invalid — a transient failure (API redeploying, network + // blip) just rejects so the request can be retried, keeping the user + // logged in across deploys. + if (sessionInvalid) forceLogout(); + return Promise.reject( + apiError?.code + ? new ApiClientError(apiError.code, apiError.message, undefined, status) + : error + ); } - const apiError = error.response?.data?.error; if (apiError?.code) { return Promise.reject(new ApiClientError(apiError.code, apiError.message, undefined, status)); } - if (status === 401 && typeof window !== "undefined") { - const path = window.location.pathname; - const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q"); - const isAdmin = path.includes("/admin"); - if (!isPublicGuest && !isAdmin) { - localStorage.removeItem("meezi_access_token"); - localStorage.removeItem("meezi_refresh_token"); - localStorage.removeItem("meezi_auth"); - const locale = path.split("/")[1] ?? "fa"; - window.location.href = `/${locale}/login`; - } - } return Promise.reject(error); } );