fix: handle role downgrade on protected pages

This commit is contained in:
yun-zhi-ztl 2026-03-17 11:28:00 +08:00
parent 43493fa63d
commit 99ff863681
6 changed files with 136 additions and 34 deletions

View file

@ -2,6 +2,7 @@ import { lazy, Suspense, type ComponentType } from 'react'
import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router'
import { Layout } from './layout'
import { getCurrentUser } from '@/api/client'
import { RoleGuard } from '@/shared/components/role-guard'
import { normalizeSearchQuery } from '@/shared/lib/search-query'
// Capture original URL before TanStack Router rewrites it
@ -34,6 +35,22 @@ function createLazyRouteComponent<TModule extends Record<string, unknown>>(
}
}
function createRoleProtectedRouteComponent<TModule extends Record<string, unknown>>(
importer: () => Promise<TModule>,
exportName: keyof TModule,
allowedRoles: readonly string[],
) {
const RouteComponent = createLazyRouteComponent(importer, exportName)
return function RoleProtectedRouteComponent(props: Record<string, unknown>) {
return (
<RoleGuard allowedRoles={allowedRoles}>
<RouteComponent {...props} />
</RoleGuard>
)
}
}
const HomePage = createLazyRouteComponent(() => import('@/pages/home'), 'HomePage')
const LoginPage = createLazyRouteComponent(() => import('@/pages/login'), 'LoginPage')
const RegisterPage = createLazyRouteComponent(() => import('@/pages/register'), 'RegisterPage')
@ -58,15 +75,25 @@ const NamespaceReviewsPage = createLazyRouteComponent(
'NamespaceReviewsPage',
)
const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage')
const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage')
const ReportsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reports'), 'ReportsPage')
const ReviewDetailPage = createLazyRouteComponent(
const ReviewsPage = createRoleProtectedRouteComponent(
() => import('@/pages/dashboard/reviews'),
'ReviewsPage',
['SKILL_ADMIN', 'NAMESPACE_ADMIN', 'SUPER_ADMIN'],
)
const ReportsPage = createRoleProtectedRouteComponent(
() => import('@/pages/dashboard/reports'),
'ReportsPage',
['SKILL_ADMIN', 'SUPER_ADMIN'],
)
const ReviewDetailPage = createRoleProtectedRouteComponent(
() => import('@/pages/dashboard/review-detail'),
'ReviewDetailPage',
['SKILL_ADMIN', 'NAMESPACE_ADMIN', 'SUPER_ADMIN'],
)
const PromotionsPage = createLazyRouteComponent(
const PromotionsPage = createRoleProtectedRouteComponent(
() => import('@/pages/dashboard/promotions'),
'PromotionsPage',
['SKILL_ADMIN', 'SUPER_ADMIN'],
)
const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage')
const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage')
@ -75,8 +102,16 @@ const SecuritySettingsPage = createLazyRouteComponent(
() => import('@/pages/settings/security'),
'SecuritySettingsPage',
)
const AdminUsersPage = createLazyRouteComponent(() => import('@/pages/admin/users'), 'AdminUsersPage')
const AuditLogPage = createLazyRouteComponent(() => import('@/pages/admin/audit-log'), 'AuditLogPage')
const AdminUsersPage = createRoleProtectedRouteComponent(
() => import('@/pages/admin/users'),
'AdminUsersPage',
['USER_ADMIN', 'SUPER_ADMIN'],
)
const AuditLogPage = createRoleProtectedRouteComponent(
() => import('@/pages/admin/audit-log'),
'AuditLogPage',
['AUDITOR', 'SUPER_ADMIN'],
)
function DefaultNotFound() {
return (
@ -227,13 +262,7 @@ const dashboardReviewsRoute = createRoute({
const dashboardReportsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard/reports',
beforeLoad: async (ctx) => {
const { user } = await requireAuth(ctx)
if (!user.platformRoles?.includes('SKILL_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) {
throw redirect({ to: '/dashboard' })
}
return { user }
},
beforeLoad: requireAuth,
component: ReportsPage,
})
@ -247,13 +276,7 @@ const dashboardReviewDetailRoute = createRoute({
const dashboardPromotionsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard/promotions',
beforeLoad: async (ctx) => {
const { user } = await requireAuth(ctx)
if (!user.platformRoles?.includes('SKILL_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) {
throw redirect({ to: '/dashboard' })
}
return { user }
},
beforeLoad: requireAuth,
component: PromotionsPage,
})
@ -305,26 +328,14 @@ const settingsAccountsRoute = createRoute({
const adminUsersRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'admin/users',
beforeLoad: async (ctx) => {
const { user } = await requireAuth(ctx)
if (!user.platformRoles?.includes('USER_ADMIN') && !user.platformRoles?.includes('SUPER_ADMIN')) {
throw redirect({ to: '/dashboard' })
}
return { user }
},
beforeLoad: requireAuth,
component: AdminUsersPage,
})
const adminAuditLogRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'admin/audit-log',
beforeLoad: async (ctx) => {
const { user } = await requireAuth(ctx)
if (!user.platformRoles?.includes('AUDITOR') && !user.platformRoles?.includes('SUPER_ADMIN')) {
throw redirect({ to: '/dashboard' })
}
return { user }
},
beforeLoad: requireAuth,
component: AuditLogPage,
})

View file

@ -969,6 +969,10 @@
"networkError": "Network connection failed, please check your network",
"unknown": "Operation failed"
},
"routeGuard": {
"forbiddenTitle": "You do not have permission to view this page",
"forbiddenDescription": "Your account permissions changed. Returning to the previous page."
},
"error": {
"auth": {
"local": {

View file

@ -969,6 +969,10 @@
"networkError": "网络连接失败,请检查网络",
"unknown": "操作失败"
},
"routeGuard": {
"forbiddenTitle": "没有权限访问该页面",
"forbiddenDescription": "当前账号权限已变更,正在返回上一页"
},
"error": {
"auth": {
"local": {

View file

@ -0,0 +1,50 @@
import { type ReactNode, useEffect, useRef } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@/features/auth/use-auth'
import { canAccessRoute, shouldNavigateBackOnForbidden } from '@/shared/lib/role-guard'
import { toast } from '@/shared/lib/toast'
interface RoleGuardProps {
allowedRoles: readonly string[]
children: ReactNode
}
export function RoleGuard({ allowedRoles, children }: RoleGuardProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const { user, isLoading } = useAuth()
const hasHandledForbiddenRef = useRef(false)
const isAllowed = canAccessRoute(user?.platformRoles, allowedRoles)
useEffect(() => {
if (isLoading || !user || isAllowed || hasHandledForbiddenRef.current) {
return
}
hasHandledForbiddenRef.current = true
toast.error(t('routeGuard.forbiddenTitle'), t('routeGuard.forbiddenDescription'))
if (shouldNavigateBackOnForbidden(window.history.length)) {
window.history.back()
return
}
void navigate({ to: '/dashboard', replace: true })
}, [isAllowed, isLoading, navigate, t, user])
if (isLoading) {
return (
<div className="flex min-h-[40vh] items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
)
}
if (!user || !isAllowed) {
return null
}
return <>{children}</>
}

View file

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { canAccessRoute, shouldNavigateBackOnForbidden } from './role-guard'
describe('canAccessRoute', () => {
it('returns true when the user has one of the required roles', () => {
expect(canAccessRoute(['USER', 'SKILL_ADMIN'], ['SKILL_ADMIN', 'SUPER_ADMIN'])).toBe(true)
})
it('returns false when the user does not have any required roles', () => {
expect(canAccessRoute(['USER'], ['SKILL_ADMIN', 'SUPER_ADMIN'])).toBe(false)
})
})
describe('shouldNavigateBackOnForbidden', () => {
it('returns true when there is browser history to go back to', () => {
expect(shouldNavigateBackOnForbidden(2)).toBe(true)
})
it('returns false when the current page is the first history entry', () => {
expect(shouldNavigateBackOnForbidden(1)).toBe(false)
})
})

View file

@ -0,0 +1,11 @@
export function canAccessRoute(userRoles: readonly string[] | undefined, requiredRoles: readonly string[]) {
if (!userRoles || userRoles.length === 0) {
return false
}
return requiredRoles.some((role) => userRoles.includes(role))
}
export function shouldNavigateBackOnForbidden(historyLength: number) {
return historyLength > 1
}