fix(security): protect admin routes with role check

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) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-05 16:19:16 +02:00
parent fc5c4fe99d
commit a90b584e53
2 changed files with 29 additions and 7 deletions

View file

@ -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 <Navigate to="/login" replace />;
}
if (requireAdmin) {
const isAdmin = user?.is_admin === true || user?.role === 'admin' || user?.role === 'super_admin';
if (!isAdmin) {
return <Navigate to="/dashboard" replace />;
}
}
return <>{children}</>;
}

View file

@ -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 (
<ProtectedRoute requireAdmin>
<ProtectedLayoutRoute>
<ErrorBoundary>{element}</ErrorBoundary>
</ProtectedLayoutRoute>
</ProtectedRoute>
);
}
export function getPublicRoutes(): RouteEntry[] {
return [
{ path: '/login', element: wrapPublic(<LazyLogin />) },
@ -101,6 +112,7 @@ export function getPublicStandaloneRoutes(): RouteEntry[] {
return [
{ path: '/launch', element: <ErrorBoundary><LazyLanding /></ErrorBoundary> },
{ path: '/design-system', element: <ErrorBoundary><LazyDesignSystemDemo /></ErrorBoundary> },
{ path: '/prototype/*', element: <ErrorBoundary><React.Suspense fallback={null}><LazyPrototype /></React.Suspense></ErrorBoundary> },
{ path: '/u/:username', element: <ErrorBoundary><LazyUserProfile /></ErrorBoundary> },
{ path: '/playlists/shared/:token', element: <ErrorBoundary><LazySharedPlaylistPage /></ErrorBoundary> },
];
@ -121,17 +133,17 @@ export function getProtectedRoutes(): RouteEntry[] {
{ path: '/profile', element: wrapProtected(<ProfileRedirect />) },
{ path: '/settings', element: wrapProtected(<LazySettings />) },
{ path: '/settings/sessions', element: wrapProtected(<LazySessions />) },
{ path: '/admin/roles', element: wrapProtected(<LazyRoles />) },
{ path: '/admin/roles', element: wrapAdminProtected(<LazyRoles />) },
{ path: '/tracks/:id', element: wrapProtected(<LazyTrackDetail />) },
{ path: '/playlists/*', element: wrapProtected(<LazyPlaylistRoutes />) },
{ path: '/search', element: wrapProtected(<LazySearch />) },
{ path: '/notifications', element: wrapProtected(<LazyNotifications />) },
{ path: '/analytics', element: wrapProtected(<LazyAnalytics />) },
{ path: '/webhooks', element: wrapProtected(<LazyWebhooks />) },
{ path: '/admin', element: wrapProtected(<LazyAdminDashboard />) },
{ path: '/admin/moderation', element: wrapProtected(<LazyAdminModeration />) },
{ path: '/admin/platform', element: wrapProtected(<LazyAdminPlatform />) },
{ path: '/admin/transfers', element: wrapProtected(<LazyAdminTransfers />) },
{ path: '/admin', element: wrapAdminProtected(<LazyAdminDashboard />) },
{ path: '/admin/moderation', element: wrapAdminProtected(<LazyAdminModeration />) },
{ path: '/admin/platform', element: wrapAdminProtected(<LazyAdminPlatform />) },
{ path: '/admin/transfers', element: wrapAdminProtected(<LazyAdminTransfers />) },
{ path: '/social', element: wrapProtected(<LazySocial />) },
{ path: '/feed', element: wrapProtected(<LazyFeed />) },
{ path: '/discover', element: wrapProtected(<LazyDiscover />) },