spa: silent refresh on 401, persistent login
This commit is contained in:
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user