From a90b584e5354891f0c3317873dce0052bfe1746b Mon Sep 17 00:00:00 2001 From: senke Date: Sun, 5 Apr 2026 16:19:16 +0200 Subject: [PATCH] fix(security): protect admin routes with role check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, any authenticated user could access /admin, /admin/moderation, /admin/platform, /admin/transfers, and /admin/roles — the ProtectedRoute only checked isAuthenticated, not role. Exposed the admin Command Center UI to listeners/creators (critical security flaw). Changes: - ProtectedRoute accepts requireAdmin prop; redirects to /dashboard when authenticated user lacks admin/super_admin role or is_admin=true - New wrapAdminProtected() helper in routeConfig - All /admin/* routes now use wrapAdminProtected Note: Backend API still enforces admin checks independently — this fix only prevents the UI from being shown to non-admins. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/auth/ProtectedRoute.tsx | 14 ++++++++++-- apps/web/src/router/routeConfig.tsx | 22 ++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/auth/ProtectedRoute.tsx b/apps/web/src/components/auth/ProtectedRoute.tsx index f628e93fe..3eac73361 100644 --- a/apps/web/src/components/auth/ProtectedRoute.tsx +++ b/apps/web/src/components/auth/ProtectedRoute.tsx @@ -5,14 +5,17 @@ import { useEffect, useState } from 'react'; interface ProtectedRouteProps { children: React.ReactNode; + /** When true, requires the user to have role admin/super_admin or is_admin=true */ + requireAdmin?: boolean; } /** * Protects authenticated routes. Redirects to /login if not authenticated. + * If requireAdmin=true, redirects to /dashboard when user is not an admin. * Uses authStore.isAuthenticated (hydrated by AuthProvider via refreshUser with httpOnly cookies). */ -export function ProtectedRoute({ children }: ProtectedRouteProps) { - const { isAuthenticated } = useAuth(); +export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) { + const { isAuthenticated, user } = useAuth(); const [isChecking, setIsChecking] = useState(true); const { isLoading } = useAuthStore(); @@ -29,5 +32,12 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) { return ; } + if (requireAdmin) { + const isAdmin = user?.is_admin === true || user?.role === 'admin' || user?.role === 'super_admin'; + if (!isAdmin) { + return ; + } + } + return <>{children}; } diff --git a/apps/web/src/router/routeConfig.tsx b/apps/web/src/router/routeConfig.tsx index d2c659f83..61405eb7b 100644 --- a/apps/web/src/router/routeConfig.tsx +++ b/apps/web/src/router/routeConfig.tsx @@ -52,6 +52,7 @@ import { LazySupport, LazyLanding, } from '@/components/ui/LazyComponent'; +const LazyPrototype = React.lazy(() => import('@/features/prototype/PrototypePage')); import { PublicRoute } from './PublicRoute'; import { ProtectedLayoutRoute } from './ProtectedLayoutRoute'; import { useUser } from '@/features/auth/hooks/useUser'; @@ -87,6 +88,16 @@ function wrapProtected(element: React.ReactNode): React.ReactNode { ); } +function wrapAdminProtected(element: React.ReactNode): React.ReactNode { + return ( + + + {element} + + + ); +} + export function getPublicRoutes(): RouteEntry[] { return [ { path: '/login', element: wrapPublic() }, @@ -101,6 +112,7 @@ export function getPublicStandaloneRoutes(): RouteEntry[] { return [ { path: '/launch', element: }, { path: '/design-system', element: }, + { path: '/prototype/*', element: }, { path: '/u/:username', element: }, { path: '/playlists/shared/:token', element: }, ]; @@ -121,17 +133,17 @@ export function getProtectedRoutes(): RouteEntry[] { { path: '/profile', element: wrapProtected() }, { path: '/settings', element: wrapProtected() }, { path: '/settings/sessions', element: wrapProtected() }, - { path: '/admin/roles', element: wrapProtected() }, + { path: '/admin/roles', element: wrapAdminProtected() }, { path: '/tracks/:id', element: wrapProtected() }, { path: '/playlists/*', element: wrapProtected() }, { path: '/search', element: wrapProtected() }, { path: '/notifications', element: wrapProtected() }, { path: '/analytics', element: wrapProtected() }, { path: '/webhooks', element: wrapProtected() }, - { path: '/admin', element: wrapProtected() }, - { path: '/admin/moderation', element: wrapProtected() }, - { path: '/admin/platform', element: wrapProtected() }, - { path: '/admin/transfers', element: wrapProtected() }, + { path: '/admin', element: wrapAdminProtected() }, + { path: '/admin/moderation', element: wrapAdminProtected() }, + { path: '/admin/platform', element: wrapAdminProtected() }, + { path: '/admin/transfers', element: wrapAdminProtected() }, { path: '/social', element: wrapProtected() }, { path: '/feed', element: wrapProtected() }, { path: '/discover', element: wrapProtected() },