From 8a04525dfcca625e000f3c845b2fb03a66362494 Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 24 May 2026 21:12:50 +0200 Subject: [PATCH] fix: auto-refresh access token on 401 in API client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neuer refreshAccessToken()-Helper: POST /auth/refresh → neuer access_token - Bei 401-Response: Token refreshen, Request automatisch wiederholen - Parallele Requests: nur ein Refresh gleichzeitig (_refreshing-Promise) - Refresh fehlgeschlagen → localStorage löschen + Redirect zu /login - Gilt für alle API-Aufrufe (Desktop + Mobile) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/api/client.ts | 56 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e5fc48a..679eb84 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,37 @@ const BASE_URL = '/api/v1' +// Läuft ein Refresh bereits? Damit parallele Requests nicht mehrfach refreshen +let _refreshing: Promise | null = null + +async function refreshAccessToken(): Promise { + const refreshToken = localStorage.getItem('refresh_token') + if (!refreshToken) return null + + try { + const res = await fetch(`${BASE_URL}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }), + }) + if (!res.ok) { + // Refresh fehlgeschlagen → ausloggen + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + window.location.href = '/login' + return null + } + const data = await res.json() + localStorage.setItem('access_token', data.access_token) + if (data.refresh_token) localStorage.setItem('refresh_token', data.refresh_token) + return data.access_token + } catch { + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + window.location.href = '/login' + return null + } +} + async function request(path: string, options: RequestInit = {}): Promise { const token = localStorage.getItem('access_token') const headers: Record = { @@ -10,6 +42,30 @@ async function request(path: string, options: RequestInit = {}): Promise { const res = await fetch(`${BASE_URL}${path}`, { ...options, headers }) + // 401 → Token-Refresh versuchen, dann Request wiederholen + if (res.status === 401 && !path.startsWith('/auth/')) { + if (!_refreshing) { + _refreshing = refreshAccessToken().finally(() => { _refreshing = null }) + } + const newToken = await _refreshing + if (!newToken) return undefined as T // Redirect zu /login läuft bereits + + // Request mit neuem Token wiederholen + const retryHeaders = { ...headers, Authorization: `Bearer ${newToken}` } + const retry = await fetch(`${BASE_URL}${path}`, { ...options, headers: retryHeaders }) + if (!retry.ok) { + const err = await retry.json().catch(() => ({ detail: retry.statusText })) + const msg = typeof err.detail === 'string' + ? err.detail + : Array.isArray(err.detail) + ? err.detail.map((e: { msg: string }) => e.msg).join(', ') + : retry.statusText + throw new Error(msg) + } + if (retry.status === 204) return undefined as T + return retry.json() + } + if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })) const msg = typeof err.detail === 'string'