import { authApi } from '@/lib/api/auth'; const STORAGE_KEY = 'dyolink.accessTokenExpiresAt'; /** Refresh this long before the access JWT expires. */ const REFRESH_BUFFER_MS = 2 * 60 * 1000; /** Safety cap — never call /auth/refresh more than once per minute per tab. */ const MIN_REFRESH_GAP_MS = 60 * 1000; let timerId: ReturnType | null = null; let refreshInFlight: Promise | null = null; let lastRefreshAt = 0; export function setAccessTokenExpiresAt(iso: string): void { if (typeof window === 'undefined') { return; } sessionStorage.setItem(STORAGE_KEY, iso); } export function clearAccessTokenExpiresAt(): void { if (typeof window === 'undefined') { return; } sessionStorage.removeItem(STORAGE_KEY); } export function rememberAccessTokenExpiresAt( iso: string | undefined | null, ): void { if (iso) { setAccessTokenExpiresAt(iso); } } function getAccessTokenExpiresAtMs(): number | null { if (typeof window === 'undefined') { return null; } const raw = sessionStorage.getItem(STORAGE_KEY); if (!raw) { return null; } const ms = Date.parse(raw); return Number.isFinite(ms) ? ms : null; } function clearTimer(): void { if (timerId !== null) { clearTimeout(timerId); timerId = null; } } function computeDelayMs(expiresAtMs: number): number { const refreshAt = expiresAtMs - REFRESH_BUFFER_MS; const delay = refreshAt - Date.now(); return Math.max(delay, MIN_REFRESH_GAP_MS); } function shouldRefreshNow(expiresAtMs: number): boolean { return expiresAtMs - REFRESH_BUFFER_MS <= Date.now(); } async function refreshAccessTokenWithOrg(): Promise { if (refreshInFlight) { return refreshInFlight; } if (Date.now() - lastRefreshAt < MIN_REFRESH_GAP_MS) { const expiresAtMs = getAccessTokenExpiresAtMs(); return expiresAtMs ? new Date(expiresAtMs).toISOString() : undefined; } refreshInFlight = (async () => { try { const result = await authApi.refreshSessionFromCookies(); const expiresAt = result.data?.accessTokenExpiresAt; rememberAccessTokenExpiresAt(expiresAt); const orgId = localStorage.getItem('currentOrganizationId'); if (orgId) { try { await authApi.selectOrganization(orgId); } catch { /* refresh may already preserve organizationId on the JWT */ } } lastRefreshAt = Date.now(); return expiresAt; } finally { refreshInFlight = null; } })(); return refreshInFlight; } function scheduleNextRefresh(reschedule: () => void): void { clearTimer(); if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { return; } const expiresAtMs = getAccessTokenExpiresAtMs(); if (!expiresAtMs) { return; } timerId = setTimeout(() => { void refreshAccessTokenWithOrg() .catch(() => { /* reactive refresh / next visibility check will recover */ }) .finally(reschedule); }, computeDelayMs(expiresAtMs)); } /** Keeps the access cookie fresh while the tab is visible. Returns a cleanup function. */ export function startProactiveSessionRefresh(): () => void { if (typeof window === 'undefined') { return () => undefined; } const reschedule = () => scheduleNextRefresh(reschedule); const onVisibilityChange = () => { if (document.visibilityState === 'visible') { const expiresAtMs = getAccessTokenExpiresAtMs(); if (expiresAtMs && shouldRefreshNow(expiresAtMs)) { void refreshAccessTokenWithOrg() .catch(() => undefined) .finally(reschedule); } else { reschedule(); } return; } clearTimer(); }; document.addEventListener('visibilitychange', onVisibilityChange); reschedule(); return () => { document.removeEventListener('visibilitychange', onVisibilityChange); clearTimer(); }; }