refactor(auth): send login token through apiFetch
Build, Test & Push Frontend / quality-check (push) Successful in 13s
Build, Test & Push Frontend / docker (push) Successful in 23s

All authenticated calls now use apiFetch, and only utils/auth.js
touches the stored token. The cookie login can then change these two
files instead of every page.
This commit is contained in:
lobo committed 2026-09-25 11:29:14 +02:00
1 parent 085fcf15a4
commit 5ff8d48c53
18 files changed
+127 -70

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
import {getToken} from './auth.js';
/**
* fetch with the login token. The caller handles the response status, 401 included.
*/
export function apiFetch(url, init = {}) {
const token = getToken();
const headers = token ? {...init.headers, 'Authorization': `Bearer ${token}`} : init.headers;
return fetch(url, {...init, headers});
}
+40
View File
@@ -0,0 +1,40 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {apiFetch} from './api.js';
describe('apiFetch', () => {
beforeEach(() => {
globalThis.fetch = vi.fn(async () => new Response(null, {status: 204}));
});
afterEach(() => {
localStorage.clear();
});
it('sends the stored token and keeps the other headers and options', async () => {
localStorage.setItem('tbp_jwt', 'the-token');
await apiFetch('/api/x', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: '{}'});
expect(fetch).toHaveBeenCalledWith('/api/x', {
method: 'PUT',
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer the-token'},
body: '{}',
});
});
it('sends no Authorization header without a token', async () => {
await apiFetch('/api/x');
expect(fetch).toHaveBeenCalledWith('/api/x', {headers: undefined});
});
it('reads the token on every call', async () => {
localStorage.setItem('tbp_jwt', 'old');
await apiFetch('/api/x');
localStorage.setItem('tbp_jwt', 'new');
await apiFetch('/api/x');
expect(fetch).toHaveBeenLastCalledWith('/api/x', {headers: {'Authorization': 'Bearer new'}});
});
});
+5 -4
View File
@@ -1,3 +1,5 @@
import {apiFetch} from './api.js';
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
export const CORNERS = ['sharp', 'default', 'round'];
@@ -49,9 +51,9 @@ export const applyAppearance = (appearance, root = document.documentElement) =>
* PUT /api/users/settings/appearance on Auth. The caller handles the response status.
*/
export const saveAppearance = (appearance) =>
fetch(`${import.meta.env.VITE_API_URL}/api/users/settings/appearance`, {
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/appearance`, {
method: 'PUT',
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'},
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(appearance),
// Saves run one after another; a hung request must not hold back the next ones.
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
@@ -61,8 +63,7 @@ export const saveAppearance = (appearance) =>
* GET /api/users/me on Auth, only for its appearance. Null when there is no answer to use.
*/
export const loadAppearance = async () => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`},
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
// Saves wait for this load.
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
+8 -2
View File
@@ -1,5 +1,11 @@
const TOKEN_KEY = 'tbp_jwt';
export const getToken = () => localStorage.getItem(TOKEN_KEY);
export const setToken = (token) => localStorage.setItem(TOKEN_KEY, token);
export const clearToken = () => localStorage.removeItem(TOKEN_KEY);
/**
* Only checks `exp`. The signature is checked by the backends.
*/
@@ -17,9 +23,9 @@ export function isTokenValid(token, nowMs = Date.now()) {
* Returns the stored token, or null. Removes an expired token.
*/
export function getValidToken() {
const token = localStorage.getItem(TOKEN_KEY);
const token = getToken();
if (token && !isTokenValid(token)) {
localStorage.removeItem(TOKEN_KEY);
clearToken();
return null;
}
return token;
+6 -4
View File
@@ -1,3 +1,5 @@
import {apiFetch} from './api.js';
export const GAME_STATUSES = ['BACKLOG', 'PLAYING', 'FINISHED', 'COMPLETED', 'DROPPED'];
export const GAME_PRIORITIES = ['HIGH', 'MEDIUM', 'LOW'];
@@ -19,9 +21,9 @@ export const toBacklogRequest = ({status, rating, notes, priority}) => ({
* @param entry a backlog entry in request form, see toBacklogRequest
*/
export const saveBacklogEntry = (appid, entry) =>
fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/backlog`, {
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/backlog`, {
method: 'PUT',
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'},
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(entry),
});
@@ -29,9 +31,9 @@ export const saveBacklogEntry = (appid, entry) =>
* PUT /api/library/games/{appid}/status. The caller handles the response status.
*/
export const saveGameStatus = (appid, status) =>
fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/status`, {
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/status`, {
method: 'PUT',
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'},
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({status}),
});