From 1ea0d4c33165e7deb3cc72a1f2b0d9528db648f2 Mon Sep 17 00:00:00 2001 From: Lawrence Liu Date: Sun, 9 Nov 2025 12:18:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dtoken=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E6=9C=AA=E9=87=8D=E6=96=B0=E7=99=BB=E5=BD=95=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98=20(#803)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 修复token过期未重新登录的问题 实现统一的401错误处理机制: - 创建httpClient封装fetch API,添加响应拦截器 - 401时自动清理localStorage和React状态 - 显示"请先登录"提示并延迟1.5秒后跳转登录页 - 保存当前URL到sessionStorage用于登录后返回 - 改造所有API调用使用httpClient统一处理 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: tinkle-community * fix: 添加401处理的单例保护防止并发竞态 问题: - 多个API同时返回401会导致多个通知叠加 - 多个style元素被添加到DOM造成内存泄漏 - 可能触发多次登录页跳转 解决方案: - 添加静态标志位 isHandling401 防止重复处理 - 第一个401触发完整处理流程 - 后续401直接抛出错误,避免重复操作 - 确保只显示一次通知和一次跳转 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: tinkle-community * fix: 修复isHandling401标志永不重置的问题 问题: - isHandling401标志在401处理后永不重置 - 导致用户重新登录后,后续401会被静默忽略 - 页面刷新或取消重定向后标志仍为true 解决方案: - 在HttpClient中添加reset401Flag()公开方法 - 登录成功后调用reset401Flag()重置标志 - 页面加载时调用reset401Flag()确保新会话正常 影响范围: - web/src/lib/httpClient.ts: 添加reset方法和导出函数 - web/src/contexts/AuthContext.tsx: 在登录和页面加载时重置 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: tinkle-community * fix(auth): consume returnUrl after successful login (BLOCKING-1) 修复登录后未跳转回原页面的问题。 问题: - httpClient在401时保存returnUrl到sessionStorage - 但登录成功后没有读取和使用returnUrl - 导致用户登录后停留在登录页,无法回到原页面 修复: - 在loginAdmin、verifyOTP、completeRegistration三个登录方法中 - 添加returnUrl检查和跳转逻辑 - 登录成功后优先跳转到returnUrl,如果没有则使用默认页面 影响: - 用户token过期后重新登录,会自动返回之前访问的页面 - 提升用户体验,避免手动导航 测试场景: 1. 用户访问/traders → token过期 → 登录 → 自动回到/traders ✅ 2. 用户直接访问/login → 登录 → 跳转到默认页面(/dashboard或/traders) ✅ Related: BLOCKING-1 in PR #803 code review --------- Co-authored-by: sue <177699783@qq.com> Co-authored-by: tinkle-community --- web/src/contexts/AuthContext.tsx | 73 +++++++++-- web/src/lib/api.ts | 179 ++++++++++++-------------- web/src/lib/httpClient.ts | 212 +++++++++++++++++++++++++++++++ 3 files changed, 355 insertions(+), 109 deletions(-) create mode 100644 web/src/lib/httpClient.ts diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 1930c587..9d1cfd1c 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useState, useEffect } from 'react' import { getSystemConfig } from '../lib/config' +import { reset401Flag } from '../lib/httpClient' interface User { id: string @@ -58,6 +59,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [isLoading, setIsLoading] = useState(true) useEffect(() => { + // Reset 401 flag on page load to allow fresh 401 handling + reset401Flag() + // 先检查是否为管理员模式(使用带缓存的系统配置获取) getSystemConfig() .then(() => { @@ -85,6 +89,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }) }, []) + // Listen for unauthorized events from httpClient (401 responses) + useEffect(() => { + const handleUnauthorized = () => { + console.log('Unauthorized event received - clearing auth state') + // Clear auth state when 401 is detected + setUser(null) + setToken(null) + // Note: localStorage cleanup is already done in httpClient + } + + window.addEventListener('unauthorized', handleUnauthorized) + + return () => { + window.removeEventListener('unauthorized', handleUnauthorized) + } + }, []) + const login = async (email: string, password: string) => { try { const response = await fetch('/api/login', { @@ -125,6 +146,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }) const data = await response.json() if (response.ok) { + // Reset 401 flag on successful login + reset401Flag() + const userInfo = { id: data.user_id || 'admin', email: data.email || 'admin@localhost', @@ -133,9 +157,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(userInfo) localStorage.setItem('auth_token', data.token) localStorage.setItem('auth_user', JSON.stringify(userInfo)) - // 跳转到仪表盘 - window.history.pushState({}, '', '/dashboard') - window.dispatchEvent(new PopStateEvent('popstate')) + + // Check and redirect to returnUrl if exists + const returnUrl = sessionStorage.getItem('returnUrl') + if (returnUrl) { + sessionStorage.removeItem('returnUrl') + window.history.pushState({}, '', returnUrl) + window.dispatchEvent(new PopStateEvent('popstate')) + } else { + // 跳转到仪表盘 + window.history.pushState({}, '', '/dashboard') + window.dispatchEvent(new PopStateEvent('popstate')) + } return { success: true } } else { return { success: false, message: data.error || '登录失败' } @@ -199,6 +232,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const data = await response.json() if (response.ok) { + // Reset 401 flag on successful login + reset401Flag() + // 登录成功,保存token和用户信息 const userInfo = { id: data.user_id, email: data.email } setToken(data.token) @@ -206,9 +242,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { localStorage.setItem('auth_token', data.token) localStorage.setItem('auth_user', JSON.stringify(userInfo)) - // 跳转到配置页面 - window.history.pushState({}, '', '/traders') - window.dispatchEvent(new PopStateEvent('popstate')) + // Check and redirect to returnUrl if exists + const returnUrl = sessionStorage.getItem('returnUrl') + if (returnUrl) { + sessionStorage.removeItem('returnUrl') + window.history.pushState({}, '', returnUrl) + window.dispatchEvent(new PopStateEvent('popstate')) + } else { + // 跳转到配置页面 + window.history.pushState({}, '', '/traders') + window.dispatchEvent(new PopStateEvent('popstate')) + } return { success: true, message: data.message } } else { @@ -232,6 +276,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const data = await response.json() if (response.ok) { + // Reset 401 flag on successful login + reset401Flag() + // 注册完成,自动登录 const userInfo = { id: data.user_id, email: data.email } setToken(data.token) @@ -239,9 +286,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { localStorage.setItem('auth_token', data.token) localStorage.setItem('auth_user', JSON.stringify(userInfo)) - // 跳转到配置页面 - window.history.pushState({}, '', '/traders') - window.dispatchEvent(new PopStateEvent('popstate')) + // Check and redirect to returnUrl if exists + const returnUrl = sessionStorage.getItem('returnUrl') + if (returnUrl) { + sessionStorage.removeItem('returnUrl') + window.history.pushState({}, '', returnUrl) + window.dispatchEvent(new PopStateEvent('popstate')) + } else { + // 跳转到配置页面 + window.history.pushState({}, '', '/traders') + window.dispatchEvent(new PopStateEvent('popstate')) + } return { success: true, message: data.message } } else { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 3d3eda69..a9390ea8 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -13,6 +13,7 @@ import type { CompetitionData, } from '../types' import { CryptoService } from './crypto' +import { httpClient } from './httpClient' const API_BASE = '/api' @@ -33,51 +34,51 @@ function getAuthHeaders(): Record { export const api = { // AI交易员管理接口 async getTraders(): Promise { - const res = await fetch(`${API_BASE}/my-traders`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(`${API_BASE}/my-traders`, getAuthHeaders()) if (!res.ok) throw new Error('获取trader列表失败') return res.json() }, // 获取公开的交易员列表(无需认证) async getPublicTraders(): Promise { - const res = await fetch(`${API_BASE}/traders`) + const res = await httpClient.get(`${API_BASE}/traders`) if (!res.ok) throw new Error('获取公开trader列表失败') return res.json() }, async createTrader(request: CreateTraderRequest): Promise { - const res = await fetch(`${API_BASE}/traders`, { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify(request), - }) + const res = await httpClient.post( + `${API_BASE}/traders`, + request, + getAuthHeaders() + ) if (!res.ok) throw new Error('创建交易员失败') return res.json() }, async deleteTrader(traderId: string): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}`, { - method: 'DELETE', - headers: getAuthHeaders(), - }) + const res = await httpClient.delete( + `${API_BASE}/traders/${traderId}`, + getAuthHeaders() + ) if (!res.ok) throw new Error('删除交易员失败') }, async startTrader(traderId: string): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}/start`, { - method: 'POST', - headers: getAuthHeaders(), - }) + const res = await httpClient.post( + `${API_BASE}/traders/${traderId}/start`, + undefined, + getAuthHeaders() + ) if (!res.ok) throw new Error('启动交易员失败') }, async stopTrader(traderId: string): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, { - method: 'POST', - headers: getAuthHeaders(), - }) + const res = await httpClient.post( + `${API_BASE}/traders/${traderId}/stop`, + undefined, + getAuthHeaders() + ) if (!res.ok) throw new Error('停止交易员失败') }, @@ -85,18 +86,19 @@ export const api = { traderId: string, customPrompt: string ): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}/prompt`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify({ custom_prompt: customPrompt }), - }) + const res = await httpClient.put( + `${API_BASE}/traders/${traderId}/prompt`, + { custom_prompt: customPrompt }, + getAuthHeaders() + ) if (!res.ok) throw new Error('更新自定义策略失败') }, async getTraderConfig(traderId: string): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}/config`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get( + `${API_BASE}/traders/${traderId}/config`, + getAuthHeaders() + ) if (!res.ok) throw new Error('获取交易员配置失败') return res.json() }, @@ -105,27 +107,25 @@ export const api = { traderId: string, request: CreateTraderRequest ): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify(request), - }) + const res = await httpClient.put( + `${API_BASE}/traders/${traderId}`, + request, + getAuthHeaders() + ) if (!res.ok) throw new Error('更新交易员失败') return res.json() }, // AI模型配置接口 async getModelConfigs(): Promise { - const res = await fetch(`${API_BASE}/models`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(`${API_BASE}/models`, getAuthHeaders()) if (!res.ok) throw new Error('获取模型配置失败') return res.json() }, // 获取系统支持的AI模型列表(无需认证) async getSupportedModels(): Promise { - const res = await fetch(`${API_BASE}/supported-models`) + const res = await httpClient.get(`${API_BASE}/supported-models`) if (!res.ok) throw new Error('获取支持的模型失败') return res.json() }, @@ -149,27 +149,25 @@ export const api = { ) // 发送加密数据 - const res = await fetch(`${API_BASE}/models`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify(encryptedPayload), - }) + const res = await httpClient.put( + `${API_BASE}/models`, + encryptedPayload, + getAuthHeaders() + ) if (!res.ok) throw new Error('更新模型配置失败') }, // 交易所配置接口 async getExchangeConfigs(): Promise { - const res = await fetch(`${API_BASE}/exchanges`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(`${API_BASE}/exchanges`, getAuthHeaders()) if (!res.ok) throw new Error('获取交易所配置失败') return res.json() }, // 获取系统支持的交易所列表(无需认证) async getSupportedExchanges(): Promise { - const res = await fetch(`${API_BASE}/supported-exchanges`) + const res = await httpClient.get(`${API_BASE}/supported-exchanges`) if (!res.ok) throw new Error('获取支持的交易所失败') return res.json() }, @@ -177,11 +175,11 @@ export const api = { async updateExchangeConfigs( request: UpdateExchangeConfigRequest ): Promise { - const res = await fetch(`${API_BASE}/exchanges`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify(request), - }) + const res = await httpClient.put( + `${API_BASE}/exchanges`, + request, + getAuthHeaders() + ) if (!res.ok) throw new Error('更新交易所配置失败') }, @@ -207,11 +205,11 @@ export const api = { ) // 发送加密数据 - const res = await fetch(`${API_BASE}/exchanges`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify(encryptedPayload), - }) + const res = await httpClient.put( + `${API_BASE}/exchanges`, + encryptedPayload, + getAuthHeaders() + ) if (!res.ok) throw new Error('更新交易所配置失败') }, @@ -220,9 +218,7 @@ export const api = { const url = traderId ? `${API_BASE}/status?trader_id=${traderId}` : `${API_BASE}/status` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取系统状态失败') return res.json() }, @@ -232,7 +228,7 @@ export const api = { const url = traderId ? `${API_BASE}/account?trader_id=${traderId}` : `${API_BASE}/account` - const res = await fetch(url, { + const res = await httpClient.request(url, { cache: 'no-store', headers: { ...getAuthHeaders(), @@ -250,9 +246,7 @@ export const api = { const url = traderId ? `${API_BASE}/positions?trader_id=${traderId}` : `${API_BASE}/positions` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取持仓列表失败') return res.json() }, @@ -262,9 +256,7 @@ export const api = { const url = traderId ? `${API_BASE}/decisions?trader_id=${traderId}` : `${API_BASE}/decisions` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取决策日志失败') return res.json() }, @@ -274,9 +266,7 @@ export const api = { const url = traderId ? `${API_BASE}/decisions/latest?trader_id=${traderId}` : `${API_BASE}/decisions/latest` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取最新决策失败') return res.json() }, @@ -286,9 +276,7 @@ export const api = { const url = traderId ? `${API_BASE}/statistics?trader_id=${traderId}` : `${API_BASE}/statistics` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取统计信息失败') return res.json() }, @@ -298,21 +286,15 @@ export const api = { const url = traderId ? `${API_BASE}/equity-history?trader_id=${traderId}` : `${API_BASE}/equity-history` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取历史数据失败') return res.json() }, // 批量获取多个交易员的历史数据(无需认证) async getEquityHistoryBatch(traderIds: string[]): Promise { - const res = await fetch(`${API_BASE}/equity-history-batch`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ trader_ids: traderIds }), + const res = await httpClient.post(`${API_BASE}/equity-history-batch`, { + trader_ids: traderIds, }) if (!res.ok) throw new Error('获取批量历史数据失败') return res.json() @@ -320,14 +302,14 @@ export const api = { // 获取前5名交易员数据(无需认证) async getTopTraders(): Promise { - const res = await fetch(`${API_BASE}/top-traders`) + const res = await httpClient.get(`${API_BASE}/top-traders`) if (!res.ok) throw new Error('获取前5名交易员失败') return res.json() }, // 获取公开交易员配置(无需认证) async getPublicTraderConfig(traderId: string): Promise { - const res = await fetch(`${API_BASE}/trader/${traderId}/config`) + const res = await httpClient.get(`${API_BASE}/trader/${traderId}/config`) if (!res.ok) throw new Error('获取公开交易员配置失败') return res.json() }, @@ -337,16 +319,14 @@ export const api = { const url = traderId ? `${API_BASE}/performance?trader_id=${traderId}` : `${API_BASE}/performance` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取AI学习数据失败') return res.json() }, // 获取竞赛数据(无需认证) async getCompetition(): Promise { - const res = await fetch(`${API_BASE}/competition`) + const res = await httpClient.get(`${API_BASE}/competition`) if (!res.ok) throw new Error('获取竞赛数据失败') return res.json() }, @@ -356,9 +336,10 @@ export const api = { coin_pool_url: string oi_top_url: string }> { - const res = await fetch(`${API_BASE}/user/signal-sources`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get( + `${API_BASE}/user/signal-sources`, + getAuthHeaders() + ) if (!res.ok) throw new Error('获取用户信号源配置失败') return res.json() }, @@ -367,14 +348,14 @@ export const api = { coinPoolUrl: string, oiTopUrl: string ): Promise { - const res = await fetch(`${API_BASE}/user/signal-sources`, { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ + const res = await httpClient.post( + `${API_BASE}/user/signal-sources`, + { coin_pool_url: coinPoolUrl, oi_top_url: oiTopUrl, - }), - }) + }, + getAuthHeaders() + ) if (!res.ok) throw new Error('保存用户信号源配置失败') }, @@ -383,9 +364,7 @@ export const api = { public_ip: string message: string }> { - const res = await fetch(`${API_BASE}/server-ip`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(`${API_BASE}/server-ip`, getAuthHeaders()) if (!res.ok) throw new Error('获取服务器IP失败') return res.json() }, diff --git a/web/src/lib/httpClient.ts b/web/src/lib/httpClient.ts new file mode 100644 index 00000000..9097c416 --- /dev/null +++ b/web/src/lib/httpClient.ts @@ -0,0 +1,212 @@ +/** + * HTTP Client with unified error handling and 401 interception + * + * Features: + * - Unified fetch wrapper + * - Automatic 401 token expiration handling + * - Auth state cleanup on unauthorized + * - Automatic redirect to login page + */ + +export class HttpClient { + // Singleton flag to prevent duplicate 401 handling + private static isHandling401 = false + + /** + * Reset 401 handling flag (call after successful login) + */ + public reset401Flag(): void { + HttpClient.isHandling401 = false + } + + /** + * Show login required notification to user + */ + private showLoginRequiredNotification(): void { + // Create notification element + const notification = document.createElement('div') + notification.style.cssText = ` + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, #F0B90B 0%, #FCD535 100%); + color: #0B0E11; + padding: 16px 24px; + border-radius: 8px; + font-size: 16px; + font-weight: bold; + z-index: 10000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + animation: slideDown 0.3s ease-out; + ` + notification.textContent = '⚠️ 登录已过期,请先登录' + + // Add slide down animation + const style = document.createElement('style') + style.textContent = ` + @keyframes slideDown { + from { + opacity: 0; + transform: translateX(-50%) translateY(-20px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + } + ` + document.head.appendChild(style) + + // Add to page + document.body.appendChild(notification) + + // Auto remove after animation + setTimeout(() => { + notification.style.animation = 'slideDown 0.3s ease-out reverse' + setTimeout(() => { + document.body.removeChild(notification) + document.head.removeChild(style) + }, 300) + }, 1800) + } + + /** + * Response interceptor - handles common HTTP errors + * + * @param response - Fetch Response object + * @returns Response if successful + * @throws Error with user-friendly message + */ + private async handleResponse(response: Response): Promise { + // Handle 401 Unauthorized - Token expired or invalid + if (response.status === 401) { + // Prevent duplicate 401 handling when multiple API calls fail simultaneously + if (HttpClient.isHandling401) { + throw new Error('登录已过期,请重新登录') + } + + // Set flag to prevent race conditions + HttpClient.isHandling401 = true + + // Clean up local storage + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + + // Notify global listeners (AuthContext will react to this) + window.dispatchEvent(new Event('unauthorized')) + + // Show user-friendly notification (only once) + this.showLoginRequiredNotification() + + // Delay redirect to let user see the notification + setTimeout(() => { + // Only redirect if not already on login page + if (!window.location.pathname.includes('/login')) { + // Save current location for post-login redirect + const returnUrl = window.location.pathname + window.location.search + if (returnUrl !== '/login' && returnUrl !== '/') { + sessionStorage.setItem('returnUrl', returnUrl) + } + + window.location.href = '/login' + } + // Note: No need to reset flag since we're redirecting + }, 1500) // 1.5秒延迟,让用户看到提示 + + throw new Error('登录已过期,请重新登录') + } + + // Handle other common errors + if (response.status === 403) { + throw new Error('没有权限访问此资源') + } + + if (response.status === 404) { + throw new Error('请求的资源不存在') + } + + if (response.status >= 500) { + throw new Error('服务器错误,请稍后重试') + } + + return response + } + + /** + * GET request + */ + async get(url: string, headers?: Record): Promise { + const response = await fetch(url, { + method: 'GET', + headers, + }) + return this.handleResponse(response) + } + + /** + * POST request + */ + async post( + url: string, + body?: any, + headers?: Record + ): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }) + return this.handleResponse(response) + } + + /** + * PUT request + */ + async put( + url: string, + body?: any, + headers?: Record + ): Promise { + const response = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }) + return this.handleResponse(response) + } + + /** + * DELETE request + */ + async delete( + url: string, + headers?: Record + ): Promise { + const response = await fetch(url, { + method: 'DELETE', + headers, + }) + return this.handleResponse(response) + } + + /** + * Generic request method for custom configurations + */ + async request(url: string, options: RequestInit = {}): Promise { + const response = await fetch(url, options) + return this.handleResponse(response) + } +} + +// Export singleton instance +export const httpClient = new HttpClient() + +// Export helper function to reset 401 flag +export const reset401Flag = () => httpClient.reset401Flag()