diff --git a/spa/src/hooks/use-auth.ts b/spa/src/hooks/use-auth.ts index 8bf4333..5470750 100644 --- a/spa/src/hooks/use-auth.ts +++ b/spa/src/hooks/use-auth.ts @@ -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() }, diff --git a/spa/src/lib/api/auth.ts b/spa/src/lib/api/auth.ts index cc4fa74..ec32020 100644 --- a/spa/src/lib/api/auth.ts +++ b/spa/src/lib/api/auth.ts @@ -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 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 { + 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 }) +} diff --git a/spa/src/lib/api/client.ts b/spa/src/lib/api/client.ts index 7df6e45..f0bbd29 100644 --- a/spa/src/lib/api/client.ts +++ b/spa/src/lib/api/client.ts @@ -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 | null = null + +async function tryRefresh(): Promise { + 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( url: string, options?: RequestInit, @@ -55,8 +80,36 @@ async function request( }) 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()) } diff --git a/spa/src/lib/auth.ts b/spa/src/lib/auth.ts index e3b95c0..788fe01 100644 --- a/spa/src/lib/auth.ts +++ b/spa/src/lib/auth.ts @@ -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" }