feat: 前端认证网关 + API 自动附加 JWT
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- AuthProvider 管理 jumpToken 交换和 JWT 生命周期 - 未授权页面(ShieldX 图标 + 提示文字) - 加载中旋转动画 - fetchJson 全局客户端自动附加 Authorization header - 401 响应触发重新认证 - JWT 存 sessionStorage,刷新不丢失 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
30
src/App.tsx
30
src/App.tsx
@@ -2,12 +2,40 @@ import { Truck, Route } from 'lucide-react';
|
|||||||
import { Shell, type ModuleConfig } from './components/Shell';
|
import { Shell, type ModuleConfig } from './components/Shell';
|
||||||
import AssetsModule from './modules/assets/AssetsModule';
|
import AssetsModule from './modules/assets/AssetsModule';
|
||||||
import MileageModule from './modules/mileage/MileageModule';
|
import MileageModule from './modules/mileage/MileageModule';
|
||||||
|
import AuthProvider from './auth/AuthProvider';
|
||||||
|
import { useAuth } from './auth/useAuth';
|
||||||
|
import UnauthorizedPage from './auth/UnauthorizedPage';
|
||||||
|
|
||||||
const MODULES: ModuleConfig[] = [
|
const MODULES: ModuleConfig[] = [
|
||||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function App() {
|
function AuthGate() {
|
||||||
|
const { isLoading, isAuthenticated, error } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
|
||||||
|
<p className="text-xs text-slate-400 font-bold">正在验证身份...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <UnauthorizedPage message={error || undefined} />;
|
||||||
|
}
|
||||||
|
|
||||||
return <Shell modules={MODULES} />;
|
return <Shell modules={MODULES} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthGate />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
118
src/auth/AuthProvider.tsx
Normal file
118
src/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
||||||
|
import { AuthContext, type AuthState } from './useAuth';
|
||||||
|
import { setTokenGetter } from './api-client';
|
||||||
|
|
||||||
|
const AUTH_API = '/api/auth';
|
||||||
|
|
||||||
|
export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [state, setState] = useState<AuthState>({
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
const tokenRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 设置全局 token getter
|
||||||
|
setTokenGetter(() => tokenRef.current);
|
||||||
|
|
||||||
|
// 监听 401 事件
|
||||||
|
const onUnauthorized = () => {
|
||||||
|
tokenRef.current = null;
|
||||||
|
sessionStorage.removeItem('bi_jwt');
|
||||||
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: '会话已过期' });
|
||||||
|
};
|
||||||
|
window.addEventListener('auth:unauthorized', onUnauthorized);
|
||||||
|
|
||||||
|
authenticate();
|
||||||
|
|
||||||
|
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
// 1. 检查 sessionStorage 中是否有 JWT
|
||||||
|
const savedToken = sessionStorage.getItem('bi_jwt');
|
||||||
|
if (savedToken) {
|
||||||
|
tokenRef.current = savedToken;
|
||||||
|
// 验证 token 是否仍然有效(尝试请求 health)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/health', {
|
||||||
|
headers: { Authorization: `Bearer ${savedToken}` },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const savedUser = sessionStorage.getItem('bi_user');
|
||||||
|
setState({
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: savedUser ? JSON.parse(savedUser) : null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* token 无效,继续流程 */ }
|
||||||
|
sessionStorage.removeItem('bi_jwt');
|
||||||
|
sessionStorage.removeItem('bi_user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 从 URL 提取 jumpToken
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const jumpToken = params.get('jumpToken');
|
||||||
|
|
||||||
|
if (!jumpToken) {
|
||||||
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: '未提供跳转令牌' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 3. 通过后端代理交换 jumpToken
|
||||||
|
const exchangeRes = await fetch(`${AUTH_API}/exchange?jumpToken=${encodeURIComponent(jumpToken)}`);
|
||||||
|
const exchangeData = await exchangeRes.json();
|
||||||
|
|
||||||
|
if (!exchangeRes.ok || !exchangeData.token) {
|
||||||
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: '跳转令牌无效或已过期' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 用 sessionToken 登录获取 JWT
|
||||||
|
const loginRes = await fetch(`${AUTH_API}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: exchangeData.token }),
|
||||||
|
});
|
||||||
|
const loginData = await loginRes.json();
|
||||||
|
|
||||||
|
if (!loginRes.ok || !loginData.token) {
|
||||||
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: '获取用户信息失败' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 存储 JWT
|
||||||
|
tokenRef.current = loginData.token;
|
||||||
|
sessionStorage.setItem('bi_jwt', loginData.token);
|
||||||
|
sessionStorage.setItem('bi_user', JSON.stringify(loginData.user));
|
||||||
|
|
||||||
|
// 6. 清除 URL 中的 jumpToken
|
||||||
|
params.delete('jumpToken');
|
||||||
|
const cleanUrl = params.toString()
|
||||||
|
? `${window.location.pathname}?${params.toString()}${window.location.hash}`
|
||||||
|
: `${window.location.pathname}${window.location.hash}`;
|
||||||
|
window.history.replaceState({}, '', cleanUrl);
|
||||||
|
|
||||||
|
setState({
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: loginData.user,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: '认证过程出错' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={state}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/auth/UnauthorizedPage.tsx
Normal file
20
src/auth/UnauthorizedPage.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ShieldX } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function UnauthorizedPage({ message }: { message?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
|
||||||
|
<div className="text-center max-w-sm">
|
||||||
|
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-slate-100 flex items-center justify-center">
|
||||||
|
<ShieldX size={36} className="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-lg font-black text-slate-800 mb-2">未授权访问</h1>
|
||||||
|
<p className="text-xs text-slate-400 mb-1">
|
||||||
|
{message || '获取用户认证信息失败,可能是跳转令牌已过期或无效'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-300">
|
||||||
|
请从资产管理平台点击跳转链接进入此系统
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/auth/api-client.ts
Normal file
24
src/auth/api-client.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/** 全局认证 fetch 客户端 */
|
||||||
|
|
||||||
|
let tokenGetter: () => string | null = () => null;
|
||||||
|
|
||||||
|
export function setTokenGetter(fn: () => string | null) {
|
||||||
|
tokenGetter = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
|
const token = tokenGetter();
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options?.headers,
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
19
src/auth/useAuth.ts
Normal file
19
src/auth/useAuth.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
user: { userName: string; permissionLevel: string; depName: string } | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthState>({
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
@@ -7,15 +7,10 @@ import type {
|
|||||||
CustomerStats,
|
CustomerStats,
|
||||||
RegionalInventoryStats,
|
RegionalInventoryStats,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { fetchJson } from '../../auth/api-client';
|
||||||
|
|
||||||
const BASE = '/api/vehicles';
|
const BASE = '/api/vehicles';
|
||||||
|
|
||||||
async function fetchJson<T>(url: string): Promise<T> {
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSummary(): Promise<SummaryData> {
|
export async function fetchSummary(): Promise<SummaryData> {
|
||||||
return fetchJson<SummaryData>(`${BASE}/summary`);
|
return fetchJson<SummaryData>(`${BASE}/summary`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
|
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
|
||||||
|
import { fetchJson } from '../../auth/api-client';
|
||||||
|
|
||||||
const BASE = '/api/mileage';
|
const BASE = '/api/mileage';
|
||||||
|
|
||||||
async function fetchJson<T>(url: string): Promise<T> {
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchMonitoring(params?: {
|
export async function fetchMonitoring(params?: {
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: string;
|
sortOrder?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user