成为全栈·React 管理后台篇·按钮级权限:能力映射、菜单过滤与自锁保护
前端权限的价值是让用户只看到能够完成的操作,并在高风险场景阻止明显误操作。真正的安全边界仍在后端。

前言
权限功能很容易从一行判断开始:user.role === 'admin'。用户管理菜单里写一次,路由里写一次,重置密码按钮再写一次。后来产品允许 editor 查看某个页面,三处条件只改了两处,于是菜单能点、路由却跳 403;或者直接输入地址能进,页面按钮又全部消失。
问题不是条件复杂,而是同一个能力被角色字符串重复描述。当前后台把角色到能力的映射收进纯函数,让菜单、路由和按钮消费同一判据,再为"管理员禁用自己"这种业务风险增加独立护栏。
从角色判断改成能力提问
角色是后端授予的身份,能力是页面正在询问的动作。组件真正关心的是"能否管理用户",而不是"是不是 admin":
ts
export const canManageUsers = (actor: Actor) =>
roleAtLeast(actor?.role, 'admin')
export const canManageArticles = (actor: Actor) =>
roleAtLeast(actor?.role, 'editor')
export const canForceArticleStatus = (actor: Actor) =>
roleAtLeast(actor?.role, 'admin')
今天这些能力恰好映射到角色等级。将来 editor 可以只读用户列表时,应该增加 canViewUsers,而不是让组件到处猜新的角色组合。
能力函数还在注释中对应具体契约端点。这样权限变更时,可以从后端 x-authz 追到前端入口,而不是凭页面名称推测。

