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:
1 parent
0fcdbc5f6c
commit
d5b8b8116c
19 files changed
+502
-110
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in new issue
Block a user