spa: silent refresh on 401, persistent login

This commit is contained in:
2026-06-11 14:39:10 +02:00
parent 822f3f9d9c
commit 96c753c2c6
4 changed files with 94 additions and 5 deletions

View File

@@ -1,7 +1,8 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useAuth } from "@/components/auth-provider"
import { login, register } from "@/lib/api/auth"
import { apiLogout, login, register } from "@/lib/api/auth"
import type { LoginRequest, RegisterRequest } from "@/lib/api/auth"
import { getRefreshToken } from "@/lib/auth"
export function useLogin() {
const { login: setAuth } = useAuth()
@@ -11,6 +12,7 @@ export function useLogin() {
onSuccess: (res) => {
setAuth({
token: res.token,
refresh_token: res.refresh_token,
user_id: res.user_id,
email: res.email,
role: res.role,
@@ -32,6 +34,12 @@ export function useLogout() {
const qc = useQueryClient()
return useMutation({
mutationFn: async () => {
const rt = getRefreshToken()
if (rt) {
try {
await apiLogout(rt)
} catch {}
}
logout()
qc.clear()
},

View File

@@ -1,5 +1,5 @@
import { z } from "zod"
import { post } from "./client"
import { API_URL, post } from "./client"
export const loginRequestSchema = z.object({
email: z.string(),
@@ -9,6 +9,7 @@ export type LoginRequest = z.infer<typeof loginRequestSchema>
export const loginResponseSchema = z.object({
token: z.string(),
refresh_token: z.string(),
user_id: z.string().uuid(),
email: z.string(),
role: z.string(),
@@ -30,3 +31,25 @@ export function login(data: LoginRequest) {
export function register(data: RegisterRequest) {
return post("/auth/register", data)
}
export type RefreshResponse = {
token: string
refresh_token: string
expires_at: string
}
export async function refreshToken(
refresh_token: string,
): Promise<RefreshResponse> {
const res = await fetch(`${API_URL}/api/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token }),
})
if (!res.ok) throw new Error("refresh failed")
return res.json()
}
export function apiLogout(refresh_token: string) {
return post("/auth/logout", { refresh_token })
}

View File

@@ -1,4 +1,4 @@
import { clearAuth, getToken } from "@/lib/auth"
import { clearAuth, getAuth, getToken, setAuth } from "@/lib/auth"
export const API_URL = import.meta.env.VITE_API_URL ?? ""
@@ -42,6 +42,31 @@ function buildUrl(
return qs ? `${base}?${qs}` : base
}
let refreshPromise: Promise<boolean> | null = null
async function tryRefresh(): Promise<boolean> {
const auth = getAuth()
if (!auth?.refresh_token) return false
try {
const res = await fetch(buildUrl("/auth/refresh"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: auth.refresh_token }),
})
if (!res.ok) return false
const data = await res.json()
setAuth({
...auth,
token: data.token,
refresh_token: data.refresh_token,
expires_at: data.expires_at,
})
return true
} catch {
return false
}
}
async function request<T = void>(
url: string,
options?: RequestInit,
@@ -55,8 +80,36 @@ async function request<T = void>(
})
if (!res.ok) {
if (res.status === 401) {
clearAuth()
window.location.href = "/app/login"
if (url.includes("/auth/refresh") || url.includes("/auth/login")) {
clearAuth()
window.location.href = "/app/login"
throw new ApiError(res.status, await res.text())
}
if (!refreshPromise) {
refreshPromise = tryRefresh().finally(() => {
refreshPromise = null
})
}
const ok = await refreshPromise
if (!ok) {
clearAuth()
window.location.href = "/app/login"
throw new ApiError(res.status, await res.text())
}
const retryRes = await fetch(url, {
...options,
headers: {
...authHeaders(),
...options?.headers,
},
})
if (!retryRes.ok) {
throw new ApiError(retryRes.status, await retryRes.text())
}
const retryText = await retryRes.text()
return retryText ? JSON.parse(retryText) : (undefined as T)
}
throw new ApiError(res.status, await res.text())
}

View File

@@ -2,6 +2,7 @@ const AUTH_KEY = "auth_state"
export type AuthState = {
token: string
refresh_token: string
user_id: string
email: string
role: string
@@ -30,6 +31,10 @@ export function getToken(): string | null {
return getAuth()?.token ?? null
}
export function getRefreshToken(): string | null {
return getAuth()?.refresh_token ?? null
}
export function isAdmin(): boolean {
return getAuth()?.role === "admin"
}