refactor(frontend): lint, one session guard, one request helper, one user context

This commit is contained in:
lobo committed 2026-09-29 15:40:55 +02:00
1 parent 111ab1e200
commit d0ea9e900d
78 files changed
+2138 -624

No files matched your search

+34 -7
View File
@@ -55,14 +55,41 @@ export async function rotatingFetch(url, init = {}) {
}
/**
* fetch for the logged-in user. A 401 renews the access cookie once and sends the request again. When that fails,
* the 401 goes back to the caller. Ending the session is the caller's job (useSessionExpired), so the user sees
* why and comes back to the same page.
* Fires `expired` when the session is over. RequireSession ends it, so the user sees why and comes back to the same
* page; a public page does not listen.
*/
export const sessionEvents = new EventTarget();
/**
* fetch for the logged-in user. A 401 renews the access cookie once and sends the request again. A 401 after that
* goes back to the caller and fires `expired`; the caller only stops.
*/
export async function apiFetch(url, init = {}) {
const response = await clientFetch(url, init);
if (response.status !== 401 || !(await refreshSession())) {
return response;
let response = await clientFetch(url, init);
if (response.status === 401) {
if (await refreshSession()) response = await clientFetch(url, init);
if (response.status === 401) sessionEvents.dispatchEvent(new Event('expired'));
}
return clientFetch(url, init);
return response;
}
/**
* apiFetch that never throws: `{ok, status, data}` with the JSON body or null. A network error is status 0 and is
* logged, an abort only returns.
*/
export async function request(url, init = {}) {
let response;
try {
response = await apiFetch(url, init);
} catch (err) {
if (!init.signal?.aborted) console.error(`Request to ${url} failed:`, err);
return {ok: false, status: 0, data: null};
}
let data = null;
try {
data = await response.json();
} catch {
// no JSON body, e.g. a 204
}
return {ok: response.ok, status: response.status, data};
}
+79 -2
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {apiFetch, clientFetch, logout} from './api.js';
import {apiFetch, clientFetch, logout, request, sessionEvents} from './api.js';
const response = (status) => ({ok: status >= 200 && status < 300, status, json: async () => ({})});
const isRefresh = (url) => url.endsWith('/api/auth/refresh');
@@ -59,7 +59,7 @@ describe('apiFetch', () => {
expect(refreshCalls()).toHaveLength(1);
});
it('returns the 401 when the refresh fails, without a loop, and leaves ending the session to the caller', async () => {
it('returns the 401 when the refresh fails, without a loop', async () => {
globalThis.fetch = vi.fn(async () => response(401));
const result = await apiFetch('/api/x');
@@ -126,6 +126,83 @@ describe('apiFetch', () => {
});
});
describe('the expired event', () => {
let expired;
const count = () => expired.mock.calls.length;
beforeEach(() => {
expired = vi.fn();
sessionEvents.addEventListener('expired', expired);
});
afterEach(() => {
sessionEvents.removeEventListener('expired', expired);
});
it('fires once when the refresh says the session is over', async () => {
globalThis.fetch = vi.fn(async () => response(401));
await apiFetch('/api/x');
expect(count()).toBe(1);
});
it('fires when the request after a good refresh still gets a 401', async () => {
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 204 : 401));
await apiFetch('/api/x');
expect(count()).toBe(1);
});
it('does not fire when the refresh has no network, or for clientFetch', async () => {
globalThis.fetch = vi.fn(async (url) => {
if (isRefresh(url)) throw new TypeError('Failed to fetch');
return response(401);
});
await expect(apiFetch('/api/x')).rejects.toThrow();
await clientFetch('/api/x');
expect(count()).toBe(0);
});
});
describe('request', () => {
it('gives the status and the JSON body', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({a: 1})}));
expect(await request('/api/x')).toEqual({ok: true, status: 200, data: {a: 1}});
});
it('gives null data for an answer without a JSON body', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 204, json: async () => { throw new SyntaxError('empty'); }}));
expect(await request('/api/x')).toEqual({ok: true, status: 204, data: null});
});
it('gives the error body of a refused request', async () => {
globalThis.fetch = vi.fn(async () => ({ok: false, status: 409, json: async () => ({code: 'CONFLICT'})}));
expect(await request('/api/x', {method: 'PUT'})).toEqual({ok: false, status: 409, data: {code: 'CONFLICT'}});
});
it('gives status 0 on a network error and logs it, but not an abort', async () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
globalThis.fetch = vi.fn(async () => { throw new TypeError('Failed to fetch'); });
expect(await request('/api/x')).toEqual({ok: false, status: 0, data: null});
expect(log).toHaveBeenCalledTimes(1);
const controller = new AbortController();
controller.abort();
globalThis.fetch = vi.fn(async () => { throw new DOMException('aborted', 'AbortError'); });
expect(await request('/api/x', {signal: controller.signal})).toEqual({ok: false, status: 0, data: null});
expect(log).toHaveBeenCalledTimes(1);
log.mockRestore();
});
});
describe('logout', () => {
it('posts to Auth and ignores a network error', async () => {
globalThis.fetch = vi.fn(async () => {
+1 -1
View File
@@ -1,6 +1,6 @@
// The tokens live in HttpOnly cookies that no script can read. This flag only remembers that a login happened; a
// stale flag costs one 401 and a failed refresh, then the login page opens.
const SESSION_KEY = 'tbp_session';
export const SESSION_KEY = 'tbp_session';
// Where the token lived before the cookies. Removed on the next login or logout.
const LEGACY_TOKEN_KEY = 'tbp_jwt';
+1 -1
View File
@@ -45,7 +45,7 @@ const COLUMNS = {
* @return {rows} with title, platform, playtimeMinutes and status where the file has them, or {error} as an i18n key
*/
export const parseCsv = (text) => {
const clean = text.replace(/^/, '');
const clean = text.replace(/^\uFEFF/, '');
const firstLine = clean.split(/\r?\n/, 1)[0];
const delimiter = [',', ';', '\t'].reduce((best, next) =>
firstLine.split(next).length > firstLine.split(best).length ? next : best);
-12
View File
@@ -30,18 +30,6 @@ export const preselectPlatform = (own, platforms, favorite) => {
// A Steam game has no stored platform.
export const platformLabel = (game) => (game.source === 'MANUAL' ? game.platform : 'Steam');
/**
* The user's favorite platform from Auth; null when none is set or the load failed, so a caller can go on.
*/
export const loadFavoritePlatform = async () => {
try {
const response = await apiFetch(`${AUTH_URL()}/api/users/me?v=1`);
return response.ok ? (await response.json()).settings?.favoritePlatform ?? null : null;
} catch {
return null;
}
};
/**
* PUT /api/users/settings/favorite-platform; null clears it. The caller handles the response status.
*/
+2 -1
View File
@@ -1,5 +1,6 @@
import {apiFetch} from './api.js';
import {ACCENTS, CORNERS, DEFAULT_APPEARANCE, appid} from './appearance.js';
import {fetchMe} from './settings.js';
export const DEFAULT_PROFILE_STYLE = {
accent: DEFAULT_APPEARANCE.accent, corners: DEFAULT_APPEARANCE.corners, bannerAppid: null, backgroundAppid: null,
@@ -16,7 +17,7 @@ export const normalizeProfileStyle = (value) => ({
* The stored style from GET /api/users/me; null when there is no answer to use.
*/
export const loadProfileStyle = async () => {
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`);
const response = await fetchMe();
if (!response.ok) return null;
const data = await response.json();
return normalizeProfileStyle(data.settings?.profileStyle);
+3 -1
View File
@@ -22,10 +22,12 @@ export const cacheLanguage = (language) => {
}
};
export const ME_URL = () => `${import.meta.env.VITE_API_URL}/api/users/me?v=1`;
/**
* GET /api/users/me on Auth. The caller handles the response status.
*/
export const fetchMe = () => apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
export const fetchMe = () => apiFetch(ME_URL(), {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});