菜单、路由和按钮各管一层体验
三处检查并不重复,它们分别解决不同问题:
- 菜单过滤:不展示必然无法使用的入口。
- 路由守卫:用户直接输入地址时给出明确去向。
- 按钮门控:同一页面内隐藏无权执行的具体动作。
菜单项直接挂能力函数:
ts
{ to: '/articles', label: '文章管理', can: canManageArticles }
路由使用同一个判据:
tsx
<RequireCan can={canManageUsers}>
<UserListPage />
</RequireCan>
按钮则通过 Can 组件声明能力:
tsx
<Can name="resetPassword">
<Button>重置密码</Button>
</Can>
Can 内部只有一张能力映射表。业务组件没有角色字符串,也不需要知道等级算法。
归属权限不能只靠最低角色
有些端点允许 editor 操作所有资源,同时允许普通用户操作自己的资源。这个规则不是简单的 minRole:
ts
export const canOperateOwned = (
actor: Actor,
ownerId: number | undefined,
min: UserRole,
) => {
if (!actor) return false
if (roleAtLeast(actor.role, min)) return true
return ownerId !== undefined && actor.id === ownerId
}
组件可以传入 ownerId 与 min,让"本人或 editor"只在一处解释。需要警惕的是资源列表可能没有返回 ownerId;此时前端不能为了显示按钮猜测所有权,应选择不展示,或补充契约字段。
自锁保护不是 RBAC
admin 有权禁用用户,但如果编辑对象就是自己,允许选择"禁用"会把唯一管理员踢出后台。后端可能允许这个请求,因为从角色上看它完全合法;前端仍应阻止明显的高风险误操作。
ts
const currentUser = useAuthStore((s) => s.user)
const isSelf = currentUser?.id === user?.id
// 编辑自己时禁用 inactive 选项
这叫业务护栏,不是新增权限。类似规则还包括不能删除正在使用的标签、不能在文章有未保存修改时直接下架。权限回答"有没有资格",护栏回答"此刻执行是否会造成明显问题"。
高风险动作还需要确认、清楚后果和进行中锁定。只把按钮染红,不足以阻止连续点击或误解操作结果。
前端隐藏按钮不构成安全
浏览器里的 JavaScript、路由和 store 都由用户控制。攻击者可以修改状态、调用控制台或直接构造 HTTP 请求。因此:
- 后端必须验证 token、角色、资源所有权和当前状态;
- 前端判据必须以契约为依据,但只负责体验;
- 收到 403 时仍要正常展示错误,不能认为"按钮隐藏了就不会发生"。
前端权限测试的目标,是避免误导正常用户。例如 editor 不应看到用户管理入口,直接访问 /users 应得到 403 页面,admin 应能看到重置密码按钮。后端测试才证明越权请求确实被拒绝。
权限不足时,隐藏还是禁用
如果用户永远没有某项能力,隐藏通常更干净;如果动作暂时不可用,禁用并解释原因更合适。editor 永远不能管理站点设置,因此菜单隐藏;admin 正在保存用户时按钮暂时禁用;标签仍有文章引用时删除按钮禁用并提示原因。
把权限不足按钮全部禁用,会让界面布满无法操作的控件;把暂时不可用按钮全部隐藏,又会让布局跳动,用户不知道功能去哪了。两者要按原因区分。
能力粒度要跟业务动作一致
"管理文章"并不能覆盖所有文章动作。editor 可以查看后台文章、编辑和审核,但任意强改状态只允许 admin。因此代码同时存在 canManageArticles 与 canForceArticleStatus。
若只设计一个 article:write,要么给 editor 暴露下架等越权按钮,要么让 admin 也失去专属动作。能力不必细到每个 DOM 元素,却应在权限规则或业务后果不同的地方拆开。
同理,canManageUsers 和 canResetPassword 目前都要求 admin,但仍是两个能力。未来可能允许客服查看用户,却不允许重置密码;现在保留动作语义,届时不需要重写页面结构。
一张实用的能力表可以这样审阅:
| 能力 | editor | admin | 主要入口 |
|---|---|---|---|
| manageArticles | 是 | 是 | 文章菜单、列表、编辑路由 |
| moderateComments | 是 | 是 | 评论审核 |
| manageCategories / Tags | 是 | 是 | 内容组织 |
| manageUsers / resetPassword | 否 | 是 | 用户管理 |
| manageSiteSettings | 否 | 是 | 站点设置 |
| forceArticleStatus | 否 | 是 | 已发布文章下架 |
它不是另建一份配置真相源,而是对 permission.ts 和契约端点的审阅视图。真正的映射仍保留在可测试的函数中。
会话中的角色发生变化怎么办
管理员可能在另一个窗口修改当前用户角色,或者账号在会话期间被禁用。前端内存里的 user 不会凭空知道变化。下一次受保护请求由后端返回 403 或账号禁用错误,请求层再刷新会话或强制退出。
因此能力函数只能基于"当前已知身份"改善界面,不能承诺权限实时有效。对高风险系统,可以增加短轮询、服务端推送或每次恢复时重新获取 /auth/me;无论如何,请求时的后端判断才是最终结论。
这也解释了为什么收到 403 后不能只在控制台打印错误。页面需要展示可理解的权限变化提示,并失效可能依赖用户角色的查询,避免用户不断点击一个已经失效的入口。
如何验证三层没有漂移
最小权限验收应使用 editor 和 admin 两个真实角色,逐项检查:菜单是否出现、直接地址是否放行、页面按钮是否正确、最终 API 是否允许。只做组件快照无法覆盖路由与服务端。
能力函数本身适合纯函数测试,例如空用户、member、editor、admin,以及本人资源与他人资源的组合。测试的意义是钉住契约映射,而不是证明隐藏按钮能够保证安全。
还有一种常见漂移来自菜单配置和路由配置分离:新增页面时只加了其中一处。代码审阅应把"入口、地址、能力"视为一组变更;浏览器验收则从菜单点击和直接地址两条路径分别进入。对于完全隐藏的 admin 页面,更要用 editor 手工输入地址验证守卫,而不是因为菜单里看不见就算通过。
无权限页面也要提供可行动的去向。未登录回登录页,角色不能进入后台去 no-access,单个能力不足去 403 并允许返回。把所有情况都重定向首页,会让用户误以为链接损坏,也让权限配置错误难以排查。
小结
当前后台用能力函数维护角色映射,让菜单、路由和按钮共用判据;所有权规则收进独立函数;自锁、引用占用等风险由业务护栏处理。三层体验可以互相补位,却都不替代后端授权。
各位看官,权限审阅时请不要只换账号看菜单。还要直接输入路由、检查页面动作,并实际向后端发送越权请求。入口一致、反馈清楚、服务端拒绝,才是一条完整权限链。
延伸阅读
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
