Public Access
refactor(frontend): lint, one session guard, one request helper, one user context
This commit is contained in:
1 parent
111ab1e200
commit
d0ea9e900d
78 files changed
+2138
-624
No files matched your search
+34
-7
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user