mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): improve token refresh handling with automatic 401 retry and focus-based refresh
This commit is contained in:
+34
-3
@@ -10,16 +10,36 @@ import type {
|
||||
SessionsResponse
|
||||
} from '@/types/api'
|
||||
|
||||
type ApiClientOptions = {
|
||||
getToken?: () => string | null
|
||||
onUnauthorized?: () => Promise<string | null>
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private token: string
|
||||
private readonly getToken: (() => string | null) | null
|
||||
private readonly onUnauthorized: (() => Promise<string | null>) | null
|
||||
|
||||
constructor(token: string) {
|
||||
constructor(token: string, options?: ApiClientOptions) {
|
||||
this.token = token
|
||||
this.getToken = options?.getToken ?? null
|
||||
this.onUnauthorized = options?.onUnauthorized ?? null
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
private async request<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
attempt: number = 0,
|
||||
overrideToken?: string | null
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set('authorization', `Bearer ${this.token}`)
|
||||
const liveToken = this.getToken ? this.getToken() : null
|
||||
const authToken = overrideToken !== undefined
|
||||
? (overrideToken ?? (liveToken ?? this.token))
|
||||
: (liveToken ?? this.token)
|
||||
if (authToken) {
|
||||
headers.set('authorization', `Bearer ${authToken}`)
|
||||
}
|
||||
if (init?.body !== undefined && !headers.has('content-type')) {
|
||||
headers.set('content-type', 'application/json')
|
||||
}
|
||||
@@ -29,6 +49,17 @@ export class ApiClient {
|
||||
headers
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
if (attempt === 0 && this.onUnauthorized) {
|
||||
const refreshed = await this.onUnauthorized()
|
||||
if (refreshed) {
|
||||
this.token = refreshed
|
||||
return await this.request<T>(path, init, attempt + 1, refreshed)
|
||||
}
|
||||
}
|
||||
throw new Error('Session expired. Please sign in again.')
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}: ${body}`)
|
||||
|
||||
+106
-33
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ApiClient } from '@/api/client'
|
||||
import type { AuthResponse } from '@/types/api'
|
||||
|
||||
@@ -44,13 +44,87 @@ export function useAuth(authSource: AuthSource | null): {
|
||||
const [user, setUser] = useState<AuthResponse['user'] | null>(null)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const refreshInFlightRef = useRef<boolean>(false)
|
||||
|
||||
const api = useMemo(() => (token ? new ApiClient(token) : null), [token])
|
||||
const refreshPromiseRef = useRef<Promise<string | null> | null>(null)
|
||||
const tokenRef = useRef<string | null>(null)
|
||||
const lastRefreshAttemptRef = useRef<number>(0)
|
||||
|
||||
// Stable reference for auth source to use in effects
|
||||
const authSourceRef = useRef(authSource)
|
||||
authSourceRef.current = authSource
|
||||
tokenRef.current = token
|
||||
|
||||
const refreshAuth = useCallback(async (options?: {
|
||||
minTtlMs?: number
|
||||
hardFail?: boolean
|
||||
force?: boolean
|
||||
}): Promise<string | null> => {
|
||||
const currentSource = authSourceRef.current
|
||||
const currentToken = tokenRef.current
|
||||
if (!currentSource) {
|
||||
return null
|
||||
}
|
||||
|
||||
const expMs = currentToken ? decodeJwtExpMs(currentToken) : null
|
||||
const minTtlMs = options?.minTtlMs ?? 0
|
||||
const now = Date.now()
|
||||
const ttlMs = expMs ? expMs - now : null
|
||||
const needsRefreshForTtl = ttlMs !== null && ttlMs <= minTtlMs
|
||||
if (!options?.force && ttlMs !== null && ttlMs > minTtlMs) {
|
||||
return currentToken
|
||||
}
|
||||
if (!options?.force && !needsRefreshForTtl && now - lastRefreshAttemptRef.current < 15_000) {
|
||||
return currentToken
|
||||
}
|
||||
if (refreshPromiseRef.current) {
|
||||
return await refreshPromiseRef.current
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
lastRefreshAttemptRef.current = now
|
||||
|
||||
try {
|
||||
const client = new ApiClient('')
|
||||
const auth = await client.authenticate(getAuthPayload(currentSource))
|
||||
tokenRef.current = auth.token
|
||||
setToken(auth.token)
|
||||
setUser(auth.user)
|
||||
setError(null)
|
||||
return auth.token
|
||||
} catch {
|
||||
const isExpired = expMs ? Date.now() >= expMs : false
|
||||
if (options?.hardFail || isExpired) {
|
||||
tokenRef.current = null
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
const msg = currentSource.type === 'telegram'
|
||||
? 'Session expired. Reopen the Mini App from Telegram.'
|
||||
: 'Session expired. Please login again.'
|
||||
setError(msg)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const refreshPromise = run()
|
||||
refreshPromiseRef.current = refreshPromise
|
||||
|
||||
try {
|
||||
return await refreshPromise
|
||||
} finally {
|
||||
if (refreshPromiseRef.current === refreshPromise) {
|
||||
refreshPromiseRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const api = useMemo(() => (
|
||||
token
|
||||
? new ApiClient(token, {
|
||||
getToken: () => tokenRef.current,
|
||||
onUnauthorized: () => refreshAuth({ force: true })
|
||||
})
|
||||
: null
|
||||
), [refreshAuth, token])
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
@@ -108,35 +182,10 @@ export function useAuth(authSource: AuthSource | null): {
|
||||
|
||||
const refresh = async () => {
|
||||
if (isCancelled) return
|
||||
if (refreshInFlightRef.current) return
|
||||
refreshInFlightRef.current = true
|
||||
|
||||
const currentSource = authSourceRef.current
|
||||
if (!currentSource) {
|
||||
refreshInFlightRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new ApiClient('')
|
||||
const auth = await client.authenticate(getAuthPayload(currentSource))
|
||||
if (isCancelled) return
|
||||
setToken(auth.token)
|
||||
setUser(auth.user)
|
||||
} catch {
|
||||
if (isCancelled) return
|
||||
if (Date.now() >= expMs) {
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
const msg = currentSource.type === 'telegram'
|
||||
? 'Session expired. Reopen the Mini App from Telegram.'
|
||||
: 'Session expired. Please login again.'
|
||||
setError(msg)
|
||||
return
|
||||
}
|
||||
const refreshed = await refreshAuth({ force: true })
|
||||
if (isCancelled) return
|
||||
if (!refreshed && Date.now() < expMs) {
|
||||
schedule(15_000)
|
||||
} finally {
|
||||
refreshInFlightRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +197,31 @@ export function useAuth(authSource: AuthSource | null): {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [authSource, token])
|
||||
}, [authSource, refreshAuth, token])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSource) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleActive = () => {
|
||||
void refreshAuth({ minTtlMs: 60_000 })
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
handleActive()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('focus', handleActive)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleActive)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [authSource, refreshAuth])
|
||||
|
||||
return { token, user, api, isLoading, error }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user