From 0c16b7b2154b9857249deeb51bd591b986ce22b6 Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 22 Dec 2025 18:29:03 +0800 Subject: [PATCH] feat(web): improve token refresh handling with automatic 401 retry and focus-based refresh --- web/src/api/client.ts | 37 ++++++++++- web/src/hooks/useAuth.ts | 139 +++++++++++++++++++++++++++++---------- 2 files changed, 140 insertions(+), 36 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index eb2bb3ea..6db1bd49 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -10,16 +10,36 @@ import type { SessionsResponse } from '@/types/api' +type ApiClientOptions = { + getToken?: () => string | null + onUnauthorized?: () => Promise +} + export class ApiClient { private token: string + private readonly getToken: (() => string | null) | null + private readonly onUnauthorized: (() => Promise) | 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(path: string, init?: RequestInit): Promise { + private async request( + path: string, + init?: RequestInit, + attempt: number = 0, + overrideToken?: string | null + ): Promise { 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(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}`) diff --git a/web/src/hooks/useAuth.ts b/web/src/hooks/useAuth.ts index 5518102a..44cc0754 100644 --- a/web/src/hooks/useAuth.ts +++ b/web/src/hooks/useAuth.ts @@ -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(null) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) - const refreshInFlightRef = useRef(false) - - const api = useMemo(() => (token ? new ApiClient(token) : null), [token]) + const refreshPromiseRef = useRef | null>(null) + const tokenRef = useRef(null) + const lastRefreshAttemptRef = useRef(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 => { + 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 } }