fix: redirect expired sessions to login

Invalidate authentication on protected 401 responses and JWT expiration, redirect to the login route, and explain that the session is no longer active.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-20 07:16:38 +02:00
parent c050815900
commit 0d8a25e73f
6 changed files with 147 additions and 8 deletions

View file

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api, authStorage } from './client'
import { api, authStorage, SESSION_EXPIRED_EVENT } from './client'
describe('API client', () => {
beforeEach(() => {
@ -11,6 +11,7 @@ describe('API client', () => {
token: 'signed-jwt',
user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' },
})
const fetchMock = vi.fn().mockResolvedValue(new Response('[]', { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
@ -24,6 +25,30 @@ describe('API client', () => {
)
})
it('invalidates the session when a protected request is unauthorized', async () => {
authStorage.save({
token: 'expired-jwt',
user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' },
})
const expired = vi.fn()
window.addEventListener(SESSION_EXPIRED_EVENT, expired, { once: true })
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: 'session expired' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
}),
),
)
await expect(api.jobs.list()).rejects.toMatchObject({ status: 401 })
expect(authStorage.token()).toBeNull()
expect(authStorage.user()).toBeNull()
expect(expired).toHaveBeenCalledOnce()
})
it('uses the backend participant multipart field names', async () => {
authStorage.save({
token: 'signed-jwt',

View file

@ -20,6 +20,7 @@ import type {
const TOKEN_KEY = 'go-guess-token'
const USER_KEY = 'go-guess-user'
export const SESSION_EXPIRED_EVENT = 'go-guess:session-expired'
export class ApiError extends Error {
constructor(
@ -45,6 +46,23 @@ export const authStorage = {
sessionStorage.removeItem(TOKEN_KEY)
sessionStorage.removeItem(USER_KEY)
},
expiresAt: () => {
try {
const payload = sessionStorage.getItem(TOKEN_KEY)?.split('.')[1]
if (!payload) return null
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const claims = JSON.parse(atob(padded)) as { exp?: unknown }
return typeof claims.exp === 'number' ? claims.exp * 1000 : null
} catch {
return null
}
},
}
export function invalidateSession() {
authStorage.clear()
window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT))
}
async function request<T>(
@ -78,7 +96,7 @@ async function request<T>(
} catch {
details = undefined
}
if (response.status === 401 && protectedRequest) authStorage.clear()
if (response.status === 401 && protectedRequest) invalidateSession()
throw new ApiError(message, response.status, details)
}
if (response.status === 204) return undefined as T
@ -93,8 +111,10 @@ async function requestBlob(path: string): Promise<Blob> {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
if (!response.ok)
if (!response.ok) {
if (response.status === 401) invalidateSession()
throw new ApiError(`Request failed with status ${response.status}`, response.status)
}
return response.blob()
}
@ -115,8 +135,10 @@ async function subscribeToInterviewEvents(
},
signal,
})
if (!response.ok || !response.body)
if (!response.ok || !response.body) {
if (response.status === 401 && protectedRequest) invalidateSession()
throw new ApiError(`Live updates failed with status ${response.status}`, response.status)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''