Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions frontend/src/hooks/__tests__/useTokenRefreshTimer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { shouldWarnSessionExpiring, SESSION_IDLE_WARN_MS } from '../useTokenRefreshTimer';

describe('shouldWarnSessionExpiring', () => {
it('does not warn while recently active', () => {
const now = 1_000_000;
expect(shouldWarnSessionExpiring(now - 1000, now, SESSION_IDLE_WARN_MS)).toBe(false);
});

it('warns after true idle', () => {
const now = 1_000_000;
expect(shouldWarnSessionExpiring(now - SESSION_IDLE_WARN_MS - 1, now, SESSION_IDLE_WARN_MS)).toBe(true);
});
});
65 changes: 57 additions & 8 deletions frontend/src/hooks/useTokenRefreshTimer.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,56 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useCallback } from 'react';
import { useAuthStore } from '@/stores/authStore';
import { refreshAccessToken } from '@/lib/api/client';
import { decodeJwtPayload } from '@/lib/auth/jwt';
import { toast } from 'sonner';

const RETRY_DELAY_MS = 30_000;
/** Idle threshold before a failed refresh surfaces the "Session expiring" toast. */
export const SESSION_IDLE_WARN_MS = 5 * 60_000;

/**
* Proactively refreshes the access token at 80% of its TTL so users never
* experience a session expiry during active use.
*
* Activity (pointer / keyboard / scroll / touch / visibility / API traffic)
* resets the idle clock. The "Session expiring soon" warning only fires
* after true idle — not while the user is actively interacting.
*/
export function useTokenRefreshTimer() {
const accessToken = useAuthStore((s) => s.accessToken);
const refreshToken = useAuthStore((s) => s.refreshToken);
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const lastActivityRef = useRef<number>(Date.now());

const markActivity = useCallback(() => {
lastActivityRef.current = Date.now();
}, []);

useEffect(() => {
if (typeof window === 'undefined') return;
const events: Array<keyof WindowEventMap> = [
'pointerdown',
'keydown',
'mousemove',
'scroll',
'touchstart',
];
for (const evt of events) {
window.addEventListener(evt, markActivity, { passive: true });
}
window.addEventListener('automl:api-activity', markActivity as EventListener);
const onVis = () => {
if (document.visibilityState === 'visible') markActivity();
};
document.addEventListener('visibilitychange', onVis);
return () => {
for (const evt of events) {
window.removeEventListener(evt, markActivity);
}
window.removeEventListener('automl:api-activity', markActivity as EventListener);
document.removeEventListener('visibilitychange', onVis);
};
}, [markActivity]);

useEffect(() => {
if (!accessToken || !refreshToken) return;
Expand All @@ -25,7 +62,6 @@ export function useTokenRefreshTimer() {
const ttl = payload.exp - nowSec;
if (ttl <= 0) return;

// Refresh at 80% of remaining TTL
const refreshInMs = Math.max(ttl * 0.8 * 1000, 5000);

const scheduleRefresh = (delayMs: number, isRetry = false) => {
Expand All @@ -34,22 +70,35 @@ export function useTokenRefreshTimer() {
const currentRefresh = useAuthStore.getState().refreshToken;
const newToken = await refreshAccessToken(currentRefresh);
if (newToken) {
// Token refreshed — next cycle will schedule via the accessToken dep change
markActivity();
return;
}
if (!isRetry) {
// Retry once after 30s
scheduleRefresh(RETRY_DELAY_MS, true);
} else {
toast.warning('Session expiring soon — please save your work', {
duration: 8000,
});
const idleFor = Date.now() - lastActivityRef.current;
if (idleFor >= SESSION_IDLE_WARN_MS) {
toast.warning('Session expiring soon — please save your work', {
duration: 8000,
});
}
// Keep retrying quietly while the tab is active.
scheduleRefresh(RETRY_DELAY_MS, true);
}
}, delayMs);
};

scheduleRefresh(refreshInMs);

return () => clearTimeout(timerRef.current);
}, [accessToken, refreshToken]);
}, [accessToken, refreshToken, markActivity]);
}

/** Test helper: whether the idle warn should fire. */
export function shouldWarnSessionExpiring(
lastActivityMs: number,
nowMs: number,
idleWarnMs = SESSION_IDLE_WARN_MS
): boolean {
return nowMs - lastActivityMs >= idleWarnMs;
}
8 changes: 8 additions & 0 deletions frontend/src/lib/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { useAuthStore } from '@/stores/authStore';

/** Notify session idle tracking that authenticated API traffic occurred. */
function notifyApiActivity(): void {
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('automl:api-activity'));
}
}


const BASE_URL = (import.meta.env.VITE_API_BASE ?? 'http://localhost:4000/api').replace(/\/$/, '');

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
Expand Down