Skip to content
Open
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
115 changes: 115 additions & 0 deletions src/lib/components/session-warning-modal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<script lang="ts">
import { onDestroy } from 'svelte';

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';

const WARNING_SECONDS = 60;

let countdown = WARNING_SECONDS;
let extending = false;
let dialogEl: HTMLDialogElement;
let countdownInterval: ReturnType<typeof setInterval> | null = null;

$: isWarning = $sessionWarningState === 'warning';
$: isExpired = $sessionWarningState === 'expired';
$: open = isWarning || isExpired;

$: if (open && dialogEl) {
dialogEl.showModal();
} else if (!open && dialogEl) {
dialogEl.close();
}

$: if (isWarning) {
countdown = WARNING_SECONDS;
startCountdown();
} else {
stopCountdown();
}

function startCountdown() {
stopCountdown();
countdownInterval = setInterval(() => {
countdown -= 1;
if (countdown <= 0) {
stopCountdown();
sessionWarningState.set('expired');
}
}, 1000);
}

function stopCountdown() {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
}

async function handleExtend() {
extending = true;
const refreshed = await refreshTokens();
extending = false;
if (refreshed) {
dismissSessionWarning();
} else {
sessionWarningState.set('expired');
}
}

async function handleSignOut() {
await logout();
}

onDestroy(stopCountdown);
</script>

<dialog
bind:this={dialogEl}
role="alertdialog"
aria-modal="true"
aria-labelledby="session-warning-title"
aria-describedby="session-warning-body"
data-testid="session-warning-modal"
class="surface-primary z-50 w-full max-w-lg overflow-y-auto border border-secondary p-0 text-primary shadow-xl backdrop:bg-black/50"
>
<div class="px-8 pb-0 pt-8 text-2xl">
<h2 id="session-warning-title">
{isExpired
? translate('common.session-expired-title')
: translate('common.session-warning-title')}
</h2>
</div>
<div class="whitespace-normal p-8">
<p id="session-warning-body">
{isExpired
? translate('common.session-expired-body')
: translate('common.session-warning-body', { seconds: countdown })}
</p>
{#if isWarning}
<p aria-live="polite" aria-atomic="true" class="sr-only">
{translate('common.session-warning-body', { seconds: countdown })}
</p>
{/if}
</div>
<div class="flex items-center justify-end gap-2 p-6">
{#if isExpired}
<Button variant="primary" on:click={handleSignOut}>
{translate('common.session-expired-sign-in-again')}
</Button>
{:else}
<Button variant="ghost" on:click={handleSignOut}>
{translate('common.session-warning-sign-out')}
</Button>
<Button variant="primary" loading={extending} on:click={handleExtend}>
{translate('common.session-warning-extend')}
</Button>
{/if}
</div>
</dialog>
9 changes: 9 additions & 0 deletions src/lib/i18n/locales/en/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
34 changes: 34 additions & 0 deletions src/lib/stores/auth-user.ts
Original file line number Diff line number Diff line change
@@ -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<User>('AuthUser', {});

export const getAuthUser = (): User => get(authUser);

const SESSION_WARNING_LEAD_SECS = 60;
let warningTimer: ReturnType<typeof setTimeout> | 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;

Expand All @@ -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({});
};

Expand Down
13 changes: 13 additions & 0 deletions src/lib/stores/session-warning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { writable } from 'svelte/store';

export type SessionWarningState = 'idle' | 'warning' | 'expired';

export const sessionWarningState = writable<SessionWarningState>('idle');

export const triggerSessionExpired = () => {
sessionWarningState.set('expired');
};

export const dismissSessionWarning = () => {
sessionWarningState.set('idle');
};
22 changes: 16 additions & 6 deletions src/lib/utilities/handle-error.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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', () => {
Expand Down
6 changes: 4 additions & 2 deletions src/lib/utilities/handle-error.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -34,7 +35,8 @@ export const handleError = (
}

if (isUnauthorized(error) && isBrowser) {
window.location.assign(routeForLoginPage());
triggerSessionExpired();
return;
}

if (isForbidden(error) && isBrowser) {
Expand All @@ -58,7 +60,7 @@ export const handleUnauthorizedOrForbiddenError = (
isBrowser = BROWSER,
): void => {
if (isUnauthorized(error) && isBrowser) {
window.location.assign(routeForLoginPage());
triggerSessionExpired();
return;
}

Expand Down
2 changes: 2 additions & 0 deletions src/routes/(app)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import BottomNavigation from '$lib/components/bottom-nav.svelte';
import DataEncoderSettings from '$lib/components/data-encoder-settings.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';
Expand Down Expand Up @@ -304,6 +305,7 @@

<DarkMode />
<SkipNavigation />
<SessionWarningModal />

<div class="flex h-dvh w-screen flex-row">
<Toaster
Expand Down
Loading