fix(frontend): harden auth request layer per web guide

- fix double-unwrap bug in useLogin prefs sync (theme sync silently broken)
- request layer: 401 single-flight refresh queue + debounced single redirect
  with return-url, cancel-aware silent passthrough, skipErrorHandler opt-out,
  typed blob download (replace bare fetch export in QueriesPage)
- useFetchList: AbortController + version guard + error state (three states)
- ErrorBoundary with chunk-load auto recovery (reload once)
- permission single source: derive route guard from menu constants, drop
  hand-maintained routePermissionMap; TicketDetailPage uses unified
  hasActionPermission instead of inline role string comparison
- namespaced localStorage keys (opsxsql.*) with read-old-write-new migration
- fix pre-existing tsc errors (InstancePage/WorkflowPage/QueriesPage/
  PlatformConfigPage/RedisDetailPage) - typecheck now passes clean
This commit is contained in:
Logan.Li committed 2026-10-26 17:12:29 +08:00
1 parent 0fcdbc5f6c
commit d5b8b8116c
19 files changed
+502 -110

No files matched your search

+17 -29
View File
@@ -1,6 +1,7 @@
import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { menuItems } from '@/constants/menu';
import { menuItems, permissionAliasMap, menuIdForPath, type UserRole } from '@/constants/menu';
import { instanceApi } from '@/api/instance';
import { STORAGE_KEYS, readStorage, writeStorage, removeStorage } from '@/lib/storage';
const defaultUser = {
id: 0,
@@ -12,7 +13,7 @@ const defaultUser = {
};
import { authApi } from '@/api/auth';
export type UserRole = 'admin' | 'dba' | 'developer' | 'auditor';
export type { UserRole };
/** 细粒度权限类型 */
export type PermissionAction =
@@ -110,28 +111,6 @@ const roleWeightMap: Record<UserRole, number> = {
auditor: 40,
};
const routePermissionMap: Record<string, UserRole[]> = {
dashboard: ['admin', 'dba', 'developer', 'auditor'],
instances: ['admin', 'dba'],
'instance-groups': ['admin'],
tickets: ['admin', 'dba', 'developer'],
queries: ['admin', 'dba', 'developer'],
roles: ['admin'],
users: ['admin'],
'audit-rules': ['admin', 'dba', 'auditor'],
'audit-logs': ['admin', 'dba', 'auditor'],
workflow: ['admin', 'dba', 'developer', 'auditor'],
'auto-task': ['admin'],
notifications: ['admin'],
announcement: ['admin'],
settings: ['admin'],
};
const permissionAliasMap: Record<string, string[]> = {
instances: ['datasource'],
settings: ['config-platform'],
};
const dataChangePermissionIds = new Set(['tickets', 'workflow']);
export const actionPermissionMap: Record<string, UserRole[]> = {
@@ -177,7 +156,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// 从 token 恢复基本用户信息(页面刷新时)
const [isLoggedIn, setIsLoggedIn] = useState(() => {
const token = localStorage.getItem('token');
const token = readStorage('token');
if (!token) return false;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
@@ -238,7 +217,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
/** 注册权限变更监听器 */
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'token') {
if (e.key === STORAGE_KEYS.token) {
if (!e.newValue) {
setIsLoggedIn(false);
clearPermissionCache();
@@ -289,7 +268,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const login = useCallback(async (username: string, password: string): Promise<boolean> => {
try {
const res = await authApi.login(username, password);
localStorage.setItem('token', res.access_token);
writeStorage('token', res.access_token);
if (res.refresh_token) {
writeStorage('refreshToken', res.refresh_token);
}
const u = res.user;
const role = u.role as UserRole;
@@ -326,7 +308,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, [clearPermissionCache, emitPermissionChange]);
const logout = useCallback(() => {
localStorage.removeItem('token');
// 先取 token 再清理:服务端撤销 refresh token 需要它
const token = readStorage('token');
removeStorage('token');
removeStorage('refreshToken');
if (token) {
authApi.logout(token).catch(() => { /* 服务端撤销失败不阻塞本地登出 */ });
}
setIsLoggedIn(false);
clearPermissionCache();
setUser({
@@ -369,7 +357,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
const menuItem = menuItems.find(item => item.id === menuId || item.permission === menuId);
const allowedRoles = menuItem?.roles || routePermissionMap[menuId];
const allowedRoles = menuItem?.roles;
if (!allowedRoles) {
cache.menu.set(menuId, false);
return false;
+61 -9
View File
@@ -1,24 +1,76 @@
import { useState, useEffect, useCallback, useRef } from 'react';
export function useFetchList<T>(fetchFn: () => Promise<T[]>, deps: unknown[] = []) {
export interface UseFetchListResult<T> {
data: T[];
loading: boolean;
/** 非空时表示最近一次加载失败(取消不算错误);页面必须渲染错误占位 */
error: string | null;
refresh: () => void;
}
/**
* 列表数据获取标准 hook(指南 03 章 5.2):
* - AbortController 取消旧请求 + 版本号令牌双重防护,杜绝筛选/翻页快速切换时的过期响应错序覆盖
* - 三态齐全:loading / 空 data / error(页面须渲染错误占位与重试)
*
* @param fetchFn 数据源函数,应把 signal 透传给请求层(axios config.signal)
* @param deps 触发重新加载的依赖(筛选/分页等状态)
*/
export function useFetchList<T>(
fetchFn: (signal: AbortSignal) => Promise<T[]>,
deps: unknown[] = []
): UseFetchListResult<T> {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchFnRef = useRef(fetchFn);
fetchFnRef.current = fetchFn;
const refresh = useCallback(() => {
const abortRef = useRef<AbortController | null>(null);
const versionRef = useRef(0);
const load = useCallback(() => {
// 取消上一个在途请求,并让在先的响应即使到达也会因版本过期被丢弃
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const currentVersion = ++versionRef.current;
setLoading(true);
fetchFnRef
.current()
.then((res) => setData(res))
.catch(() => setData([]))
.finally(() => setLoading(false));
setError(null);
fetchFnRef.current(controller.signal)
.then((res) => {
if (currentVersion !== versionRef.current || controller.signal.aborted) return;
setData(res);
setLoading(false);
})
.catch((err: unknown) => {
if (currentVersion !== versionRef.current) return;
if (isAbortError(err)) return; // 被新请求取代,静默
const detail =
(err as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail;
setError(typeof detail === 'string' ? detail : (err as Error)?.message || '加载失败');
setLoading(false);
});
}, []);
useEffect(() => {
refresh();
load();
return () => {
abortRef.current?.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { data, loading, refresh };
return { data, loading, error, refresh: load };
}
function isAbortError(err: unknown): boolean {
return (
(err as { name?: string })?.name === 'CanceledError' ||
(err as { name?: string })?.name === 'AbortError' ||
(err as { code?: string })?.code === 'ERR_CANCELED'
);
}
+15 -5
View File
@@ -1,8 +1,9 @@
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useTheme, THEME_OPTIONS, type Theme } from '@/hooks/useTheme';
import api from '@/api/request';
import { writeStorage } from '@/lib/storage';
export interface LoginState {
username: string;
@@ -14,8 +15,15 @@ export interface LoginState {
handleLogin: (e: React.FormEvent) => Promise<void>;
}
/** 只放行同源相对路径(/ 开头且非 // 开头),防开放重定向 */
function getSafeRedirect(redirect: string | null): string | null {
if (!redirect || !redirect.startsWith('/') || redirect.startsWith('//')) return null;
return redirect;
}
export function useLogin(): LoginState {
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
const { setTheme } = useTheme();
const [username, setUsername] = useState('');
@@ -32,7 +40,8 @@ export function useLogin(): LoginState {
if (ok) {
// Apply user preferences from backend (cross-browser sync)
try {
const { data: prefs } = await api.get<{ theme?: string; login_style?: string }>('/user/preferences');
// 拦截器已解包一层:响应本体即 { theme, login_style }
const prefs = await api.get<unknown, { theme?: string; login_style?: string }>('/user/preferences');
if (prefs?.theme) {
const t = prefs.theme;
if (t === 'system') {
@@ -43,14 +52,15 @@ export function useLogin(): LoginState {
setTheme(t as Theme);
}
}
if (prefs?.login_style) localStorage.setItem('login-style', prefs.login_style);
if (prefs?.login_style) writeStorage('loginStyle', prefs.login_style);
} catch { /* ignore */ }
navigate('/queries');
const redirect = getSafeRedirect(new URLSearchParams(location.search).get('redirect'));
navigate(redirect || '/queries');
} else {
setError('用户名或密码错误');
}
setLoading(false);
}, [username, password, login, navigate, setTheme]);
}, [username, password, login, navigate, setTheme, location.search]);
return { username, password, loading, error, setUsername, setPassword, handleLogin };
}
+5 -4
View File
@@ -1,4 +1,5 @@
import { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react';
import { readStorage, writeStorage } from '@/lib/storage';
export type Theme = 'system' | 'light-professional' | 'ocean-deep' | 'pine-green';
@@ -38,11 +39,11 @@ const ThemeContext = createContext<ThemeContextType | null>(null);
function getInitialTheme(): Theme {
if (typeof window === 'undefined') return DEFAULT_THEME;
const stored = localStorage.getItem('dg-theme');
const stored = readStorage('theme');
const migrated = migrateLegacyTheme(stored);
if (migrated) {
// Persist the migrated value so we don't migrate every time
localStorage.setItem('dg-theme', migrated);
writeStorage('theme', migrated);
return migrated;
}
if (stored && THEME_ORDER.includes(stored as Theme)) {
@@ -79,13 +80,13 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
setThemeState(prev => {
const idx = THEME_ORDER.indexOf(prev);
const next = THEME_ORDER[(idx + 1) % THEME_ORDER.length];
localStorage.setItem('dg-theme', next);
writeStorage('theme', next);
return next;
});
}, []);
const setTheme = useCallback((t: Theme) => {
localStorage.setItem('dg-theme', t);
writeStorage('theme', t);
setThemeState(t);
}, []);