mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 00:07:01 +08:00
fix(web): fix 401 unauthorized redirect not working properly (#997)
修复了token过期后页面一直遇到401错误、无法自动跳转登录页的问题 主要改动: 1. httpClient.ts - 去掉延迟跳转的setTimeout,改为立即跳转 - 返回pending promise阻止SWR捕获401错误 - 保存from401标记到sessionStorage,由登录页显示提示 2. LoginPage.tsx - 检测from401标记,显示"登录已过期"提示(永久显示) - 在登录成功时手动关闭过期提示toast - 支持管理员登录、普通登录、OTP验证三种场景 3. TraderConfigModal.tsx - 修复3处直接使用fetch()的问题,改为httpClient.get() - 确保所有API请求都经过统一的401拦截器 4. translations.ts - 添加sessionExpired的中英文翻译 修复效果: - token过期时立即跳转登录页(无延迟) - 登录页持续显示过期提示,直到用户登录成功或手动关闭 - 不会再看到401错误页面或重复的错误提示 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: tinkle-community <tinklefund@gmail.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
@@ -24,6 +24,18 @@ export function LoginPage() {
|
|||||||
const adminMode = false
|
const adminMode = false
|
||||||
const { config: systemConfig } = useSystemConfig()
|
const { config: systemConfig } = useSystemConfig()
|
||||||
const registrationEnabled = systemConfig?.registration_enabled !== false
|
const registrationEnabled = systemConfig?.registration_enabled !== false
|
||||||
|
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(null)
|
||||||
|
|
||||||
|
// Show notification if user was redirected here due to 401
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionStorage.getItem('from401') === 'true') {
|
||||||
|
const id = toast.warning(t('sessionExpired', language), {
|
||||||
|
duration: Infinity // Keep showing until user dismisses or logs in
|
||||||
|
})
|
||||||
|
setExpiredToastId(id)
|
||||||
|
sessionStorage.removeItem('from401')
|
||||||
|
}
|
||||||
|
}, [language])
|
||||||
|
|
||||||
const handleAdminLogin = async (e: React.FormEvent) => {
|
const handleAdminLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -34,6 +46,11 @@ export function LoginPage() {
|
|||||||
const msg = result.message || t('loginFailed', language)
|
const msg = result.message || t('loginFailed', language)
|
||||||
setError(msg)
|
setError(msg)
|
||||||
toast.error(msg)
|
toast.error(msg)
|
||||||
|
} else {
|
||||||
|
// Dismiss the "login expired" toast on successful login
|
||||||
|
if (expiredToastId) {
|
||||||
|
toast.dismiss(expiredToastId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -49,6 +66,11 @@ export function LoginPage() {
|
|||||||
if (result.requiresOTP && result.userID) {
|
if (result.requiresOTP && result.userID) {
|
||||||
setUserID(result.userID)
|
setUserID(result.userID)
|
||||||
setStep('otp')
|
setStep('otp')
|
||||||
|
} else {
|
||||||
|
// Dismiss the "login expired" toast on successful login (no OTP required)
|
||||||
|
if (expiredToastId) {
|
||||||
|
toast.dismiss(expiredToastId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const msg = result.message || t('loginFailed', language)
|
const msg = result.message || t('loginFailed', language)
|
||||||
@@ -70,6 +92,11 @@ export function LoginPage() {
|
|||||||
const msg = result.message || t('verificationFailed', language)
|
const msg = result.message || t('verificationFailed', language)
|
||||||
setError(msg)
|
setError(msg)
|
||||||
toast.error(msg)
|
toast.error(msg)
|
||||||
|
} else {
|
||||||
|
// Dismiss the "login expired" toast on successful OTP verification
|
||||||
|
if (expiredToastId) {
|
||||||
|
toast.dismiss(expiredToastId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 成功的话AuthContext会自动处理登录状态
|
// 成功的话AuthContext会自动处理登录状态
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useLanguage } from '../contexts/LanguageContext'
|
|||||||
import { t } from '../i18n/translations'
|
import { t } from '../i18n/translations'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Pencil, Plus, X as IconX } from 'lucide-react'
|
import { Pencil, Plus, X as IconX } from 'lucide-react'
|
||||||
|
import { httpClient } from '../lib/httpClient'
|
||||||
|
|
||||||
// 提取下划线后面的名称部分
|
// 提取下划线后面的名称部分
|
||||||
function getShortName(fullName: string): string {
|
function getShortName(fullName: string): string {
|
||||||
@@ -114,7 +115,7 @@ export function TraderConfigModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchConfig = async () => {
|
const fetchConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/config')
|
const response = await httpClient.get('/api/config')
|
||||||
const config = await response.json()
|
const config = await response.json()
|
||||||
if (config.default_coins) {
|
if (config.default_coins) {
|
||||||
setAvailableCoins(config.default_coins)
|
setAvailableCoins(config.default_coins)
|
||||||
@@ -140,7 +141,7 @@ export function TraderConfigModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPromptTemplates = async () => {
|
const fetchPromptTemplates = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/prompt-templates')
|
const response = await httpClient.get('/api/prompt-templates')
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (data.templates) {
|
if (data.templates) {
|
||||||
setPromptTemplates(data.templates)
|
setPromptTemplates(data.templates)
|
||||||
@@ -198,19 +199,13 @@ export function TraderConfigModal({
|
|||||||
throw new Error('未登录,请先登录')
|
throw new Error('未登录,请先登录')
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await httpClient.get(
|
||||||
`/api/account?trader_id=${traderData.trader_id}`,
|
`/api/account?trader_id=${traderData.trader_id}`,
|
||||||
{
|
{
|
||||||
headers: {
|
Authorization: `Bearer ${token}`,
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('获取账户余额失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
// total_equity = 当前账户净值(包含未实现盈亏)
|
// total_equity = 当前账户净值(包含未实现盈亏)
|
||||||
|
|||||||
@@ -474,6 +474,7 @@ export const translations = {
|
|||||||
registrationFailed: 'Registration failed. Please try again.',
|
registrationFailed: 'Registration failed. Please try again.',
|
||||||
verificationFailed:
|
verificationFailed:
|
||||||
'OTP verification failed. Please check the code and try again.',
|
'OTP verification failed. Please check the code and try again.',
|
||||||
|
sessionExpired: 'Session expired, please login again',
|
||||||
invalidCredentials: 'Invalid email or password',
|
invalidCredentials: 'Invalid email or password',
|
||||||
weak: 'Weak',
|
weak: 'Weak',
|
||||||
medium: 'Medium',
|
medium: 'Medium',
|
||||||
@@ -1287,6 +1288,7 @@ export const translations = {
|
|||||||
loginFailed: '登录失败,请检查您的邮箱和密码。',
|
loginFailed: '登录失败,请检查您的邮箱和密码。',
|
||||||
registrationFailed: '注册失败,请重试。',
|
registrationFailed: '注册失败,请重试。',
|
||||||
verificationFailed: 'OTP 验证失败,请检查验证码后重试。',
|
verificationFailed: 'OTP 验证失败,请检查验证码后重试。',
|
||||||
|
sessionExpired: '登录已过期,请重新登录',
|
||||||
invalidCredentials: '邮箱或密码错误',
|
invalidCredentials: '邮箱或密码错误',
|
||||||
weak: '弱',
|
weak: '弱',
|
||||||
medium: '中',
|
medium: '中',
|
||||||
|
|||||||
@@ -6,10 +6,9 @@
|
|||||||
* - Automatic 401 token expiration handling
|
* - Automatic 401 token expiration handling
|
||||||
* - Auth state cleanup on unauthorized
|
* - Auth state cleanup on unauthorized
|
||||||
* - Automatic redirect to login page
|
* - Automatic redirect to login page
|
||||||
|
* - Notification shown on login page after redirect
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
export class HttpClient {
|
export class HttpClient {
|
||||||
// Singleton flag to prevent duplicate 401 handling
|
// Singleton flag to prevent duplicate 401 handling
|
||||||
private static isHandling401 = false
|
private static isHandling401 = false
|
||||||
@@ -21,13 +20,6 @@ export class HttpClient {
|
|||||||
HttpClient.isHandling401 = false
|
HttpClient.isHandling401 = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show login required notification to user
|
|
||||||
*/
|
|
||||||
private showLoginRequiredNotification(): void {
|
|
||||||
toast.warning('登录已过期,请先登录', { duration: 1800 })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response interceptor - handles common HTTP errors
|
* Response interceptor - handles common HTTP errors
|
||||||
*
|
*
|
||||||
@@ -53,23 +45,24 @@ export class HttpClient {
|
|||||||
// Notify global listeners (AuthContext will react to this)
|
// Notify global listeners (AuthContext will react to this)
|
||||||
window.dispatchEvent(new Event('unauthorized'))
|
window.dispatchEvent(new Event('unauthorized'))
|
||||||
|
|
||||||
// Show user-friendly notification (only once)
|
// Only redirect if not already on login page
|
||||||
this.showLoginRequiredNotification()
|
if (!window.location.pathname.includes('/login')) {
|
||||||
|
// Save current location for post-login redirect
|
||||||
// Delay redirect to let user see the notification
|
const returnUrl = window.location.pathname + window.location.search
|
||||||
setTimeout(() => {
|
if (returnUrl !== '/login' && returnUrl !== '/') {
|
||||||
// Only redirect if not already on login page
|
sessionStorage.setItem('returnUrl', returnUrl)
|
||||||
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秒延迟,让用户看到提示
|
// Mark that user came from 401 (login page will show notification)
|
||||||
|
sessionStorage.setItem('from401', 'true')
|
||||||
|
|
||||||
|
// Redirect immediately to login page
|
||||||
|
window.location.href = '/login'
|
||||||
|
|
||||||
|
// Return pending promise to prevent error from being caught by SWR/React
|
||||||
|
// The notification will be shown on the login page
|
||||||
|
return new Promise(() => {}) as Promise<Response>
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error('登录已过期,请重新登录')
|
throw new Error('登录已过期,请重新登录')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user