From fa83cf47c9849e231cd7d57e515072f1a8cf2df8 Mon Sep 17 00:00:00 2001 From: Ridwan Sanusi Date: Wed, 10 Jun 2026 17:36:11 -0400 Subject: [PATCH 1/2] a11y(2.2.1): warn before session expiry and offer extend affordance Silent 401-on-refresh-token-expiry hard-redirected to login, destroying in-flight form state and giving users no warning or extension opportunity. Add JWT exp decoding to schedule a 60s warning modal (alertdialog), a Stay-signed-in button that calls refreshTokens(), and replace handleError's hard redirect on 401 with the modal so form state is preserved. Co-Authored-By: Claude Sonnet 4.6 --- .../components/session-warning-modal.svelte | 115 ++++++++++++++++++ src/lib/i18n/locales/en/common.ts | 9 ++ src/lib/stores/auth-user.ts | 34 ++++++ src/lib/stores/session-warning.ts | 13 ++ src/lib/utilities/handle-error.ts | 6 +- src/routes/(app)/+layout.svelte | 2 + 6 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/session-warning-modal.svelte create mode 100644 src/lib/stores/session-warning.ts diff --git a/src/lib/components/session-warning-modal.svelte b/src/lib/components/session-warning-modal.svelte new file mode 100644 index 0000000000..07b90b42b1 --- /dev/null +++ b/src/lib/components/session-warning-modal.svelte @@ -0,0 +1,115 @@ + + + +
+

+ {isExpired + ? translate('common.session-expired-title') + : translate('common.session-warning-title')} +

+
+
+

+ {isExpired + ? translate('common.session-expired-body') + : translate('common.session-warning-body', { seconds: countdown })} +

