react路由根据用户角色设置权限校验
前言
做前端项目的时候,我们经常会有这样的情况:一个系统会有多种权限不同的角色,每个角色都有自己能访问的模块角色间能访问的模块(页面)并不完全相同,因此我们经常会有根据不同的角色管理不同的给=每个路由不同的访问权限的需求。
思路
- 方法1: 先通过http请求获取用户信息,拿到用户权限角色,然后根据不同的角色去动态的生成路由(Routes),该方法实际是可行的,但是有个缺点,比如我们有路由,路径为/a/b,该路由我们规定
SYS_ADMIN
能访问而其他角色不能访问,那么我们由于是根据权限生成路由然后传参给useRoute
这个hook的,因此会导致 其他用户访问的时候会显示是404找不到该页面。
- 方法2: 在每个路由组件中先判断用户的权限在渲染对应的内容,该方法同样可行,问题是这样很麻烦,需要在每个路由组建中去写权限控制逻辑。
- 方法3:类似于
vue-router
的router.beforeEach
钩子,我们在路由表中每个路由都定义自己可以被访问的权限,然后在每次路由放生变化的时候先判断该用户是否有权限访问,如果有权限就返回对应的路由组件,否则就给到对应的提示信息(也可以是返回一个用于提示没有权限的组件)。这样做的优点就是我们只需要写一套逻辑,一劳永逸,后边添加新的模块的时候只需要在路由表里给到对应的权限即可。
实现
我的习惯就是先枚举出所有的路由路径(因为通常情况下该路径会被多个地方使用,比如左侧菜单兰,header的面包屑等等)。
// route.config.ts
export enum Pathnames {
Login = "/login",
SignUpPage = "/signup",
ResetPassword = "/login/resetPassword",
Dashboard = "",
UserPage = "/user",
Profile = "/user/profile",
AdminManagement = "/user/admin-management",
OrgnizationManagement = "/user/orgnization",
RegistTemplate = "/reg-template",
CreateRegTemplate = "/reg-template/create",
EditRegTemplate = "/reg-template/:id",
Geofence = "/geofence",
CreateGeofence = "/geofence/create",
GeofenceDetail = "/geofence/:id",
Pipelines = "/pipelines",
EditPipeline = "/pipelines/edit/:id",
ViewPipelineDetail = "/pipelines/view/:id",
ViewPipelineRunDetail = "/pipelines/view/:pipelineId/runs/:runId",
Devices = "/device-management",
GroupDetail = "/groups/:id",
DeviceDetail = "/devices/:id",
SettingsTemplate = "/settings-template",
CreateSettingsTemplate = "/settings-template/create",
EditSettingsTemplate = "/settings-template/:id",
OtaUpgrade = "/ota",
Applications = "/application",
FileManagement = "/file",
MainLayout = "/",
TenantManagement = "/tenants",
LicenseManagement = "/licenses",
NotFound = "*",
}
然后定义出系统所有的角色
// user.model.ts
export enum Authority {
TENANT_ADMIN = "TENANT_ADMIN",
GENERAL_ADMIN = "GENERAL_ADMIN",
OBSERVE_ADMIN = "OBSERVE_ADMIN",
SYS_ADMIN = "SYS_ADMIN",
GROUP_ADMIN = "GROUP_ADMIN",
GROUP_OBSERVE_ADMIN = "GROUP_OBSERVE_ADMIN",
}
export const GENARAL_AUTHORITY = [Authority.TENANT_ADMIN, Authority.GENERAL_ADMIN, Authority.GROUP_ADMIN, Authority.OBSERVE_ADMIN, Authority.GROUP_OBSERVE_ADMIN];
export const TENANT_AND_GENERAL = [Authority.TENANT_ADMIN, Authority.GENERAL_ADMIN];
export const NOT_GROUP_ADMIN = [Authority.TENANT_ADMIN, Authority.GENERAL_ADMIN, Authority.OBSERVE_ADMIN];
export const NOT_OBSERVE_ADMIN = [Authority.TENANT_ADMIN, Authority.GENERAL_ADMIN, Authority.GROUP_ADMIN];
export const ALL_AUTHORITY = [Authority.TENANT_ADMIN, Authority.GENERAL_ADMIN, Authority.OBSERVE_ADMIN, Authority.SYS_ADMIN, Authority.GROUP_ADMIN, Authority.GROUP_OBSERVE_ADMIN];
接着就是生成所有的路由表(因为我使用的react-router-dom的版本时6.x,因此我选择使用useRoutes
钩子来生成路由组件)
// routes/MainRoute.tsx
import { RouteObject, useNavigate } from "react-router-dom";
import { ALL_AUTHORITY, Authority, GENARAL_AUTHORITY, NOT_GROUP_ADMIN, NOT_OBSERVE_ADMIN, TENANT_AND_GENERAL } from "../models/user.model";
import { useEffect } from "react";
import LazyImport from "../components/common/tools/LazyImport";
import { Pathnames } from "./route.config";
const TenantManagement = LazyImport(() => import("../pages/TenantManagement"));
const LicenseManagement = LazyImport(() => import("../pages/LicenseManagement"));
const NotFound = LazyImport(() => import("../components/common/tools/NotFound"));
const Dashboard = LazyImport(() => import("../components/dashboard/Dashboard"));
const MainLayout = LazyImport(() => import("../components/layouts/MainLayout"));
const Login = LazyImport(() => import("../components/system/Login"));
const SignUpPage = LazyImport(() => import("../components/system/SignUpPage"));
const RegistTemplate = LazyImport(() => import("../pages/RegistTemplate"));
const CreateRegTemplate = LazyImport(() => import("../components/regist-template/CreateRegTemplate"));
const Devices = LazyImport(() => import("../pages/Devices"));
const SettingsTemplate = LazyImport(() => import("../pages/SettingsTemplate"));
const CreateSettingsTemplate = LazyImport(() => import("../components/settings-template/CreateSettingsTemplate"));
const Applications = LazyImport(() => import("../pages/Applications"));
const FileManagement = LazyImport(() => import("../pages/FileManagement"));
const GroupDetail = LazyImport(() => import("../components/devices/config/GroupDetail"));
const DeviceDetail = LazyImport(() => import("../components/devices/config/DeviceDetail"));
const UserPage = LazyImport(() => import("../pages/UserPage"));
const Profile = LazyImport(() => import("../components/admin/Profile"));
const AdminManagement = LazyImport(() => import("../components/admin/AdminManagement"));
const OrgnizationManagement = LazyImport(() => import("../components/admin/OrgnizationManagement"));
const ResetPassword = LazyImport(() => import("../components/common/tools/ResetPassword"));
const Geofence = LazyImport(() => import("../pages/Geofence"));
const CreateGeofence = LazyImport(() => import("../components/geofence/CreateGeofence"));
const ViewGeofenceDetail = LazyImport(() => import("../components/geofence/ViewGeofenceDetail"));
const Pipelines = LazyImport(() => import("../pages/Pipelines"));
const EditPipeline = LazyImport(() => import("../components/pipelines/EditPipeline"));
const ViewPipelineDetail = LazyImport(() => import("../components/pipelines/ViewPipelineDetail"));
const ViewPipelineRunDetail = LazyImport(() => import("../components/pipelines/ViewPipelineRunDetail"));
// 自定义react-router v5之前的Redirect组件
export function Redirect({ to }) {
let navigate = useNavigate();
useEffect(() => {
navigate(to);
});
return null;
}
export interface RouteWithArgs extends RouteObject {
path: Pathnames;
auth?: Authority[];
children?: RouteWithArgs[];
}
export const allRoutes: RouteWithArgs[] = [
{
element: Login,
path: Pathnames.Login,
},
{
element: SignUpPage,
path: Pathnames.SignUpPage,
},
{
element: ResetPassword,
path: Pathnames.ResetPassword,
},
{
element: MainLayout,
path: Pathnames.MainLayout,
auth: GENARAL_AUTHORITY,
children: [
{
element: Dashboard,
path: Pathnames.Dashboard,
auth: ALL_AUTHORITY,
},
{
element: UserPage,
path: Pathnames.UserPage,
auth: [Authority.TENANT_ADMIN],
children: [
{
element: <Redirect to={"/user/profile"}></Redirect>,
path: Pathnames.UserPage,
},
{
element: Profile,
path: Pathnames.Profile,
},
{
element: AdminManagement,
path: Pathnames.AdminManagement,
},
{
element: OrgnizationManagement,
path: Pathnames.OrgnizationManagement,
},
],
},
{
element: RegistTemplate,
path: Pathnames.RegistTemplate,
auth: GENARAL_AUTHORITY,
},
{
element: CreateRegTemplate,
path: Pathnames.CreateRegTemplate,
auth: TENANT_AND_GENERAL,
},
{
element: CreateRegTemplate,
path: Pathnames.EditRegTemplate,
auth: TENANT_AND_GENERAL,
},
{
element: Geofence,
path: Pathnames.Geofence,
auth: NOT_GROUP_ADMIN,
},
{
element: CreateGeofence,
path: Pathnames.CreateGeofence,
auth: TENANT_AND_GENERAL,
},
{
element: ViewGeofenceDetail,
path: Pathnames.GeofenceDetail,
auth: GENARAL_AUTHORITY,
},
{
element: Pipelines,
path: Pathnames.Pipelines,
auth: GENARAL_AUTHORITY,
},
{
element: EditPipeline,
path: Pathnames.EditPipeline,
auth: GENARAL_AUTHORITY,
},
{
element: ViewPipelineDetail,
path: Pathnames.ViewPipelineDetail,
auth: GENARAL_AUTHORITY,
},
{
element: ViewPipelineRunDetail,
path: Pathnames.ViewPipelineRunDetail,
auth: GENARAL_AUTHORITY,
},
{
element: Devices,
path: Pathnames.Devices,
auth: GENARAL_AUTHORITY,
},
{
element: GroupDetail,
path: Pathnames.GroupDetail,
auth: NOT_OBSERVE_ADMIN,
},
{
element: DeviceDetail,
path: Pathnames.DeviceDetail,
auth: GENARAL_AUTHORITY,
},
{
element: SettingsTemplate,
path: Pathnames.SettingsTemplate,
auth: GENARAL_AUTHORITY,
},
{
element: CreateSettingsTemplate,
path: Pathnames.CreateSettingsTemplate,
auth: TENANT_AND_GENERAL,
},
{
element: CreateSettingsTemplate,
path: Pathnames.EditSettingsTemplate,
auth: TENANT_AND_GENERAL,
},
{
element: Applications,
path: Pathnames.Applications,
auth: GENARAL_AUTHORITY,
},
{
element: FileManagement,
path: Pathnames.FileManagement,
auth: GENARAL_AUTHORITY,
},
],
},
{
element: MainLayout,
path: Pathnames.MainLayout,
auth: [Authority.SYS_ADMIN],
children: [
{
element: <Redirect to="/tenants" />,
auth: [Authority.SYS_ADMIN],
path: Pathnames.Dashboard,
},
{
element: TenantManagement,
path: Pathnames.TenantManagement,
auth: [Authority.SYS_ADMIN],
},
{
element: LicenseManagement,
path: Pathnames.LicenseManagement,
auth: [Authority.SYS_ADMIN],
},
{
element: UserPage,
path: Pathnames.UserPage,
auth: [Authority.SYS_ADMIN],
children: [
{
element: <Redirect to={"/user/profile"}></Redirect>,
path: Pathnames.UserPage,
},
{
element: Profile,
path: Pathnames.Profile,
},
],
},
],
},
{
element: NotFound,
path: Pathnames.NotFound,
},
];
export function createRoutes(auth: Authority): RouteWithArgs[] {
return allRoutes.filter((route) => !route.auth || route.auth.includes(auth));
}
我给每个路由都添加了权限信息(auth字段,没有该字段代表不需要权限就能访问),数据类型为Authority的数组集合。
然后在编写路由组件
// routes/Index.tsx
import { useDispatch, useSelector } from "react-redux";
import { useCallback, useEffect, useMemo } from "react";
import { useLocation, useRoutes } from "react-router-dom";
import { createRoutes } from "./MainRoute";
import { setLicenseInfoAction, setUserFetchStatus, setWidth } from "../store/actions/config.action";
import { selectAuthority } from "../store/selectors";
import { Store } from "../models/store.model";
import CommonLoading from "../components/common/tools/CommonLoading";
import { userController } from "../controllers/user.controller";
import { licenseController } from "../controllers/license.controller";
import Login from "../components/system/Login";
import * as debounce from "debounce";
import { decryptExpireTime } from "../utils";
const Routes = () => {
const auth = useSelector(selectAuthority);
const routes = useMemo(() => {
return createRoutes(auth);
}, [auth]);
const locationData = useLocation();
const dispatch = useDispatch();
const getLicenseExpireTime = useCallback(() => {
const result = licenseController.getLicenseExpireTime();
result
.then((res) => {
localStorage.setItem("licenseExpire", res);
let expireTimeStamp = decryptExpireTime(res);
if (expireTimeStamp === -1) {
const existLicense = false;
dispatch(setLicenseInfoAction({ existLicense }));
} else {
const existLicense = true;
const licenseExpire = expireTimeStamp > Date.now() ? false : true;
dispatch(setLicenseInfoAction({ existLicense, licenseExpire }));
}
})
.catch(() => {});
return result;
}, [dispatch]);
// 一进来首先校验token
const checkUserInfo = useCallback(() => {
userController
.validateJwtToken()
.then(() => {
getLicenseExpireTime().finally(() => {
dispatch(setUserFetchStatus(true));
});
})
.catch(() => {
dispatch(setUserFetchStatus(true));
});
}, [dispatch, getLicenseExpireTime]);
// 设置屏幕宽度监听器
const watchWindowWidthDebounce = useMemo(() => {
return function () {
dispatch(setWidth(window.innerWidth, window.innerHeight));
window.onresize = debounce(() => {
dispatch(setWidth(window.innerWidth, window.innerHeight));
}, 300);
};
}, [dispatch]);
useEffect(() => {
watchWindowWidthDebounce();
checkUserInfo();
}, [checkUserInfo, watchWindowWidthDebounce]);
const getUserInfoFinish = useSelector((state: Store) => state.config.getUserInfoFinish);
const RouteComponent = useRoutes(routes);
// 通过useRoutes传入配置的路由表来生成路由组建,因此暴露出去的Routes其实就是一个路由组件
if (locationData.pathname === "/login") {
return <Login />;
} else if (getUserInfoFinish) {
return RouteComponent;
} else {
return <CommonLoading title={null}></CommonLoading>;
}
};
export default Routes;
由于我们所有的需要权限控制的页面都在MainLayout.tsx组件中,因此我们在这里去做权限控制(实际上直接在路由组件Index.tsx中也是可以的)
import { Box } from "@mui/material";
import { memo, useMemo } from "react";
import { useSelector } from "react-redux";
import { matchRoutes, Navigate, Outlet, RouteMatch, useLocation } from "react-router-dom";
import { Store } from "../../models/store.model";
import { isMobile, selectAuthority, selectUserLoginStatus } from "../../store/selectors";
import CommonLoading from "../common/tools/CommonLoading";
import UploadLicense from "../layouts/UploadLicense";
import Content from "./Content";
import HeaderBar from "./HeaderBar";
import NavMenu from "./NavMenu";
import NavMenuDrawer from "./NavMenuDrawer";
import "leaflet.markercluster";
import "leaflet.markercluster/dist/MarkerCluster.css";
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
import { allRoutes, RouteWithArgs } from "../../routes/MainRoute";
import { Pathnames } from "../../routes/route.config";
import NoPermissionPage from "../common/tools/NoPermissionPage";
const MainLayout = memo(() => {
const state = useSelector((state: Store) => state.config);
const _isMobile = useSelector(isMobile);
const { isLogin, licenseValid } = useSelector(selectUserLoginStatus);
const auth = useSelector(selectAuthority);
const { getUserInfoFinish, collapsed } = state;
const { pathname } = useLocation();
const hasPermission = useMemo(() => {
// 判断用户是否有权限访问该页面
const matchedRoutes: RouteMatch<Pathnames>[] = matchRoutes(allRoutes, pathname);
if (!matchedRoutes?.length) {
return;
}
// 这里通过matchedRoutes中的auth和当前用户的auth来对比判断是否有权限访问
const matchedRoute = matchedRoutes[matchedRoutes.length - 1];
// @ts-ignore
const route: RouteWithArgs = matchedRoute?.route;
const allowedAuth = route?.auth;
return !allowedAuth || allowedAuth.includes(auth);
}, [pathname, auth]);
const contentWidth = useMemo(() => {
if (_isMobile) {
return "100%";
}
return collapsed && !_isMobile ? "calc(100% - 240px)" : "calc(100% - 80px)";
}, [collapsed, _isMobile]);
if (!getUserInfoFinish) {
// 如果还在获取用户信息阶段 就展示loading界面
return <CommonLoading></CommonLoading>;
} else if (isLogin && licenseValid) {
// 如果用户权限校验通过
return (
<Box
sx={{
width: "100%",
height: "100%",
display: "flex",
justifyContent: "space-between",
}}
>
{_isMobile ? <NavMenuDrawer collapsed={collapsed}></NavMenuDrawer> : <NavMenu></NavMenu>}
<Box sx={{ height: 1 / 1, width: contentWidth, transition: "width .3s" }}>
<HeaderBar></HeaderBar>
{/* 判断用户是否有权限访问该页面,如果没有权限就渲染没有权限的页面 */}
<Content>{hasPermission ? <Outlet /> : <NoPermissionPage />}</Content>
</Box>
</Box>
);
} else if (!licenseValid) {
// 如果用户license过期
return <UploadLicense></UploadLicense>;
} else {
// 如果用户权限校验不通过 就跳转到登录页面
return <Navigate to="/login" />;
}
});
export default MainLayout;
我们在hasPermission方法中实现了判断有没有权限的逻辑
(<Content>{hasPermission ? <Outlet /> : <NoPermissionPage />}</Content>
),
然后再根据hasPermission的值返回对应的的结果,即:
如果hasPermission为true,代表有权限,返回路由组件<Outlet />
,反之返回没有权限的提示组件
在该组件中我们即可提示用户没有权限:
import { Box, Typography, Button } from "@mui/material";
import { useTranslation } from "react-i18next";
import MatDialog from "./MatDialog";
import { useNavigate } from "react-router-dom";
export default function NoPermissionPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const navigateToHome = () => navigate("/");
return (
<Box>
<MatDialog open={true} hideFooter onClose={null} size="sm" title={null}>
<Box className="flex flex-column">
<Typography variant="h3">{t("user.noPermissionForThisPage")}</Typography>
<Button sx={{ mt: 4, mb: 2 }} variant="contained" onClick={navigateToHome}>
{t("common.backHome")}
</Button>
</Box>
</MatDialog>
</Box>
);
}
效果:
比如对于同一页面,有权限的角色访问:
无权限的角色访问: