feat(web): 实现前端权限系统基础设施
- 添加 permissionStore 管理用户菜单和权限状态 - 实现 usePermission hook 和权限服务 - 配置路由权限映射 - 添加 403 页面和权限相关组件 - Dashboard layout 集成权限检查 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { Sidebar, MobileSidebar } from '@/components/layout/Sidebar';
|
||||
import { getRoutePermission, isPublicRoute } from '@/config/route-permissions';
|
||||
import { getUserMenusAndPermissions } from '@/services/permission.service';
|
||||
import { useAuthStore, usePermissionStore } from '@/stores';
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { token, isHydrated: authHydrated } = useAuthStore();
|
||||
const { isLoaded, setPermissionData, hasPermission, clearPermissionData } = usePermissionStore();
|
||||
|
||||
// 加载用户权限数据
|
||||
useEffect(() => {
|
||||
if (!authHydrated) return;
|
||||
|
||||
// 未登录时清空权限数据
|
||||
if (!token) {
|
||||
clearPermissionData();
|
||||
return;
|
||||
}
|
||||
|
||||
// 已登录且权限未加载时,加载权限数据
|
||||
if (!isLoaded) {
|
||||
getUserMenusAndPermissions()
|
||||
.then((data) => {
|
||||
setPermissionData(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('加载权限数据失败:', error);
|
||||
// 加载失败时也标记为已加载,避免无限重试
|
||||
setPermissionData({ menus: [], permissions: [], isSuperAdmin: false });
|
||||
});
|
||||
}
|
||||
}, [authHydrated, token, isLoaded, setPermissionData, clearPermissionData]);
|
||||
|
||||
// 路由权限检查
|
||||
useEffect(() => {
|
||||
if (!authHydrated || !isLoaded) return;
|
||||
|
||||
// 公开路由不检查权限
|
||||
if (isPublicRoute(pathname)) return;
|
||||
|
||||
// 获取路由所需权限
|
||||
const requiredPermission = getRoutePermission(pathname);
|
||||
if (!requiredPermission) return;
|
||||
|
||||
// 检查权限
|
||||
if (!hasPermission(requiredPermission)) {
|
||||
router.replace('/403');
|
||||
}
|
||||
}, [authHydrated, isLoaded, pathname, hasPermission, router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* <EFBFBD><EFBFBD>面端侧边栏 */}
|
||||
{/* 桌面端侧边栏 */}
|
||||
<Sidebar />
|
||||
|
||||
{/* 移动端侧边栏 */}
|
||||
|
||||
42
apps/web/src/app/403/page.tsx
Normal file
42
apps/web/src/app/403/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { ShieldX } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function ForbiddenPage() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center space-y-6 p-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-full bg-destructive/10 p-6">
|
||||
<ShieldX className="h-16 w-16 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold tracking-tight">403</h1>
|
||||
<h2 className="text-xl font-semibold text-muted-foreground">
|
||||
访问被拒绝
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
抱歉,您没有访问此页面的权限。如果您认为这是一个错误,请联系系统管理员。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
返回上页
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/dashboard">返回首页</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
apps/web/src/components/permission/PermissionGuard.tsx
Normal file
64
apps/web/src/components/permission/PermissionGuard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
export interface PermissionGuardProps {
|
||||
/** 所需权限(单个或多个,OR 关系) */
|
||||
permission: string | string[];
|
||||
/** 有权限时显示的内容 */
|
||||
children: ReactNode;
|
||||
/** 无权限时显示的内容(可选) */
|
||||
fallback?: ReactNode;
|
||||
/** 权限检查模式:any-任一权限, all-所有权限 */
|
||||
mode?: 'any' | 'all';
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限守卫组件
|
||||
* 根据用户权限决定是否渲染子组件
|
||||
*
|
||||
* @example
|
||||
* // 单个权限
|
||||
* <PermissionGuard permission="user:create">
|
||||
* <CreateUserButton />
|
||||
* </PermissionGuard>
|
||||
*
|
||||
* @example
|
||||
* // 多个权限(OR 关系)
|
||||
* <PermissionGuard permission={['user:create', 'user:update']}>
|
||||
* <UserForm />
|
||||
* </PermissionGuard>
|
||||
*
|
||||
* @example
|
||||
* // 多个权限(AND 关系)
|
||||
* <PermissionGuard permission={['user:create', 'user:update']} mode="all">
|
||||
* <UserForm />
|
||||
* </PermissionGuard>
|
||||
*
|
||||
* @example
|
||||
* // 带 fallback
|
||||
* <PermissionGuard permission="user:delete" fallback={<span>无权限</span>}>
|
||||
* <DeleteButton />
|
||||
* </PermissionGuard>
|
||||
*/
|
||||
export function PermissionGuard({
|
||||
permission,
|
||||
children,
|
||||
fallback = null,
|
||||
mode = 'any',
|
||||
}: PermissionGuardProps) {
|
||||
const { hasPermission, hasAllPermissions } = usePermission();
|
||||
|
||||
const hasAccess =
|
||||
mode === 'all' && Array.isArray(permission)
|
||||
? hasAllPermissions(permission)
|
||||
: hasPermission(permission);
|
||||
|
||||
if (!hasAccess) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
52
apps/web/src/components/permission/WithPermission.tsx
Normal file
52
apps/web/src/components/permission/WithPermission.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { type ComponentType, type ReactNode } from 'react';
|
||||
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
export interface WithPermissionOptions {
|
||||
/** 所需权限(单个或多个,OR 关系) */
|
||||
permission: string | string[];
|
||||
/** 无权限时显示的内容(可选) */
|
||||
fallback?: ReactNode;
|
||||
/** 权限检查模式:any-任一权限, all-所有权限 */
|
||||
mode?: 'any' | 'all';
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限 HOC
|
||||
* 为组件添加权限检查功能
|
||||
*
|
||||
* @example
|
||||
* const ProtectedButton = withPermission(Button, {
|
||||
* permission: 'user:delete',
|
||||
* fallback: <span>无权限</span>,
|
||||
* });
|
||||
*/
|
||||
export function withPermission<P extends object>(
|
||||
WrappedComponent: ComponentType<P>,
|
||||
options: WithPermissionOptions
|
||||
) {
|
||||
const { permission, fallback = null, mode = 'any' } = options;
|
||||
|
||||
function WithPermissionComponent(props: P) {
|
||||
const { hasPermission, hasAllPermissions } = usePermission();
|
||||
|
||||
const hasAccess =
|
||||
mode === 'all' && Array.isArray(permission)
|
||||
? hasAllPermissions(permission)
|
||||
: hasPermission(permission);
|
||||
|
||||
if (!hasAccess) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <WrappedComponent {...props} />;
|
||||
}
|
||||
|
||||
// 设置组件显示名称
|
||||
const wrappedName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
|
||||
WithPermissionComponent.displayName = `WithPermission(${wrappedName})`;
|
||||
|
||||
return WithPermissionComponent;
|
||||
}
|
||||
2
apps/web/src/components/permission/index.ts
Normal file
2
apps/web/src/components/permission/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PermissionGuard, type PermissionGuardProps } from './PermissionGuard';
|
||||
export { withPermission, type WithPermissionOptions } from './WithPermission';
|
||||
@@ -5,10 +5,14 @@ export const API_ENDPOINTS = {
|
||||
REGISTER: '/auth/register',
|
||||
REFRESH: '/auth/refresh',
|
||||
ME: '/auth/me',
|
||||
MENUS: '/auth/menus',
|
||||
LOGOUT: '/auth/logout',
|
||||
},
|
||||
USERS: '/users',
|
||||
CAPTCHA: '/captcha',
|
||||
ROLES: '/roles',
|
||||
PERMISSIONS: '/permissions',
|
||||
MENUS: '/menus',
|
||||
} as const;
|
||||
|
||||
// 分页默认值
|
||||
@@ -22,4 +26,5 @@ export const PAGINATION = {
|
||||
export const STORAGE_KEYS = {
|
||||
AUTH: 'auth-storage',
|
||||
UI: 'ui-storage',
|
||||
PERMISSION: 'permission-storage',
|
||||
} as const;
|
||||
|
||||
66
apps/web/src/config/route-permissions.ts
Normal file
66
apps/web/src/config/route-permissions.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 路由权限配置
|
||||
* 定义每个路由所需的权限
|
||||
*/
|
||||
export const routePermissions: Record<string, string | string[]> = {
|
||||
// 用户管理
|
||||
'/users': 'user:read',
|
||||
'/users/create': 'user:create',
|
||||
'/users/[id]': 'user:read',
|
||||
'/users/[id]/edit': 'user:update',
|
||||
|
||||
// 角色管理
|
||||
'/roles': 'role:read',
|
||||
'/roles/create': 'role:create',
|
||||
'/roles/[id]': 'role:read',
|
||||
'/roles/[id]/edit': 'role:update',
|
||||
|
||||
// 菜单管理
|
||||
'/menus': 'menu:read',
|
||||
'/menus/create': 'menu:create',
|
||||
'/menus/[id]': 'menu:read',
|
||||
'/menus/[id]/edit': 'menu:update',
|
||||
|
||||
// 权限管理
|
||||
'/permissions': 'permission:read',
|
||||
};
|
||||
|
||||
/**
|
||||
* 公开路由(无需权限检查)
|
||||
*/
|
||||
export const publicRoutes = [
|
||||
'/dashboard',
|
||||
'/profile',
|
||||
'/settings',
|
||||
];
|
||||
|
||||
/**
|
||||
* 检查路由是否需要权限
|
||||
*/
|
||||
export function isPublicRoute(pathname: string): boolean {
|
||||
return publicRoutes.some((route) => {
|
||||
// 精确匹配或前缀匹配(带斜杠)
|
||||
return pathname === route || pathname.startsWith(route + '/');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取路由所需权限
|
||||
*/
|
||||
export function getRoutePermission(pathname: string): string | string[] | undefined {
|
||||
// 精确匹配
|
||||
if (routePermissions[pathname]) {
|
||||
return routePermissions[pathname];
|
||||
}
|
||||
|
||||
// 动态路由匹配
|
||||
for (const [route, permission] of Object.entries(routePermissions)) {
|
||||
const pattern = route.replace(/\[.*?\]/g, '[^/]+');
|
||||
const regex = new RegExp(`^${pattern}$`);
|
||||
if (regex.test(pathname)) {
|
||||
return permission;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -7,3 +7,26 @@ export {
|
||||
useRestoreUser,
|
||||
userKeys,
|
||||
} from './useUsers';
|
||||
|
||||
export { usePermission } from './usePermission';
|
||||
|
||||
export {
|
||||
useRoles,
|
||||
useRole,
|
||||
useAllPermissions,
|
||||
useCreateRole,
|
||||
useUpdateRole,
|
||||
useDeleteRole,
|
||||
roleKeys,
|
||||
permissionKeys,
|
||||
} from './useRoles';
|
||||
|
||||
export {
|
||||
useMenus,
|
||||
useMenuTree,
|
||||
useMenu,
|
||||
useCreateMenu,
|
||||
useUpdateMenu,
|
||||
useDeleteMenu,
|
||||
menuKeys,
|
||||
} from './useMenus';
|
||||
|
||||
55
apps/web/src/hooks/usePermission.ts
Normal file
55
apps/web/src/hooks/usePermission.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { usePermissionStore } from '@/stores';
|
||||
|
||||
/**
|
||||
* 权限检查 Hook
|
||||
* 提供便捷的权限检查方法
|
||||
*/
|
||||
export function usePermission() {
|
||||
const { permissions, isSuperAdmin, hasPermission, hasAllPermissions } = usePermissionStore();
|
||||
|
||||
/**
|
||||
* 检查是否拥有指定权限(支持单个或多个,OR 关系)
|
||||
*/
|
||||
const checkPermission = useCallback(
|
||||
(permission: string | string[]) => {
|
||||
return hasPermission(permission);
|
||||
},
|
||||
[hasPermission]
|
||||
);
|
||||
|
||||
/**
|
||||
* 检查是否拥有任一指定权限(OR 关系)
|
||||
*/
|
||||
const hasAnyPermission = useCallback(
|
||||
(requiredPermissions: string[]) => {
|
||||
if (isSuperAdmin) return true;
|
||||
return requiredPermissions.some((p) => permissions.includes(p));
|
||||
},
|
||||
[isSuperAdmin, permissions]
|
||||
);
|
||||
|
||||
/**
|
||||
* 检查是否拥有所有指定权限(AND 关系)
|
||||
*/
|
||||
const checkAllPermissions = useCallback(
|
||||
(requiredPermissions: string[]) => {
|
||||
return hasAllPermissions(requiredPermissions);
|
||||
},
|
||||
[hasAllPermissions]
|
||||
);
|
||||
|
||||
return {
|
||||
/** 当前用户的权限列表 */
|
||||
permissions,
|
||||
/** 是否为超级管理员 */
|
||||
isSuperAdmin,
|
||||
/** 检查是否拥有指定权限 */
|
||||
hasPermission: checkPermission,
|
||||
/** 检查是否拥有任一指定权限 */
|
||||
hasAnyPermission,
|
||||
/** 检查是否拥有所有指定权限 */
|
||||
hasAllPermissions: checkAllPermissions,
|
||||
};
|
||||
}
|
||||
11
apps/web/src/services/permission.service.ts
Normal file
11
apps/web/src/services/permission.service.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { UserMenusAndPermissionsResponse } from '@seclusion/shared';
|
||||
|
||||
import { API_ENDPOINTS } from '@/config/constants';
|
||||
import { http } from '@/lib/http';
|
||||
|
||||
/**
|
||||
* 获取当前用户的菜单和权限
|
||||
*/
|
||||
export async function getUserMenusAndPermissions(): Promise<UserMenusAndPermissionsResponse> {
|
||||
return http.get<UserMenusAndPermissionsResponse>(API_ENDPOINTS.AUTH.MENUS);
|
||||
}
|
||||
@@ -1,3 +1,13 @@
|
||||
export { useAuthStore, useToken, useUser, useIsAuthenticated, useIsHydrated } from './authStore';
|
||||
export { useUIStore, useTheme, useSidebarOpen, useSidebarCollapsed } from './uiStore';
|
||||
export {
|
||||
usePermissionStore,
|
||||
useMenus,
|
||||
usePermissions,
|
||||
useIsSuperAdmin,
|
||||
useIsPermissionLoaded,
|
||||
useHasPermission,
|
||||
useHasAllPermissions,
|
||||
} from './permissionStore';
|
||||
export type { AuthState, AuthActions, AuthStore, UIState, UIActions, UIStore, Theme } from './types';
|
||||
export type { PermissionState, PermissionActions, PermissionStore } from './permissionStore';
|
||||
|
||||
108
apps/web/src/stores/permissionStore.ts
Normal file
108
apps/web/src/stores/permissionStore.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { MenuTreeNode } from '@seclusion/shared';
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
import { STORAGE_KEYS } from '@/config/constants';
|
||||
|
||||
// 权限 Store 状态类型
|
||||
export interface PermissionState {
|
||||
/** 用户可访问的菜单树 */
|
||||
menus: MenuTreeNode[];
|
||||
/** 用户拥有的权限编码列表 */
|
||||
permissions: string[];
|
||||
/** 是否为超级管理员 */
|
||||
isSuperAdmin: boolean;
|
||||
/** 权限数据是否已加载 */
|
||||
isLoaded: boolean;
|
||||
}
|
||||
|
||||
// 权限 Store Actions 类型
|
||||
export interface PermissionActions {
|
||||
/** 设置权限数据 */
|
||||
setPermissionData: (data: { menus: MenuTreeNode[]; permissions: string[]; isSuperAdmin: boolean }) => void;
|
||||
/** 清空权限数据 */
|
||||
clearPermissionData: () => void;
|
||||
/** 检查是否拥有指定权限(单个或多个,OR 关系) */
|
||||
hasPermission: (permission: string | string[]) => boolean;
|
||||
/** 检查是否拥有所有指定权限(AND 关系) */
|
||||
hasAllPermissions: (permissions: string[]) => boolean;
|
||||
}
|
||||
|
||||
// 权限 Store 完整类型
|
||||
export type PermissionStore = PermissionState & PermissionActions;
|
||||
|
||||
// 初始状态
|
||||
const initialState: PermissionState = {
|
||||
menus: [],
|
||||
permissions: [],
|
||||
isSuperAdmin: false,
|
||||
isLoaded: false,
|
||||
};
|
||||
|
||||
// 创建权限 Store
|
||||
export const usePermissionStore = create<PermissionStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setPermissionData: (data) => {
|
||||
set({
|
||||
menus: data.menus,
|
||||
permissions: data.permissions,
|
||||
isSuperAdmin: data.isSuperAdmin,
|
||||
isLoaded: true,
|
||||
});
|
||||
},
|
||||
|
||||
clearPermissionData: () => {
|
||||
set(initialState);
|
||||
},
|
||||
|
||||
hasPermission: (permission) => {
|
||||
const { isSuperAdmin, permissions } = get();
|
||||
|
||||
// 超级管理员拥有所有权限
|
||||
if (isSuperAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查权限(支持单个或多个,OR 关系)
|
||||
if (Array.isArray(permission)) {
|
||||
return permission.some((p) => permissions.includes(p));
|
||||
}
|
||||
|
||||
return permissions.includes(permission);
|
||||
},
|
||||
|
||||
hasAllPermissions: (requiredPermissions) => {
|
||||
const { isSuperAdmin, permissions } = get();
|
||||
|
||||
// 超级管理员拥有所有权限
|
||||
if (isSuperAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否拥有所有权限
|
||||
return requiredPermissions.every((p) => permissions.includes(p));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEYS.PERMISSION || 'permission-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
menus: state.menus,
|
||||
permissions: state.permissions,
|
||||
isSuperAdmin: state.isSuperAdmin,
|
||||
isLoaded: state.isLoaded,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// 便捷 Hook
|
||||
export const useMenus = () => usePermissionStore((state) => state.menus);
|
||||
export const usePermissions = () => usePermissionStore((state) => state.permissions);
|
||||
export const useIsSuperAdmin = () => usePermissionStore((state) => state.isSuperAdmin);
|
||||
export const useIsPermissionLoaded = () => usePermissionStore((state) => state.isLoaded);
|
||||
export const useHasPermission = () => usePermissionStore((state) => state.hasPermission);
|
||||
export const useHasAllPermissions = () => usePermissionStore((state) => state.hasAllPermissions);
|
||||
Reference in New Issue
Block a user