+ {#if isWarning} +

+ {translate('common.session-warning-body', { seconds: countdown })} +

+ {/if} +
+
+ {#if isExpired} + + {:else} + + + {/if} +
+
diff --git a/src/lib/i18n/locales/en/common.ts b/src/lib/i18n/locales/en/common.ts index 6c89619dac..95968a0ea7 100644 --- a/src/lib/i18n/locales/en/common.ts +++ b/src/lib/i18n/locales/en/common.ts @@ -227,4 +227,13 @@ export const Strings = { 'change-log': 'Change Log', comfortable: 'Comfortable', dense: 'Dense', + 'session-warning-title': 'Your session is about to expire', + 'session-warning-body': + 'Your session will expire in {{seconds}} seconds. Stay signed in to keep working.', + 'session-warning-extend': 'Stay signed in', + 'session-warning-sign-out': 'Sign out', + 'session-expired-title': 'Your session has expired', + 'session-expired-body': + 'Your session has expired. Sign in again to continue.', + 'session-expired-sign-in-again': 'Sign in again', } as const; diff --git a/src/lib/stores/auth-user.ts b/src/lib/stores/auth-user.ts index a158ebb029..58087a94ef 100644 --- a/src/lib/stores/auth-user.ts +++ b/src/lib/stores/auth-user.ts @@ -1,12 +1,38 @@ import { get } from 'svelte/store'; import { persistStore } from '$lib/stores/persist-store'; +import { + dismissSessionWarning, + sessionWarningState, +} from '$lib/stores/session-warning'; import type { User } from '$lib/types/global'; export const authUser = persistStore('AuthUser', {}); export const getAuthUser = (): User => get(authUser); +const SESSION_WARNING_LEAD_SECS = 60; +let warningTimer: ReturnType | null = null; + +function decodeJwtExp(token: string): number | null { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + return typeof payload.exp === 'number' ? payload.exp : null; + } catch { + return null; + } +} + +function scheduleSessionWarning(exp: number) { + if (warningTimer) clearTimeout(warningTimer); + const now = Math.floor(Date.now() / 1000); + const delay = (exp - now - SESSION_WARNING_LEAD_SECS) * 1000; + if (delay <= 0) return; + warningTimer = setTimeout(() => { + sessionWarningState.set('warning'); + }, delay); +} + export const setAuthUser = (user: User) => { const { accessToken, idToken, name, email, picture } = user; @@ -21,9 +47,17 @@ export const setAuthUser = (user: User) => { email, picture, }); + + dismissSessionWarning(); + const exp = decodeJwtExp(accessToken); + if (exp) scheduleSessionWarning(exp); }; export const clearAuthUser = () => { + if (warningTimer) { + clearTimeout(warningTimer); + warningTimer = null; + } authUser.set({}); }; diff --git a/src/lib/stores/session-warning.ts b/src/lib/stores/session-warning.ts new file mode 100644 index 0000000000..f96f743bb1 --- /dev/null +++ b/src/lib/stores/session-warning.ts @@ -0,0 +1,13 @@ +import { writable } from 'svelte/store'; + +export type SessionWarningState = 'idle' | 'warning' | 'expired'; + +export const sessionWarningState = writable('idle'); + +export const triggerSessionExpired = () => { + sessionWarningState.set('expired'); +}; + +export const dismissSessionWarning = () => { + sessionWarningState.set('idle'); +}; diff --git a/src/lib/utilities/handle-error.ts b/src/lib/utilities/handle-error.ts index 26a1c369f3..0285e2b209 100644 --- a/src/lib/utilities/handle-error.ts +++ b/src/lib/utilities/handle-error.ts @@ -1,6 +1,7 @@ import { BROWSER } from 'esm-env'; import { networkError } from '$lib/stores/error'; +import { triggerSessionExpired } from '$lib/stores/session-warning'; import { toaster } from '$lib/stores/toaster'; import type { NetworkError } from '$lib/types/global'; @@ -34,7 +35,8 @@ export const handleError = ( } if (isUnauthorized(error) && isBrowser) { - window.location.assign(routeForLoginPage()); + triggerSessionExpired(); + return; } if (isForbidden(error) && isBrowser) { @@ -58,7 +60,7 @@ export const handleUnauthorizedOrForbiddenError = ( isBrowser = BROWSER, ): void => { if (isUnauthorized(error) && isBrowser) { - window.location.assign(routeForLoginPage()); + triggerSessionExpired(); return; } diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 96f07b94c2..4e192a9795 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -6,6 +6,7 @@ import BottomNavigation from '$lib/components/bottom-nav.svelte'; import DataEncoderSettings from '$lib/components/data-encoder-settings.svelte'; + import SessionWarningModal from '$lib/components/session-warning-modal.svelte'; import NamespacePicker from '$lib/components/namespace-picker.svelte'; import SideNavigation from '$lib/components/side-nav.svelte'; import SkipNavigation from '$lib/components/skip-nav.svelte'; @@ -304,6 +305,7 @@ +
Date: Thu, 11 Jun 2026 11:59:04 -0400 Subject: [PATCH 2/2] fix: import order and update 401 unit tests for session-warning behavior Co-Authored-By: Claude Sonnet 4.6 --- .../components/session-warning-modal.svelte | 2 +- src/lib/utilities/handle-error.test.ts | 22 ++++++++++++++----- src/routes/(app)/+layout.svelte | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/lib/components/session-warning-modal.svelte b/src/lib/components/session-warning-modal.svelte index 07b90b42b1..815d9f242a 100644 --- a/src/lib/components/session-warning-modal.svelte +++ b/src/lib/components/session-warning-modal.svelte @@ -3,12 +3,12 @@ import Button from '$lib/holocene/button.svelte'; import { translate } from '$lib/i18n/translate'; + import { logout } from '$lib/stores/auth-user'; import { dismissSessionWarning, sessionWarningState, } from '$lib/stores/session-warning'; import { refreshTokens } from '$lib/utilities/auth-refresh'; - import { logout } from '$lib/stores/auth-user'; const WARNING_SECONDS = 60; diff --git a/src/lib/utilities/handle-error.test.ts b/src/lib/utilities/handle-error.test.ts index 6b76890453..8fefbee14c 100644 --- a/src/lib/utilities/handle-error.test.ts +++ b/src/lib/utilities/handle-error.test.ts @@ -1,5 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('$lib/stores/session-warning', () => ({ + triggerSessionExpired: vi.fn(), + dismissSessionWarning: vi.fn(), + sessionWarningState: { subscribe: vi.fn(), set: vi.fn() }, +})); + +import { triggerSessionExpired } from '$lib/stores/session-warning'; + import { handleError } from './handle-error'; import { routeForLoginPage } from './route-for'; @@ -20,26 +28,28 @@ describe('handleError', () => { vi.clearAllMocks(); }); - it('should redirect if it is an unauthorized error with status', () => { + it('should trigger session expired if it is an unauthorized error with status', () => { const error = { status: 401, statusText: 'Unauthorized', response: null as unknown as Response, }; - expect(() => handleError(error)).toThrowError(); - expect(window.location.assign).toHaveBeenCalledWith(routeForLoginPage()); + handleError(error); + expect(triggerSessionExpired).toHaveBeenCalled(); + expect(window.location.assign).not.toHaveBeenCalled(); }); - it('should redirect if it is an unauthorized error with statusCode', () => { + it('should trigger session expired if it is an unauthorized error with statusCode', () => { const error = { statusCode: 401, statusText: 'Unauthorized', response: null as unknown as Response, }; - expect(() => handleError(error)).toThrowError(); - expect(window.location.assign).toHaveBeenCalledWith(routeForLoginPage()); + handleError(error); + expect(triggerSessionExpired).toHaveBeenCalled(); + expect(window.location.assign).not.toHaveBeenCalled(); }); it('should redirect if it is a forbidden error with status', () => { diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 4e192a9795..a66ffa4d64 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -6,8 +6,8 @@ import BottomNavigation from '$lib/components/bottom-nav.svelte'; import DataEncoderSettings from '$lib/components/data-encoder-settings.svelte'; - import SessionWarningModal from '$lib/components/session-warning-modal.svelte'; import NamespacePicker from '$lib/components/namespace-picker.svelte'; + import SessionWarningModal from '$lib/components/session-warning-modal.svelte'; import SideNavigation from '$lib/components/side-nav.svelte'; import SkipNavigation from '$lib/components/skip-nav.svelte'; import TopNavigation from '$lib/components/top-nav.svelte';