feat(web): improve token refresh handling with automatic 401 retry and focus-based refresh

This commit is contained in:
weishu
2025-12-22 18:29:32 +08:00
parent 439778c875
commit 0c16b7b215
2 changed files with 140 additions and 36 deletions
+34 -3
View File
@@ -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
View File
@@ -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 }
}