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:
parent
fc5c4fe99d
commit
a90b584e53
2 changed files with 29 additions and 7 deletions
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />) },
|
||||
|
|
|
|||
Loading…
Reference in a new issue