从0到1搭建React从路由权限到按钮权限权限系统对于一个后台来说至关重要,权限管理是确保系统安全性、可靠性和稳定性的重
前言
权限系统对于一个后台来说至关重要,权限管理是确保系统安全性、可靠性和稳定性的重要手段。现在开源的后台框架一般都是自带权限系统,自己用过的有两个OPSLI
和Ant Design Pro
。
一直以来都是用别人东西(也是很香),我认为权限管理可以大致分两个部分
- 路由权限,路由权限一般来说是
基于角色的访问控制(Role-Based Access Control, RBAC
),是普通用户还是管理员还是超级管理员。 - 模块权限,大到
Section
,小到Button
,都是可以进行权限控制。这个可以按基于角色的访问控制(Role-Based Access Control, RBAC)
和基于属性的访问控制(Attribute-Based Access Control, ABAC)
都是可以的。基于属性的访问控制(ABAC)
提供了一种灵活而细粒度的权限管理方式。
基于角色的访问控制
这个好理解,基于属性的访问控制
那这个是什么。简单来说就是两个管理员账号,一个有删除用户的权限,一个没有删除用户的权限,这个可以归于用户属性,常见的还有环境属性等。
接下来将介绍如何从0到1搭建一个包含路由权限和按钮权限的React应用
简单的看下成果
文章后面有完整代码地址
正文
项目结构搭建
这里项目dome
演示采用Monorepo
的代码仓库结构,将前端项目和后端项目放在一个仓库里。前端使用React
,后端使用Nest
。
- 新建文件夹
auth
- 使用
pnpm init
初始化 - 创建
packages
文件夹 - 创建
pnpm-workspace.yaml
文件
packages:
- 'packages/*'
4. 在packages
文件夹下面分别使用命令创建后端和前端项目
nest new backend
pnpm create vite
5. 在根目录在package.json
下面添加两个命令,分别用来启动前端和后端项目。
"scripts": {
"start:frontend": "pnpm --filter frontend run start:dev",
"start:backend": "pnpm --filter backend run start:dev",
},
在前后端项目中也必须要有start:dev
这个命令。Nest
默认就有这个命令,修改前端项目的package.json
"scripts": {
"start:dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
完成后结构如下
后端环境搭建
这里就初略说一下干了什么事,重点不在这里。
- 创建3个模块,登录、用户和权限模块
登录模块:用户登录、对权限检查
用户:为登录模块提供服务,查询用户
权限模块:为登录模块提供服务,生成token
- 创建了一个全局拦截器,统一数据的返回格式
- 创建了一个守卫,用来检验
token
- 创建了全局的权限过滤器,对
token
错误时进行处理。
详细代码可以到仓库里面看。
权限管理思路
使用Provider
来做,在系统初始化时获取当前账号的角色权限
和属性权限
,并保存。
AuthProvider
如下,对账号的角色权限
、属性权限
和角色路由
进行管理,这样在全局都可以对权限进行使用和修改。
import { createContext, useEffect, useState } from 'react';
import { RouteItem } from '../../routes';
interface AuthContextType {
moduleAuth: string[];
roleAuth: string;
roleRouterList: RouteItem[];
changeRoleAuth: (roleAuth: string) => void;
changeModuleAuth: (authList: string[]) => void;
changeRoleRouterList: (roleRouterList: RouteItem[]) => void;
}
export const AuthContext = createContext<AuthContextType>({
moduleAuth: [],
roleAuth: '',
roleRouterList: [],
changeRoleRouterList: (roleRouterList: RouteItem[]) => {},
changeModuleAuth: (authList: string[]) => {},
changeRoleAuth: (roleAuth: string) => {},
});
interface Props {
children: React.ReactNode;
}
const AuthProvider = (props: Props) => {
const { children } = props;
/** 属性权限 */
const [moduleAuth, setModuleAuth] = useState<string[]>([]);
/** 角色权限 */
const [roleAuth, setRoleAuth] = useState<string>('');
/** 角色路由权限列表 */
const [roleRouterList, setRoleRouterList] = useState<RouteItem[]>([]);
return (
<AuthContext.Provider
value={{
moduleAuth,
roleAuth,
roleRouterList,
changeRoleRouterList: setRoleRouterList,
changeRoleAuth: setRoleAuth,
changeModuleAuth: setModuleAuth,
}}
>
{children}
</AuthContext.Provider>
);
};
export default AuthProvider;
为了方便使用,我们写一个useAuth
import React, { useContext } from 'react';
import { AuthContext } from './../AuthProvider'
export const useAuth = () => {
return useContext(AuthContext);
};
将根元素包裹
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</StrictMode>
);
到这里基本的框架有了。
路由权限
对于路由数据的来源有两种
(1). 路由数据写在项目中,一个角色权限对应着一个路由表。也可以是一个总的路由表,通过meta
中的字段控制路由权限。对于明确各个角色权限
可以使用这种。
(2). 后端返回路由数据,会更加灵活(可以随时修改角色的路由权限、添加角色
),这种相当于直接得到第一种处理后的数据。
本文讲的是第一种通过meta
中的字段控制路由权限。
这是我们的路由总表
const router: RouteItem[] = [
{
path: 'login',
element: <Login />,
},
{
path: '404',
element: <NotFound />,
},
{
path: '/',
element: <Navigation />,
children: [
{
path: 'home',
element: <Home />,
meta: {
title: '首页',
},
},
{
path: 'user',
meta: {
title: '用户管理',
},
children: [
{
index: true,
element: <Navigate replace to="list" />,
},
{
path: 'list',
element: <UserList />,
meta: {
title: '用户列表',
auth: ['admin'], // 该路由需要管理员权限
},
},
{
path: 'order',
element: <UserOrder />,
meta: {
title: '用户订单',
},
},
{
path: 'edit/:id',
element: <EditUser />,
meta: {
isHide: true, // 是否在菜单中隐藏
},
},
],
},
{
path: 'meal',
meta: {
title: '餐品管理',
},
children: [
{
index: true,
element: <Navigate replace to="list" />,
},
{
path: 'list',
element: <MealList />,
meta: {
title: '餐品列表',
},
},
{
path: 'order',
element: <MealOrder />,
meta: {
title: '餐品订单',
},
},
],
},
{
path: 'system',
meta: {
title: '系统设置',
auth: ['admin'], // 该路由需要管理员权限
},
children: [
{
index: true,
element: <Navigate replace to="auth" />,
},
{
path: 'auth',
element: <AuthSet />,
meta: {
title: '权限设置',
},
},
{
path: 'role',
element: <RoleSet />,
meta: {
title: '角色设置',
},
},
],
},
{
index: true,
element: <Navigate to="home" />, // 重定向到 /home
},
],
},
{
path: '*',
element: <Navigate replace to="/404" />,
},
];
这里又分为两种情况
- 从登录页进入
- 已登录
从登录页进入
登录时对角色权限
、属性权限
和角色路由
进行保存
const { changeRoleAuth, changeRoleRouterList, changeModuleAuth } = useAuth();
const onClick = () => {
axios({
method: 'post',
url: '/login',
data: {
name: user,
password: password,
},
})
.then((res: any) => {
if (res.code == 200) {
//保存token
localStorage.setItem('token', res.data.token);
//调用getRoleRouterList,获取账号对应的角色路由
let resultRouter = getRoleRouterList(user);
//保存角色路由权限列表
changeRoleRouterList(resultRouter);
//保存角色权限
changeRoleAuth(res.data.auth);
//保存属性权限
changeModuleAuth(res.data.authModule);
navigate(`/home`, {
replace: false,
});
} else {
messageApi.error(res.data.message);
}
})
.catch(err => {
messageApi.error('系统错误');
});
};
getRoleRouterList
方法的实现,获取账号对应的角色路由
这一步就已经有账号的权限
和角色路由
了,接下来只要渲染就行了
//App.tsx部分代码
const { roleRouterList, changeModuleAuth, changeRoleAuth, changeRoleRouterList } = useAuth();
const [routes, setRoutes] = useState<any>(router);
// 路由权限变化时更新路由
useEffect(() => {
if (roleRouterList.length > 0) {
setRoutes(roleRouterList);
}
}, [roleRouterList]);
if (isLoading) {
return <Loading />;
}
return (
<>
<Element router={routes} />
</>
);
const Element = (props: any) => {
const element = useRoutes(props.router);
return <>{element}</>;
};
已登录
对于已登录情况,只需要把token
给到后端,然后返回账号的角色权限
和属性权限
。
//App.tsx部分代码
const { roleRouterList, changeModuleAuth, changeRoleAuth, changeRoleRouterList } = useAuth();
const [isLoading, setLoading] = useState(true);
const [routes, setRoutes] = useState<any>(router);
let navigate = useNavigate();
const onInit = () => {
let token = localStorage.getItem('token');
//是否有token
if (!!token) {
//检验token是否有效
axios({
method: 'post',
url: '/login/check',
})
.then((res: any) => {
if (res.code === 200) {
let resultRouter = getRoleRouterList(res.data.auth);
setRoutes(resultRouter);
//保存角色权限
changeRoleAuth(res.data.auth);
//保存角色路由权限列表
changeRoleRouterList(resultRouter);
//保存属性权限
changeModuleAuth(res.data.authModule);
setLoading(false);
} else {
localStorage.clear();
navigate('/login');
setLoading(false);
}
})
.catch(err => {
setLoading(false);
});
} else {
//没有token,返回登录页面
setLoading(false);
navigate('/login');
localStorage.clear();
}
};
// 初始化
useEffect(() => {
onInit();
}, []);
if (isLoading) {
return <Loading />;
}
return (
<>
<Element router={routes} />
</>
);
属性权限
因为我们使用Provider
包裹根元素,所以在全局可以访问权限。我们只需要写一个组件,根据账号的属性权限
来控制显示还是消失就好了
AuthModule
组件
AuthModule
组件的使用
<AuthModule module={['userAction']}>
<Button>删除用户</Button>
<Button>添加用户</Button>
</AuthModule>
<AuthModule
module={['userAction']}
role={['admin']}
>
<Button>删除用户</Button>
<Button>添加用户</Button>
</AuthModule>
左侧导航栏渲染
我们现在已经有角色的权限路由
了,我们只要对权限路由
进行处理得到菜单列表数据。
处理出来的数据,直接给antd
的Menu
组件就可以了
<Menu
mode="inline"
theme="dark"
selectedKeys={selectMenu}
defaultOpenKeys={defaultOpenKeys}
inlineCollapsed={false}
items={navigationList}
onClick={clickMenu}
style={{
height: '100%',
}}
/>
结果如下
问题补充
token失效跳转登录
设置响应拦截,对token
失效的情况进行处理
axios.interceptors.response.use(
response => {
return response.data;
},
error => {
message.error(error.response.data.message);
//token失效
if (error.response.status === 401) {
navigateTo('/login');
localStorage.clear();
}
return Promise.reject(error);
}
);
当前页刷新时,菜单选中和父级展开
- 菜单选中,我们在获取菜单列表数据时,对路径进行了拼接。并设置为
menu
的key
,所以很容易,只要取当前的pathname
/** 选中的菜单项 */
const selectMenu = useMemo(() => {
let path = location.pathname;
return [path];
}, [location]);
- 父级展开,我们只要设置
Menu
组件上的defaultOpenKeys
属性就行了,但是要注意defaultOpenKeys
要先有值,而不是页面渲染后defaultOpenKeys
再有值。所以使用isLoading
控制Menu
组件的显示就行了
let [isLoading, setIsLoading] = useState(true);
let [defaultOpenKeys, setDefaultOpenKeys] = useState<string[]>([]);
/** 刷新页面时,展开当前页面所在的菜单项 */
useEffect(() => {
if (!isLoading) {
return;
}
let path = location.pathname;
const fn = (list: any[], keys: string[]) => {
for (let i = 0; i < list.length; i++) {
let tempKeys = [...keys];
let item = list[i];
if (item.key === path) {
setDefaultOpenKeys(tempKeys);
break;
}
if (item.children && item.children.length > 0) {
tempKeys.push(item.key);
fn(item.children, tempKeys);
}
}
};
fn(navigationList, []);
setIsLoading(false);
}, [navigationList]);
结语
感兴趣的可以去试试
转载自:https://juejin.cn/post/7413335608464097319