From 99ff863681721dbd0508d338a6233497f4d2f64c Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Tue, 17 Mar 2026 11:28:00 +0800 Subject: [PATCH] fix: handle role downgrade on protected pages --- web/src/app/router.tsx | 79 ++++++++++++++---------- web/src/i18n/locales/en.json | 4 ++ web/src/i18n/locales/zh.json | 4 ++ web/src/shared/components/role-guard.tsx | 50 +++++++++++++++ web/src/shared/lib/role-guard.test.ts | 22 +++++++ web/src/shared/lib/role-guard.ts | 11 ++++ 6 files changed, 136 insertions(+), 34 deletions(-) create mode 100644 web/src/shared/components/role-guard.tsx create mode 100644 web/src/shared/lib/role-guard.test.ts create mode 100644 web/src/shared/lib/role-guard.ts diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 3c35dc97..8adc18e4 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -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>( } } +function createRoleProtectedRouteComponent>( + importer: () => Promise, + exportName: keyof TModule, + allowedRoles: readonly string[], +) { + const RouteComponent = createLazyRouteComponent(importer, exportName) + + return function RoleProtectedRouteComponent(props: Record) { + return ( + + + + ) + } +} + 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, }) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 2c84ccad..a52c31cc 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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": { diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index dbdcea46..7d292485 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -969,6 +969,10 @@ "networkError": "网络连接失败,请检查网络", "unknown": "操作失败" }, + "routeGuard": { + "forbiddenTitle": "没有权限访问该页面", + "forbiddenDescription": "当前账号权限已变更,正在返回上一页" + }, "error": { "auth": { "local": { diff --git a/web/src/shared/components/role-guard.tsx b/web/src/shared/components/role-guard.tsx new file mode 100644 index 00000000..62ca6c38 --- /dev/null +++ b/web/src/shared/components/role-guard.tsx @@ -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 ( +
+ Loading... +
+ ) + } + + if (!user || !isAllowed) { + return null + } + + return <>{children} +} diff --git a/web/src/shared/lib/role-guard.test.ts b/web/src/shared/lib/role-guard.test.ts new file mode 100644 index 00000000..47379053 --- /dev/null +++ b/web/src/shared/lib/role-guard.test.ts @@ -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) + }) +}) diff --git a/web/src/shared/lib/role-guard.ts b/web/src/shared/lib/role-guard.ts new file mode 100644 index 00000000..033a7b0f --- /dev/null +++ b/web/src/shared/lib/role-guard.ts @@ -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 +}