mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-12 23:01:05 +00:00
fix: handle role downgrade on protected pages
This commit is contained in:
parent
43493fa63d
commit
99ff863681
6 changed files with 136 additions and 34 deletions
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -969,6 +969,10 @@
|
|||
"networkError": "网络连接失败,请检查网络",
|
||||
"unknown": "操作失败"
|
||||
},
|
||||
"routeGuard": {
|
||||
"forbiddenTitle": "没有权限访问该页面",
|
||||
"forbiddenDescription": "当前账号权限已变更,正在返回上一页"
|
||||
},
|
||||
"error": {
|
||||
"auth": {
|
||||
"local": {
|
||||
|
|
|
|||
50
web/src/shared/components/role-guard.tsx
Normal file
50
web/src/shared/components/role-guard.tsx
Normal 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}</>
|
||||
}
|
||||
22
web/src/shared/lib/role-guard.test.ts
Normal file
22
web/src/shared/lib/role-guard.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
11
web/src/shared/lib/role-guard.ts
Normal file
11
web/src/shared/lib/role-guard.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue