fix: auto-refresh access token on 401 in API client

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 21:12:50 +02:00
parent 62c4e742ab
commit 8a04525dfc
+56
View File
@@ -1,5 +1,37 @@
const BASE_URL = '/api/v1'
// Läuft ein Refresh bereits? Damit parallele Requests nicht mehrfach refreshen
let _refreshing: Promise<string | null> | null = null
async function refreshAccessToken(): Promise<string | null> {
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<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem('access_token')
const headers: Record<string, string> = {
@@ -10,6 +42,30 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
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'