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
+493 -101

No files matched your search

+8 -3
View File
@@ -5,6 +5,8 @@ import { ThemeProvider } from '@/hooks/useTheme';
import Layout from '@/components/Layout'; import Layout from '@/components/Layout';
import LoginPage from '@/pages/LoginPage'; import LoginPage from '@/pages/LoginPage';
import { ToastContainer } from '@/components/ToastContainer'; import { ToastContainer } from '@/components/ToastContainer';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { menuIdForPath } from '@/constants/menu';
const Dashboard = lazy(() => import('@/pages/Dashboard')); const Dashboard = lazy(() => import('@/pages/Dashboard'));
const InstancePage = lazy(() => import('@/pages/InstancePage')); const InstancePage = lazy(() => import('@/pages/InstancePage'));
@@ -37,12 +39,13 @@ function PageFallback() {
function RouteGuard() { function RouteGuard() {
const { isLoggedIn, hasPermission, visibleMenuItems } = useAuth(); const { isLoggedIn, hasPermission, visibleMenuItems } = useAuth();
const location = useLocation(); const location = useLocation();
const path = location.pathname.split('/')[1] || 'dashboard'; const permissionPath = menuIdForPath(location.pathname);
const permissionPath = ({ redis: 'instances', mongo: 'instances' } as Record<string, string>)[path] || path;
const fallbackPath = '/' + (visibleMenuItems[0]?.id || 'queries'); const fallbackPath = '/' + (visibleMenuItems[0]?.id || 'queries');
if (!isLoggedIn) { if (!isLoggedIn) {
return <Navigate to="/login" replace />; // 携带当前位置,登录后回跳(useLogin 侧做同源校验防开放重定向)
const redirect = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?redirect=${redirect}`} replace />;
} }
// 检查当前路径是否有权限访问 // 检查当前路径是否有权限访问
@@ -64,6 +67,7 @@ function LoginGuard() {
function AppRoutes() { function AppRoutes() {
return ( return (
<ErrorBoundary>
<Suspense fallback={<PageFallback />}> <Suspense fallback={<PageFallback />}>
<Routes> <Routes>
<Route path="/login" element={<LoginGuard />} /> <Route path="/login" element={<LoginGuard />} />
@@ -94,6 +98,7 @@ function AppRoutes() {
</Route> </Route>
</Routes> </Routes>
</Suspense> </Suspense>
</ErrorBoundary>
); );
} }
+10 -3
View File
@@ -1,7 +1,9 @@
import axios from 'axios';
import request from '../request'; import request from '../request';
export interface LoginResponse { export interface LoginResponse {
access_token: string; access_token: string;
refresh_token?: string;
token_type: string; token_type: string;
user: { user: {
id: number; id: number;
@@ -27,8 +29,13 @@ export interface MeResponse {
} }
export const authApi = { export const authApi = {
/** 登录,成功返回 access_token + refresh_token 与用户信息 */
login: (username: string, password: string) => login: (username: string, password: string) =>
request.post<any, LoginResponse>('/auth/login', { username, password }), request.post<unknown, LoginResponse>('/auth/login', { username, password }),
getMe: () => request.get<any, MeResponse>('/auth/me'), /** 登出并撤销服务端 refresh token;token 参数用于本地凭证已清理时的显式撤销 */
logout: () => request.post('/auth/logout'), logout: (token?: string) =>
token
? axios.post('/api/v1/auth/logout', {}, { headers: { Authorization: `Bearer ${token}` } })
: request.post('/auth/logout'),
getMe: () => request.get<unknown, MeResponse>('/auth/me'),
}; };
+153 -15
View File
@@ -1,13 +1,138 @@
import axios from 'axios'; /**
* 统一请求层(指南 03 章):
* - 一切 IO 走本实例,组件禁止直接使用 axios/fetch
* - 401 单飞刷新:并发 N 个过期请求只触发一次 /auth/refresh,其余入队等待重放
* - 刷新失败才清会话并「防抖单次」跳转登录(携带 redirect 回跳参数)
* - 错误分类提示;取消的请求静默透传
* - 单请求逃生口:config.skipErrorHandler = true 关闭全局错误提示
*/
import axios, { type AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios';
import { toast } from '@/hooks/useToast'; import { toast } from '@/hooks/useToast';
import { readStorage, writeStorage, removeStorage } from '@/lib/storage';
declare module 'axios' {
export interface AxiosRequestConfig {
/** 关闭全局错误提示(登录页、轮询等由调用方自行处理错误时使用) */
skipErrorHandler?: boolean;
/** 内部标记:已被刷新流程重放过,防止无限循环 */
__isRetried?: boolean;
}
}
/** 类型化的 API 错误对象 */
export interface ApiError {
status?: number;
/** 后端 detail 字符串,或 422 校验错误数组 */
detail?: string | Array<{ msg?: string; loc?: string[] }>;
code?: string | number;
message: string;
}
const request = axios.create({ const request = axios.create({
baseURL: '/api/v1', baseURL: '/api/v1',
timeout: 30000, timeout: 30000,
}); });
/** 刷新 token 专用裸实例:不能被自己的 401 拦截逻辑拦截 */
const refreshClient = axios.create({ baseURL: '/api/v1', timeout: 15000 });
/* ------------------------------------------------------------------ */
/* Token 刷新:单飞标志 + 等待队列(并发 401 只刷一次) */
/* ------------------------------------------------------------------ */
let isRefreshing = false;
let refreshQueue: Array<(token: string | null) => void> = [];
function extractDetail(data: unknown): string | undefined {
if (data && typeof data === 'object' && 'detail' in data) {
const detail = (data as { detail: unknown }).detail;
if (typeof detail === 'string') return detail;
}
return undefined;
}
/** 登录跳转防抖:并发 N 个 401 失败只产生一次跳转 */
let redirectingToLogin = false;
export function redirectToLogin(): void {
if (redirectingToLogin) return;
redirectingToLogin = true;
const current = window.location.pathname + window.location.search;
const target = window.location.pathname.startsWith('/login')
? '/login'
: `/login?redirect=${encodeURIComponent(current)}`;
removeStorage('token');
removeStorage('refreshToken');
window.location.href = target;
}
async function doRefresh(): Promise<string | null> {
const refreshToken = readStorage('refreshToken');
if (!refreshToken) return null;
try {
const res = await refreshClient.post('/auth/refresh', { refresh_token: refreshToken });
const token = res.data?.access_token;
if (!token) return null;
writeStorage('token', token);
if (res.data?.refresh_token) {
writeStorage('refreshToken', res.data.refresh_token);
}
return token;
} catch {
return null;
}
}
/** 尝试刷新并重放原请求;失败时 reject 并单次跳登录 */
async function refreshAndRetry(error: AxiosError): Promise<unknown> {
const original = error.config as (InternalAxiosRequestConfig & { __isRetried?: boolean }) | undefined;
// 刷新接口自身的 401、已重放过的请求、或显式免刷新的请求 → 直接失败
if (!original || original.__isRetried || original.skipErrorHandler) {
redirectToLogin();
return Promise.reject(error);
}
if (isRefreshing) {
// 单飞进行中:入队等待新 token 后重放
return new Promise<unknown>((resolve, reject) => {
refreshQueue.push((token) => {
if (!token) {
reject(error);
return;
}
original.headers.Authorization = `Bearer ${token}`;
original.__isRetried = true;
resolve(request(original));
});
});
}
isRefreshing = true;
const newToken = await doRefresh();
isRefreshing = false;
// 唤醒队列中所有等待者
const queue = refreshQueue;
refreshQueue = [];
queue.forEach((cb) => cb(newToken));
if (!newToken) {
redirectToLogin();
return Promise.reject(error);
}
original.headers.Authorization = `Bearer ${newToken}`;
original.__isRetried = true;
return request(original);
}
/* ------------------------------------------------------------------ */
/* 拦截器 */
/* ------------------------------------------------------------------ */
request.interceptors.request.use((config) => { request.interceptors.request.use((config) => {
const token = localStorage.getItem('token'); const token = readStorage('token');
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
@@ -18,30 +143,43 @@ request.interceptors.response.use(
(response) => { (response) => {
return response.data; return response.data;
}, },
(error) => { async (error: AxiosError) => {
// 被取消的请求静默透传(列表页切换筛选时的旧请求)
if (axios.isCancel(error) || error.code === 'ERR_CANCELED') {
return Promise.reject(error);
}
const status = error.response?.status; const status = error.response?.status;
const detail = error.response?.data?.detail; const detail = error.response?.data && (error.response.data as { detail?: unknown }).detail;
const msg = detail || error.message || '未知错误'; const config = error.config as AxiosRequestConfig | undefined;
const silent = config?.skipErrorHandler === true;
if (status === 401) { if (status === 401) {
localStorage.removeItem('token'); try {
window.location.href = '/login'; return await refreshAndRetry(error);
} else if (status === 429) { } catch (err) {
toast('warning', msg); // 刷新失败已由 redirectToLogin 统一处理,这里不再弹错误
return Promise.reject(err);
}
}
if (!silent) {
if (status === 429) {
toast('warning', extractDetail(error.response?.data) || error.message || '请求过于频繁');
} else if (status === 422) { } else if (status === 422) {
// 验证错误 — 取第一个错误信息 // 校验错误 — 取第一个错误信息
const errors = error.response?.data?.detail; if (Array.isArray(detail)) {
if (Array.isArray(errors)) { toast('error', detail[0]?.msg || '请求参数错误');
const first = errors[0];
toast('error', first?.msg || '请求参数错误');
} else { } else {
toast('error', typeof errors === 'string' ? errors : '请求参数错误'); toast('error', typeof detail === 'string' ? detail : '请求参数错误');
} }
} else if (status && status >= 400) { } else if (status && status >= 400) {
const msg = extractDetail(error.response?.data) || error.message || '未知错误';
toast('error', msg); toast('error', msg);
} else { } else {
toast('error', '网络连接失败'); toast('error', '网络连接失败');
} }
}
return Promise.reject(error); return Promise.reject(error);
} }
@@ -13,9 +13,9 @@ interface InstanceData {
host: string; host: string;
port: number; port: number;
username: string; username: string;
database: string; database: string | null;
env: string; env: string;
access_mode: string; access_mode?: string;
workflow_id?: number | null; workflow_id?: number | null;
workflow_name?: string | null; workflow_name?: string | null;
excluded_databases?: string[] | null; excluded_databases?: string[] | null;
+73
View File
@@ -0,0 +1,73 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { STORAGE_KEYS } from '@/lib/storage';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
message: string;
}
/** 发版后旧 hash chunk 404 等动态 import 失败特征 */
const CHUNK_ERROR_RE = /dynamically imported module|Importing a module script failed|error loading dynamically imported module/i;
/**
* 全局错误边界(指南 04/08 章):
* - chunk 加载失败 → 自动 reload 一次(sessionStorage 标记防循环),恢复发版窗口期白屏
* - 其他渲染错误 → 错误占位 + 重试按钮(不是 console.error 完事)
*/
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, message: '' };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, message: error?.message || String(error) };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('[ErrorBoundary]', error, info.componentStack);
// chunk 失败自动恢复:只 reload 一次,防循环刷新
if (CHUNK_ERROR_RE.test(error?.message || '')) {
try {
if (sessionStorage.getItem(STORAGE_KEYS.chunkReloaded) !== '1') {
sessionStorage.setItem(STORAGE_KEYS.chunkReloaded, '1');
window.location.reload();
}
} catch { /* noop */ }
}
}
handleReload = (): void => {
try {
sessionStorage.removeItem(STORAGE_KEYS.chunkReloaded);
} catch { /* noop */ }
window.location.reload();
};
render(): ReactNode {
if (!this.state.hasError) return this.props.children;
const isChunkError = CHUNK_ERROR_RE.test(this.state.message);
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div className="text-2xl font-semibold text-[var(--dg-text-primary)]">
{isChunkError ? '页面资源已更新' : '页面出现错误'}
</div>
<div className="max-w-md text-sm text-[var(--dg-text-secondary)]">
{isChunkError
? '系统发布了新版本,刷新页面即可继续使用。'
: `渲染时发生异常:${this.state.message}`}
</div>
<button
type="button"
onClick={this.handleReload}
className="rounded-md border border-[var(--dg-border)] px-4 py-2 text-sm text-[var(--dg-text-primary)] transition-colors hover:bg-[var(--dg-bg-secondary)]"
>
刷新页面
</button>
</div>
);
}
}
+35 -1
View File
@@ -1,3 +1,19 @@
/**
* 菜单与路由权限的唯一源真相(指南 04 章:meta/权限单源,禁止双份人工同步)。
* 菜单项 roles 即路由级访问控制依据;useAuth/App 守卫均从这里派生。
*/
export type UserRole = 'admin' | 'dba' | 'developer' | 'auditor';
export interface MenuItem {
id: string;
label: string;
icon: string;
section: string;
roles: UserRole[];
permission: string;
}
export const menuSections = [ export const menuSections = [
{ id: 'workspace', label: '工作台' }, { id: 'workspace', label: '工作台' },
{ id: 'change', label: '数据变更' }, { id: 'change', label: '数据变更' },
@@ -8,7 +24,7 @@ export const menuSections = [
{ id: 'system', label: '系统治理' }, { id: 'system', label: '系统治理' },
]; ];
export const menuItems = [ export const menuItems: MenuItem[] = [
// 工作台 // 工作台
{ id: 'dashboard', label: '运营总览', icon: 'LayoutDashboard', section: 'workspace', roles: ['admin', 'dba', 'auditor'], permission: 'dashboard' }, { id: 'dashboard', label: '运营总览', icon: 'LayoutDashboard', section: 'workspace', roles: ['admin', 'dba', 'auditor'], permission: 'dashboard' },
@@ -37,3 +53,21 @@ export const menuItems = [
{ id: 'announcement', label: '公告管理', icon: 'Megaphone', section: 'system', roles: ['admin'], permission: 'announcement' }, { id: 'announcement', label: '公告管理', icon: 'Megaphone', section: 'system', roles: ['admin'], permission: 'announcement' },
{ id: 'settings', label: '系统设置', icon: 'Settings', section: 'system', roles: ['admin'], permission: 'settings' }, { id: 'settings', label: '系统设置', icon: 'Settings', section: 'system', roles: ['admin'], permission: 'settings' },
]; ];
/** 路由一级路径 → 菜单/权限 id 的映射(详情页等非菜单路由归属其权限域) */
export const routeToMenuId: Record<string, string> = {
redis: 'instances',
mongo: 'instances',
};
/** 菜单权限别名(历史权限码兼容) */
export const permissionAliasMap: Record<string, string[]> = {
instances: ['datasource'],
settings: ['config-platform'],
};
/** 根据路由一级路径解析所属菜单 id */
export function menuIdForPath(pathname: string): string {
const first = pathname.split('/')[1] || 'queries';
return routeToMenuId[first] || first;
}
+17 -29
View File
@@ -1,6 +1,7 @@
import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react'; 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 { instanceApi } from '@/api/instance';
import { STORAGE_KEYS, readStorage, writeStorage, removeStorage } from '@/lib/storage';
const defaultUser = { const defaultUser = {
id: 0, id: 0,
@@ -12,7 +13,7 @@ const defaultUser = {
}; };
import { authApi } from '@/api/auth'; import { authApi } from '@/api/auth';
export type UserRole = 'admin' | 'dba' | 'developer' | 'auditor'; export type { UserRole };
/** 细粒度权限类型 */ /** 细粒度权限类型 */
export type PermissionAction = export type PermissionAction =
@@ -110,28 +111,6 @@ const roleWeightMap: Record<UserRole, number> = {
auditor: 40, 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']); const dataChangePermissionIds = new Set(['tickets', 'workflow']);
export const actionPermissionMap: Record<string, UserRole[]> = { export const actionPermissionMap: Record<string, UserRole[]> = {
@@ -177,7 +156,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// 从 token 恢复基本用户信息(页面刷新时) // 从 token 恢复基本用户信息(页面刷新时)
const [isLoggedIn, setIsLoggedIn] = useState(() => { const [isLoggedIn, setIsLoggedIn] = useState(() => {
const token = localStorage.getItem('token'); const token = readStorage('token');
if (!token) return false; if (!token) return false;
try { try {
const payload = JSON.parse(atob(token.split('.')[1])); const payload = JSON.parse(atob(token.split('.')[1]));
@@ -238,7 +217,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
/** 注册权限变更监听器 */ /** 注册权限变更监听器 */
useEffect(() => { useEffect(() => {
const handleStorageChange = (e: StorageEvent) => { const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'token') { if (e.key === STORAGE_KEYS.token) {
if (!e.newValue) { if (!e.newValue) {
setIsLoggedIn(false); setIsLoggedIn(false);
clearPermissionCache(); clearPermissionCache();
@@ -289,7 +268,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const login = useCallback(async (username: string, password: string): Promise<boolean> => { const login = useCallback(async (username: string, password: string): Promise<boolean> => {
try { try {
const res = await authApi.login(username, password); 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 u = res.user;
const role = u.role as UserRole; const role = u.role as UserRole;
@@ -326,7 +308,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, [clearPermissionCache, emitPermissionChange]); }, [clearPermissionCache, emitPermissionChange]);
const logout = useCallback(() => { 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); setIsLoggedIn(false);
clearPermissionCache(); clearPermissionCache();
setUser({ setUser({
@@ -369,7 +357,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
const menuItem = menuItems.find(item => item.id === menuId || item.permission === menuId); const menuItem = menuItems.find(item => item.id === menuId || item.permission === menuId);
const allowedRoles = menuItem?.roles || routePermissionMap[menuId]; const allowedRoles = menuItem?.roles;
if (!allowedRoles) { if (!allowedRoles) {
cache.menu.set(menuId, false); cache.menu.set(menuId, false);
return false; return false;
+61 -9
View File
@@ -1,24 +1,76 @@
import { useState, useEffect, useCallback, useRef } from 'react'; 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 [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchFnRef = useRef(fetchFn); const fetchFnRef = useRef(fetchFn);
fetchFnRef.current = 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); setLoading(true);
fetchFnRef setError(null);
.current()
.then((res) => setData(res)) fetchFnRef.current(controller.signal)
.catch(() => setData([])) .then((res) => {
.finally(() => setLoading(false)); 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(() => { useEffect(() => {
refresh(); load();
return () => {
abortRef.current?.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, 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 { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useTheme, THEME_OPTIONS, type Theme } from '@/hooks/useTheme'; import { useTheme, THEME_OPTIONS, type Theme } from '@/hooks/useTheme';
import api from '@/api/request'; import api from '@/api/request';
import { writeStorage } from '@/lib/storage';
export interface LoginState { export interface LoginState {
username: string; username: string;
@@ -14,8 +15,15 @@ export interface LoginState {
handleLogin: (e: React.FormEvent) => Promise<void>; 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 { export function useLogin(): LoginState {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth(); const { login } = useAuth();
const { setTheme } = useTheme(); const { setTheme } = useTheme();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -32,7 +40,8 @@ export function useLogin(): LoginState {
if (ok) { if (ok) {
// Apply user preferences from backend (cross-browser sync) // Apply user preferences from backend (cross-browser sync)
try { 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) { if (prefs?.theme) {
const t = prefs.theme; const t = prefs.theme;
if (t === 'system') { if (t === 'system') {
@@ -43,14 +52,15 @@ export function useLogin(): LoginState {
setTheme(t as Theme); 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 */ } } catch { /* ignore */ }
navigate('/queries'); const redirect = getSafeRedirect(new URLSearchParams(location.search).get('redirect'));
navigate(redirect || '/queries');
} else { } else {
setError('用户名或密码错误'); setError('用户名或密码错误');
} }
setLoading(false); setLoading(false);
}, [username, password, login, navigate, setTheme]); }, [username, password, login, navigate, setTheme, location.search]);
return { username, password, loading, error, setUsername, setPassword, handleLogin }; 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 { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react';
import { readStorage, writeStorage } from '@/lib/storage';
export type Theme = 'system' | 'light-professional' | 'ocean-deep' | 'pine-green'; export type Theme = 'system' | 'light-professional' | 'ocean-deep' | 'pine-green';
@@ -38,11 +39,11 @@ const ThemeContext = createContext<ThemeContextType | null>(null);
function getInitialTheme(): Theme { function getInitialTheme(): Theme {
if (typeof window === 'undefined') return DEFAULT_THEME; if (typeof window === 'undefined') return DEFAULT_THEME;
const stored = localStorage.getItem('dg-theme'); const stored = readStorage('theme');
const migrated = migrateLegacyTheme(stored); const migrated = migrateLegacyTheme(stored);
if (migrated) { if (migrated) {
// Persist the migrated value so we don't migrate every time // Persist the migrated value so we don't migrate every time
localStorage.setItem('dg-theme', migrated); writeStorage('theme', migrated);
return migrated; return migrated;
} }
if (stored && THEME_ORDER.includes(stored as Theme)) { if (stored && THEME_ORDER.includes(stored as Theme)) {
@@ -79,13 +80,13 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
setThemeState(prev => { setThemeState(prev => {
const idx = THEME_ORDER.indexOf(prev); const idx = THEME_ORDER.indexOf(prev);
const next = THEME_ORDER[(idx + 1) % THEME_ORDER.length]; const next = THEME_ORDER[(idx + 1) % THEME_ORDER.length];
localStorage.setItem('dg-theme', next); writeStorage('theme', next);
return next; return next;
}); });
}, []); }, []);
const setTheme = useCallback((t: Theme) => { const setTheme = useCallback((t: Theme) => {
localStorage.setItem('dg-theme', t); writeStorage('theme', t);
setThemeState(t); setThemeState(t);
}, []); }, []);
+79
View File
@@ -0,0 +1,79 @@
/**
* 统一的 localStorage 命名空间与迁移层。
*
* 所有持久化 key 一律从 STORAGE_KEYS 取值(`<app>.<域>.<名>` 命名空间),
* 禁止在业务代码里手写字符串 key。历史无命名空间 key 仅在此处做
* 「读旧写新」迁移,迁移期结束后删除 LEGACY_KEYS。
*/
const read = (key: string): string | null => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
const write = (key: string, value: string): void => {
try {
localStorage.setItem(key, value);
} catch {
/* 存储不可用(隐私模式等)时静默降级为内存态 */
}
};
const remove = (key: string): void => {
try {
localStorage.removeItem(key);
} catch {
/* noop */
}
};
export const STORAGE_KEYS = {
token: 'opsxsql.auth.token',
refreshToken: 'opsxsql.auth.refresh',
theme: 'opsxsql.theme.mode',
loginStyle: 'opsxsql.ui.loginStyle',
queryFavorites: 'opsxsql.query.favorites',
querySideWidth: 'opsxsql.query.sideWidth',
chunkReloaded: 'opsxsql.app.chunkReloaded',
} as const;
/** 历史无命名空间 key → 新 key(仅用于一次性迁移读取) */
const LEGACY_KEYS: Partial<Record<keyof typeof STORAGE_KEYS, string>> = {
token: 'token',
theme: 'dg-theme',
loginStyle: 'login-style',
queryFavorites: 'query_favorites',
querySideWidth: 'query-side-width',
};
/** 读取(新 key 优先,回退旧 key 并完成迁移写入) */
export function readStorage(key: keyof typeof STORAGE_KEYS): string | null {
const current = read(STORAGE_KEYS[key]);
if (current !== null) return current;
const legacyKey = LEGACY_KEYS[key];
if (legacyKey) {
const legacy = read(legacyKey);
if (legacy !== null) {
write(STORAGE_KEYS[key], legacy);
remove(legacyKey);
return legacy;
}
}
return null;
}
/** 写入(只写新 key) */
export function writeStorage(key: keyof typeof STORAGE_KEYS, value: string): void {
write(STORAGE_KEYS[key], value);
}
/** 删除(新旧 key 一起清理) */
export function removeStorage(key: keyof typeof STORAGE_KEYS): void {
remove(STORAGE_KEYS[key]);
const legacyKey = LEGACY_KEYS[key];
if (legacyKey) remove(legacyKey);
}
+3 -2
View File
@@ -3,6 +3,7 @@ import { useTheme, THEME_OPTIONS, type Theme } from '@/hooks/useTheme';
import { LOGIN_STYLE_OPTIONS, type LoginStyle } from '@/pages/LoginPage'; import { LOGIN_STYLE_OPTIONS, type LoginStyle } from '@/pages/LoginPage';
import { Check, Save, Loader2 } from 'lucide-react'; import { Check, Save, Loader2 } from 'lucide-react';
import api from '@/api/request'; import api from '@/api/request';
import { readStorage, writeStorage } from '@/lib/storage';
import { toast } from '@/hooks/useToast'; import { toast } from '@/hooks/useToast';
// Login style preview color themes (independent from app theme) // Login style preview color themes (independent from app theme)
@@ -43,7 +44,7 @@ export default function AppearanceSettings() {
if (prefs?.login_style) setLoginStyle(prefs.login_style as LoginStyle); if (prefs?.login_style) setLoginStyle(prefs.login_style as LoginStyle);
}) })
.catch(() => { .catch(() => {
const stored = localStorage.getItem('login-style') as LoginStyle | null; const stored = readStorage('loginStyle') as LoginStyle | null;
if (stored) setLoginStyle(stored); if (stored) setLoginStyle(stored);
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -66,7 +67,7 @@ export default function AppearanceSettings() {
theme, theme,
login_style: loginStyle, login_style: loginStyle,
}); });
localStorage.setItem('login-style', loginStyle); writeStorage('loginStyle', loginStyle);
setDirty(false); setDirty(false);
toast('success', '外观设置已保存'); toast('success', '外观设置已保存');
} catch { } catch {
+1
View File
@@ -28,6 +28,7 @@ interface InstanceItem {
port: number; port: number;
username: string; username: string;
database: string | null; database: string | null;
access_mode?: string;
env: string; env: string;
description: string | null; description: string | null;
status: string; status: string;
+3 -2
View File
@@ -10,6 +10,7 @@ import InkWashView from '@/components/login/InkWashView';
import NeonCityView from '@/components/login/NeonCityView'; import NeonCityView from '@/components/login/NeonCityView';
import GradientMeshView from '@/components/login/GradientMeshView'; import GradientMeshView from '@/components/login/GradientMeshView';
import { THEME_OPTIONS, type Theme, useTheme } from '@/hooks/useTheme'; import { THEME_OPTIONS, type Theme, useTheme } from '@/hooks/useTheme';
import { readStorage } from '@/lib/storage';
export type LoginStyle = 'sidepanel' | 'split' | 'centered' | 'fullimage' | 'minimal' | 'aurora' | 'cosmos' | 'inkwash' | 'neoncity' | 'gradientmesh'; export type LoginStyle = 'sidepanel' | 'split' | 'centered' | 'fullimage' | 'minimal' | 'aurora' | 'cosmos' | 'inkwash' | 'neoncity' | 'gradientmesh';
@@ -61,12 +62,12 @@ export default function LoginPage() {
if (s && s in LOGIN_STYLES) { if (s && s in LOGIN_STYLES) {
setStyle(s); setStyle(s);
} else { } else {
const stored = localStorage.getItem('login-style') as LoginStyle | null; const stored = readStorage('loginStyle') as LoginStyle | null;
if (stored && stored in LOGIN_STYLES) setStyle(stored); if (stored && stored in LOGIN_STYLES) setStyle(stored);
} }
}) })
.catch(() => { .catch(() => {
const stored = localStorage.getItem('login-style') as LoginStyle | null; const stored = readStorage('loginStyle') as LoginStyle | null;
if (stored && stored in LOGIN_STYLES) setStyle(stored); if (stored && stored in LOGIN_STYLES) setStyle(stored);
}) })
.finally(() => setReady(true)); .finally(() => setReady(true));
+2 -2
View File
@@ -107,8 +107,8 @@ export default function PlatformConfigPage() {
const fetchSettings = useCallback(async () => { const fetchSettings = useCallback(async () => {
try { try {
const res = await api.get('/settings'); const res = await api.get<unknown, SettingsMap>('/settings');
setSettings(res as SettingsMap); setSettings(res);
} catch { } catch {
setToast('加载设置失败'); setToast('加载设置失败');
} finally { } finally {
+13 -13
View File
@@ -26,6 +26,8 @@ import { QueryHistoryDrawer } from '@/features/query/components/QueryHistoryDraw
import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel'; import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel';
import type { QueryHistoryItem } from '@/features/query/types'; import type { QueryHistoryItem } from '@/features/query/types';
import { downloadCSV, exportToCSV } from '@/features/query/utils'; import { downloadCSV, exportToCSV } from '@/features/query/utils';
import api from '@/api/request';
import { readStorage, writeStorage } from '@/lib/storage';
interface QueryResult { interface QueryResult {
columns: string[]; columns: string[];
@@ -136,13 +138,13 @@ export default function QueriesPage() {
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 }); const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
const resultContainerRef = useRef<HTMLDivElement>(null); const resultContainerRef = useRef<HTMLDivElement>(null);
// -- Sidebar 拖拽宽度 -- // -- Sidebar 拖拽宽度 --
const [sideWidth, setSideWidth] = useState(() => Number(localStorage.getItem('query-side-width')) || 280); const [sideWidth, setSideWidth] = useState(() => Number(readStorage('querySideWidth')) || 280);
const sidebarDragRef = useRef<HTMLDivElement>(null); const sidebarDragRef = useRef<HTMLDivElement>(null);
// -- 历史 state -- // -- 历史 state --
const [history, setHistory] = useState<QueryHistoryItem[]>(() => { const [history, setHistory] = useState<QueryHistoryItem[]>(() => {
try { try {
const saved = localStorage.getItem('query_favorites'); const saved = readStorage('queryFavorites');
const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>(); const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>();
const initialHistory: QueryHistoryItem[] = []; const initialHistory: QueryHistoryItem[] = [];
return initialHistory.map(h => ({ ...h, favorite: favIds.has(h.id) })); return initialHistory.map(h => ({ ...h, favorite: favIds.has(h.id) }));
@@ -237,7 +239,7 @@ export default function QueriesPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgres' && dbType !== 'postgresql')) return; if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgresql')) return;
if (/^\d+$/.test(database)) return; if (/^\d+$/.test(database)) return;
instanceDetailApi.getDbSchema(Number(instanceId), database) instanceDetailApi.getDbSchema(Number(instanceId), database)
.then(data => { if (data && typeof data === 'object') setEditorSchema(prev => ({ ...prev, [database]: data as Record<string, string[]> })); }) .then(data => { if (data && typeof data === 'object') setEditorSchema(prev => ({ ...prev, [database]: data as Record<string, string[]> })); })
@@ -330,13 +332,11 @@ export default function QueriesPage() {
const handleExportResult = useCallback(async () => { const handleExportResult = useCallback(async () => {
if (!sql || !instanceId) return; if (!sql || !instanceId) return;
try { try {
const resp = await fetch('/api/v1/query/export', { const blob = await api.post<unknown, Blob>('/query/export', {
method: 'POST', instance_id: Number(instanceId),
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` }, database: database || undefined,
body: JSON.stringify({ instance_id: Number(instanceId), database: database || undefined, sql }), sql,
}); }, { responseType: 'blob' });
if (!resp.ok) throw new Error('导出失败');
const blob = await resp.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
@@ -364,7 +364,7 @@ export default function QueriesPage() {
const toggleFavorite = useCallback((id: number) => { const toggleFavorite = useCallback((id: number) => {
setHistory(prev => { setHistory(prev => {
const next = prev.map(h => h.id === id ? { ...h, favorite: !h.favorite } : h); const next = prev.map(h => h.id === id ? { ...h, favorite: !h.favorite } : h);
try { localStorage.setItem('query_favorites', JSON.stringify(next.filter(h => h.favorite).map(h => h.id))); } catch { /* noop */ } try { writeStorage('queryFavorites', JSON.stringify(next.filter(h => h.favorite).map(h => h.id))); } catch { /* noop */ }
return next; return next;
}); });
}, []); }, []);
@@ -388,7 +388,7 @@ export default function QueriesPage() {
document.removeEventListener('mousemove', onMove); document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp); document.removeEventListener('mouseup', onUp);
setSideWidth(w => { setSideWidth(w => {
localStorage.setItem('query-side-width', String(w)); writeStorage('querySideWidth', String(w));
return w; return w;
}); });
}; };
@@ -634,7 +634,7 @@ export default function QueriesPage() {
<div className="dg-card overflow-hidden flex flex-col"> <div className="dg-card overflow-hidden flex flex-col">
<SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]} <SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]}
language={editorLang} height="200px" onExecute={handleExecute} language={editorLang} height="200px" onExecute={handleExecute}
schema={(dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql') ? editorSchema : undefined} /> schema={(dbType === 'mysql' || dbType === 'postgresql') ? editorSchema : undefined} />
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] dg-text-muted"> <div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] dg-text-muted">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />} {dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />}
+5 -4
View File
@@ -191,8 +191,9 @@ export default function RedisDetailPage() {
var handleDeleteHashLine = function (field: string) { var handleDeleteHashLine = function (field: string) {
if (!selectedKey || selectedKey.type !== 'hash') return; if (!selectedKey || selectedKey.type !== 'hash') return;
const keySnapshot = selectedKey;
setDeleteConfirm({ msg: '确定删除字段 ' + field + '?', onConfirm: async () => { setDeleteConfirm({ msg: '确定删除字段 ' + field + '?', onConfirm: async () => {
var nextValue = Object.assign({}, selectedKey.value || {}); var nextValue = Object.assign({}, keySnapshot.value || {});
delete nextValue[field]; delete nextValue[field];
updateHashValue(nextValue, 'Hash 字段已删除'); updateHashValue(nextValue, 'Hash 字段已删除');
}}); }});
@@ -407,7 +408,7 @@ export default function RedisDetailPage() {
</div> </div>
<button onClick={handleCopyValue} className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]" title="复制值"><Copy className="h-4 w-4" /></button> <button onClick={handleCopyValue} className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]" title="复制值"><Copy className="h-4 w-4" /></button>
<button onClick={handleSaveKey} disabled={savingKey} className="inline-flex h-10 items-center gap-2 rounded-md bg-[var(--dg-blue)] px-4 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"><Save className="h-4 w-4" />保存</button> <button onClick={handleSaveKey} disabled={savingKey} className="inline-flex h-10 items-center gap-2 rounded-md bg-[var(--dg-blue)] px-4 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"><Save className="h-4 w-4" />保存</button>
<button onClick={function () { handleDeleteKey(selectedKey.key || selectedKey.name || ''); }} className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20" title="删除 Key"><Trash2 className="h-4 w-4" /></button> <button onClick={function () { handleDeleteKey(selectedKey?.key || selectedKey?.name || ''); }} className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20" title="删除 Key"><Trash2 className="h-4 w-4" /></button>
</div> </div>
</div> </div>
@@ -456,8 +457,8 @@ export default function RedisDetailPage() {
<div> <div>
{inspectValue && ( {inspectValue && (
<div className="mb-2 flex items-center justify-between rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-xs"> <div className="mb-2 flex items-center justify-between rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-xs">
<span className="truncate font-mono text-[var(--dg-text-secondary)]">正在查看字段:{inspectValue.title}</span> <span className="truncate font-mono text-[var(--dg-text-secondary)]">正在查看字段:{inspectValue?.title}</span>
<button onClick={function () { copyText(typeof inspectValue.value === 'string' ? inspectValue.value : JSON.stringify(inspectValue.value), '已复制字段值'); }} className="inline-flex items-center gap-1.5 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"> <button onClick={function () { const iv = inspectValue; if (!iv) return; copyText(typeof iv.value === 'string' ? iv.value : JSON.stringify(iv.value), '已复制字段值'); }} className="inline-flex items-center gap-1.5 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<Copy className="h-3.5 w-3.5" />复制 <Copy className="h-3.5 w-3.5" />复制
</button> </button>
</div> </div>
+6 -6
View File
@@ -221,20 +221,20 @@ export default function TicketDetailPage() {
/* Permission checks */ /* Permission checks */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
const { hasActionPermission } = auth;
const { canApprove, canExecute, canRollback, canCancel } = useMemo(() => { const { canApprove, canExecute, canRollback, canCancel } = useMemo(() => {
if (!ticket) { if (!ticket) {
return { canApprove: false, canExecute: false, canRollback: false, canCancel: false }; return { canApprove: false, canExecute: false, canRollback: false, canCancel: false };
} }
const adminOrDba = auth.user.role === 'admin' || auth.user.role === 'dba'; // 角色权限统一走 hasActionPermission(actionPermissionMap 单源),此处只组合工单状态
const canApproveRole = adminOrDba || auth.user.role === 'developer';
const applicant = ticket.applicant_id === auth.user.id; const applicant = ticket.applicant_id === auth.user.id;
return { return {
canApprove: canApproveRole && (ticket.status === 'pending' || ticket.status === 'reviewing'), canApprove: hasActionPermission('ticket.approve') && (ticket.status === 'pending' || ticket.status === 'reviewing'),
canExecute: adminOrDba && ticket.status === 'approved', canExecute: hasActionPermission('ticket.execute') && ticket.status === 'approved',
canRollback: adminOrDba && (ticket.status === 'success' || ticket.status === 'failed'), canRollback: hasActionPermission('ticket.rollback') && (ticket.status === 'success' || ticket.status === 'failed'),
canCancel: applicant && (ticket.status === 'pending' || ticket.status === 'reviewing' || ticket.status === 'approved'), canCancel: applicant && (ticket.status === 'pending' || ticket.status === 'reviewing' || ticket.status === 'approved'),
}; };
}, [ticket, auth.user.role, auth.user.id]); }, [ticket, auth.user.id, hasActionPermission]);
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Parse execute result for visualization */ /* Parse execute result for visualization */
+2 -1
View File
@@ -42,6 +42,7 @@ interface UserItem {
username: string; username: string;
display_name: string | null; display_name: string | null;
role: string; role: string;
is_active?: boolean;
} }
interface StepDraft { interface StepDraft {
@@ -668,7 +669,7 @@ export default function WorkflowPage() {
{(wf.steps || []).map((step, i) => { {(wf.steps || []).map((step, i) => {
const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield; const StepIcon = STEP_TYPE_ICONS[step.step_type] || Shield;
let uids: number[] = []; let uids: number[] = [];
try { const parsed = JSON.parse(step.approver_ids); if (Array.isArray(parsed)) uids = parsed; } catch {} try { const parsed = step.approver_ids ? JSON.parse(step.approver_ids) : null; if (Array.isArray(parsed)) uids = parsed; } catch {}
const stepsArr = wf.steps || []; const stepsArr = wf.steps || [];
return ( return (
<div key={step.id || i} className="flex items-center gap-1 flex-shrink-0"> <div key={step.id || i} className="flex items-center gap-1 flex-shrink-0">