spa: silent refresh on 401, persistent login
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { useAuth } from "@/components/auth-provider"
|
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 type { LoginRequest, RegisterRequest } from "@/lib/api/auth"
|
||||||
|
import { getRefreshToken } from "@/lib/auth"
|
||||||
|
|
||||||
export function useLogin() {
|
export function useLogin() {
|
||||||
const { login: setAuth } = useAuth()
|
const { login: setAuth } = useAuth()
|
||||||
@@ -11,6 +12,7 @@ export function useLogin() {
|
|||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
setAuth({
|
setAuth({
|
||||||
token: res.token,
|
token: res.token,
|
||||||
|
refresh_token: res.refresh_token,
|
||||||
user_id: res.user_id,
|
user_id: res.user_id,
|
||||||
email: res.email,
|
email: res.email,
|
||||||
role: res.role,
|
role: res.role,
|
||||||
@@ -32,6 +34,12 @@ export function useLogout() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
const rt = getRefreshToken()
|
||||||
|
if (rt) {
|
||||||
|
try {
|
||||||
|
await apiLogout(rt)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
logout()
|
logout()
|
||||||
qc.clear()
|
qc.clear()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { post } from "./client"
|
import { API_URL, post } from "./client"
|
||||||
|
|
||||||
export const loginRequestSchema = z.object({
|
export const loginRequestSchema = z.object({
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
@@ -9,6 +9,7 @@ export type LoginRequest = z.infer<typeof loginRequestSchema>
|
|||||||
|
|
||||||
export const loginResponseSchema = z.object({
|
export const loginResponseSchema = z.object({
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
|
refresh_token: z.string(),
|
||||||
user_id: z.string().uuid(),
|
user_id: z.string().uuid(),
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
role: z.string(),
|
role: z.string(),
|
||||||
@@ -30,3 +31,25 @@ export function login(data: LoginRequest) {
|
|||||||
export function register(data: RegisterRequest) {
|
export function register(data: RegisterRequest) {
|
||||||
return post("/auth/register", data)
|
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 ?? ""
|
export const API_URL = import.meta.env.VITE_API_URL ?? ""
|
||||||
|
|
||||||
@@ -42,6 +42,31 @@ function buildUrl(
|
|||||||
return qs ? `${base}?${qs}` : base
|
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>(
|
async function request<T = void>(
|
||||||
url: string,
|
url: string,
|
||||||
options?: RequestInit,
|
options?: RequestInit,
|
||||||
@@ -55,8 +80,36 @@ async function request<T = void>(
|
|||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
clearAuth()
|
if (url.includes("/auth/refresh") || url.includes("/auth/login")) {
|
||||||
window.location.href = "/app/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())
|
throw new ApiError(res.status, await res.text())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const AUTH_KEY = "auth_state"
|
|||||||
|
|
||||||
export type AuthState = {
|
export type AuthState = {
|
||||||
token: string
|
token: string
|
||||||
|
refresh_token: string
|
||||||
user_id: string
|
user_id: string
|
||||||
email: string
|
email: string
|
||||||
role: string
|
role: string
|
||||||
@@ -30,6 +31,10 @@ export function getToken(): string | null {
|
|||||||
return getAuth()?.token ?? null
|
return getAuth()?.token ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken(): string | null {
|
||||||
|
return getAuth()?.refresh_token ?? null
|
||||||
|
}
|
||||||
|
|
||||||
export function isAdmin(): boolean {
|
export function isAdmin(): boolean {
|
||||||
return getAuth()?.role === "admin"
|
return getAuth()?.role === "admin"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user