Files
nofx/web/src/contexts/AuthContext.tsx
Ember 3e19f7cc8c refactor(web): redesign httpClient with axios and unified error handling (#1061)
* fix(web): remove duplicate PasswordChecklist in error block

- Remove duplicate PasswordChecklist component from error message area
- Keep only the real-time password validation checklist
- Error block now displays only the error message text

Bug was introduced in commit aa0bd93 (PR #872)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: tinkle-community <tinklefund@gmail.com>

* refactor(web): redesign httpClient with axios and unified error handling

Major refactoring to improve error handling architecture:

## Changes

### 1. HTTP Client Redesign (httpClient.ts)
- Replaced native fetch with axios for better interceptor support
- Implemented request/response interceptors for centralized error handling
- Added automatic Bearer token injection in request interceptor
- Network errors and system errors (404, 403, 500) now intercepted and shown via toast
- Only business logic errors (4xx except 401/403/404) returned to caller
- New ApiResponse<T> interface for type-safe responses

### 2. API Migration (api.ts)
- Migrated all 31 API methods from legacy fetch-style to new httpClient
- Updated pattern: from `res.ok/res.json()` to `result.success/result.data`
- Removed getAuthHeaders() helper (token now auto-injected)
- Added TypeScript generics for better type safety

### 3. Component Updates
- AuthContext.tsx: Updated register() to use new API
- TraderConfigModal.tsx: Migrated 3 API calls (config, templates, balance)
- RegisterPage.tsx: Simplified error display (error type handling now in API layer)

### 4. Removed Legacy Code
- Removed legacyHttpClient compatibility wrapper (~30 lines)
- Removed legacyRequest() method
- Clean separation: API layer handles all error classification

## Benefits
- Centralized error handling - no need to check network/system errors in components
- Better UX - automatic toast notifications for system errors
- Type safety - generic ApiResponse<T> provides compile-time checks
- Cleaner business components - only handle business logic errors
- Consistent error messages across the application

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: tinkle-community <tinklefund@gmail.com>

---------

Co-authored-by: tinkle-community <tinklefund@gmail.com>
2025-11-17 14:48:14 +08:00

380 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { createContext, useContext, useState, useEffect } from 'react'
import { getSystemConfig } from '../lib/config'
import { reset401Flag, httpClient } from '../lib/httpClient'
interface User {
id: string
email: string
}
interface AuthContextType {
user: User | null
token: string | null
login: (
email: string,
password: string
) => Promise<{
success: boolean
message?: string
userID?: string
requiresOTP?: boolean
}>
loginAdmin: (password: string) => Promise<{
success: boolean
message?: string
}>
register: (
email: string,
password: string,
betaCode?: string
) => Promise<{
success: boolean
message?: string
userID?: string
otpSecret?: string
qrCodeURL?: string
}>
verifyOTP: (
userID: string,
otpCode: string
) => Promise<{ success: boolean; message?: string }>
completeRegistration: (
userID: string,
otpCode: string
) => Promise<{ success: boolean; message?: string }>
resetPassword: (
email: string,
newPassword: string,
otpCode: string
) => Promise<{ success: boolean; message?: string }>
logout: () => void
isLoading: boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// Reset 401 flag on page load to allow fresh 401 handling
reset401Flag()
// 先检查是否为管理员模式(使用带缓存的系统配置获取)
getSystemConfig()
.then(() => {
// 不再在管理员模式下模拟登录;统一检查本地存储
const savedToken = localStorage.getItem('auth_token')
const savedUser = localStorage.getItem('auth_user')
if (savedToken && savedUser) {
setToken(savedToken)
setUser(JSON.parse(savedUser))
}
setIsLoading(false)
})
.catch((err) => {
console.error('Failed to fetch system config:', err)
// 发生错误时,继续检查本地存储
const savedToken = localStorage.getItem('auth_token')
const savedUser = localStorage.getItem('auth_user')
if (savedToken && savedUser) {
setToken(savedToken)
setUser(JSON.parse(savedUser))
}
setIsLoading(false)
})
}, [])
// 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', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
})
const data = await response.json()
if (response.ok) {
if (data.requires_otp) {
return {
success: true,
userID: data.user_id,
requiresOTP: true,
message: data.message,
}
}
} else {
return { success: false, message: data.error }
}
} catch (error) {
return { success: false, message: '登录失败,请重试' }
}
return { success: false, message: '未知错误' }
}
const loginAdmin = async (password: string) => {
try {
const response = await fetch('/api/admin-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
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',
}
setToken(data.token)
setUser(userInfo)
localStorage.setItem('auth_token', data.token)
localStorage.setItem('auth_user', JSON.stringify(userInfo))
// 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 || '登录失败' }
}
} catch (e) {
return { success: false, message: '登录失败,请重试' }
}
}
const register = async (
email: string,
password: string,
betaCode?: string
) => {
const requestBody: {
email: string
password: string
beta_code?: string
} = { email, password }
if (betaCode) {
requestBody.beta_code = betaCode
}
const result = await httpClient.post<{
user_id: string
otp_secret: string
qr_code_url: string
message: string
}>('/api/register', requestBody)
if (result.success && result.data) {
return {
success: true,
userID: result.data.user_id,
otpSecret: result.data.otp_secret,
qrCodeURL: result.data.qr_code_url,
message: result.message || result.data.message,
}
}
// Only business errors reach here (system/network errors were intercepted)
return {
success: false,
message: result.message || 'Registration failed',
}
}
const verifyOTP = async (userID: string, otpCode: string) => {
try {
const response = await fetch('/api/verify-otp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: userID, otp_code: otpCode }),
})
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)
setUser(userInfo)
localStorage.setItem('auth_token', data.token)
localStorage.setItem('auth_user', JSON.stringify(userInfo))
// 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 {
return { success: false, message: data.error }
}
} catch (error) {
return { success: false, message: 'OTP验证失败请重试' }
}
}
const completeRegistration = async (userID: string, otpCode: string) => {
try {
const response = await fetch('/api/complete-registration', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: userID, otp_code: otpCode }),
})
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)
setUser(userInfo)
localStorage.setItem('auth_token', data.token)
localStorage.setItem('auth_user', JSON.stringify(userInfo))
// 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 {
return { success: false, message: data.error }
}
} catch (error) {
return { success: false, message: '注册完成失败,请重试' }
}
}
const resetPassword = async (
email: string,
newPassword: string,
otpCode: string
) => {
try {
const response = await fetch('/api/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
new_password: newPassword,
otp_code: otpCode,
}),
})
const data = await response.json()
if (response.ok) {
return { success: true, message: data.message }
} else {
return { success: false, message: data.error }
}
} catch (error) {
return { success: false, message: '密码重置失败,请重试' }
}
}
const logout = () => {
const savedToken = localStorage.getItem('auth_token')
if (savedToken) {
fetch('/api/logout', {
method: 'POST',
headers: { Authorization: `Bearer ${savedToken}` },
}).catch(() => {
/* ignore network errors on logout */
})
}
setUser(null)
setToken(null)
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
}
return (
<AuthContext.Provider
value={{
user,
token,
login,
loginAdmin,
register,
verifyOTP,
completeRegistration,
resetPassword,
logout,
isLoading,
}}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}