mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-03 19:11:02 +08:00
- LoginPage: call completeRegistration instead of verifyOTP when qrCodeURL exists - This ensures otp_verified is set to true for users completing OTP setup - Backend: reorder maxUsers check to allow existing incomplete users to continue - Backend: return OTP info when login with incomplete OTP setup
416 lines
12 KiB
TypeScript
416 lines
12 KiB
TypeScript
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
|
||
requiresOTPSetup?: boolean
|
||
qrCodeURL?: string
|
||
otpSecret?: string
|
||
email?: string
|
||
}>
|
||
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) {
|
||
// Check for OTP setup required (incomplete registration)
|
||
if (data.requires_otp_setup) {
|
||
return {
|
||
success: true,
|
||
userID: data.user_id,
|
||
requiresOTPSetup: true,
|
||
message: data.message,
|
||
qrCodeURL: data.qr_code_url,
|
||
otpSecret: data.otp_secret,
|
||
email: data.email
|
||
}
|
||
}
|
||
// Check for OTP verification required (normal login flow)
|
||
if (data.requires_otp) {
|
||
return {
|
||
success: true,
|
||
userID: data.user_id,
|
||
requiresOTP: true,
|
||
message: data.message,
|
||
qrCodeURL: data.qr_code_url,
|
||
otpSecret: data.otp_secret
|
||
}
|
||
}
|
||
// Unexpected success response
|
||
return { success: false, message: '登录响应异常' }
|
||
} else {
|
||
return {
|
||
success: false,
|
||
message: data.error,
|
||
qrCodeURL: data.qr_code_url,
|
||
otpSecret: data.otp_secret,
|
||
userID: data.user_id
|
||
}
|
||
}
|
||
} catch (error) {
|
||
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
|
||
}
|
||
|
||
try {
|
||
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',
|
||
}
|
||
} catch (error) {
|
||
console.error('Auth register error:', error);
|
||
// Re-throw if it's a critical error, or return structured error
|
||
// Since httpClient throws on 500, we should return a structured error response
|
||
// to let the UI display it gracefully without crashing.
|
||
return {
|
||
success: false,
|
||
message: error instanceof Error ? error.message : 'Detailed server error'
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|