307 lines
8.9 KiB
TypeScript
307 lines
8.9 KiB
TypeScript
/**
|
|
* Protected Route component for handling authentication and authorization
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { Navigate, useLocation } from 'react-router-dom';
|
|
import { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores';
|
|
import { RouteConfig, canAccessRoute, ROUTES } from './routes.config';
|
|
|
|
interface ProtectedRouteProps {
|
|
children: React.ReactNode;
|
|
route?: RouteConfig;
|
|
fallback?: React.ReactNode;
|
|
redirectTo?: string;
|
|
}
|
|
|
|
interface LoadingSpinnerProps {
|
|
message?: string;
|
|
}
|
|
|
|
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ message = 'Cargando...' }) => (
|
|
<div className="flex items-center justify-center min-h-screen bg-bg-primary">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<div className="spinner spinner-lg"></div>
|
|
<p className="text-text-secondary text-sm">{message}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const UnauthorizedPage: React.FC = () => (
|
|
<div className="flex items-center justify-center min-h-screen bg-bg-primary">
|
|
<div className="text-center max-w-md mx-auto px-6">
|
|
<div className="mb-6">
|
|
<div className="w-16 h-16 mx-auto mb-4 bg-color-error rounded-full flex items-center justify-center">
|
|
<svg
|
|
className="w-8 h-8 text-text-inverse"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.732 19.5c-.77.833.192 2.5 1.732 2.5z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<h1 className="text-2xl font-bold text-text-primary mb-2">
|
|
Acceso no autorizado
|
|
</h1>
|
|
<p className="text-text-secondary mb-6">
|
|
No tienes permisos para acceder a esta página. Contacta con tu administrador si crees que esto es un error.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<button
|
|
onClick={() => window.history.back()}
|
|
className="btn btn-primary"
|
|
>
|
|
Volver atrás
|
|
</button>
|
|
<button
|
|
onClick={() => window.location.href = ROUTES.DASHBOARD}
|
|
className="btn btn-outline"
|
|
>
|
|
Ir al Panel de Control
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const ForbiddenPage: React.FC = () => (
|
|
<div className="flex items-center justify-center min-h-screen bg-bg-primary">
|
|
<div className="text-center max-w-md mx-auto px-6">
|
|
<div className="mb-6">
|
|
<div className="w-16 h-16 mx-auto mb-4 bg-color-warning rounded-full flex items-center justify-center">
|
|
<svg
|
|
className="w-8 h-8 text-text-inverse"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L5.636 5.636"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<h1 className="text-2xl font-bold text-text-primary mb-2">
|
|
Acceso restringido
|
|
</h1>
|
|
<p className="text-text-secondary mb-6">
|
|
Tu cuenta no tiene los permisos necesarios para ver esta sección. Contacta con tu administrador para solicitar acceso.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<button
|
|
onClick={() => window.history.back()}
|
|
className="btn btn-primary"
|
|
>
|
|
Volver atrás
|
|
</button>
|
|
<button
|
|
onClick={() => window.location.href = ROUTES.DASHBOARD}
|
|
className="btn btn-outline"
|
|
>
|
|
Ir al Panel de Control
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
|
children,
|
|
route,
|
|
fallback,
|
|
redirectTo,
|
|
}) => {
|
|
const user = useAuthUser();
|
|
const isAuthenticated = useIsAuthenticated();
|
|
const isLoading = useAuthLoading();
|
|
const location = useLocation();
|
|
|
|
// Show loading spinner while checking authentication
|
|
if (isLoading) {
|
|
return fallback || <LoadingSpinner message="Verificando autenticación..." />;
|
|
}
|
|
|
|
// If route doesn't require auth, render children
|
|
if (route && !route.requiresAuth) {
|
|
return <>{children}</>;
|
|
}
|
|
|
|
// If not authenticated and route requires auth, redirect to login
|
|
if (!isAuthenticated) {
|
|
const redirectPath = redirectTo || ROUTES.LOGIN;
|
|
const returnUrl = location.pathname + location.search;
|
|
|
|
return (
|
|
<Navigate
|
|
to={`${redirectPath}?returnUrl=${encodeURIComponent(returnUrl)}`}
|
|
replace
|
|
/>
|
|
);
|
|
}
|
|
|
|
// If no route config is provided, just check authentication
|
|
if (!route) {
|
|
return <>{children}</>;
|
|
}
|
|
|
|
// Get user roles and permissions
|
|
const userRoles = user?.role ? [user.role] : [];
|
|
const userPermissions: string[] = [];
|
|
|
|
// Check if user can access this route
|
|
const canAccess = canAccessRoute(route, isAuthenticated, userRoles, userPermissions);
|
|
|
|
if (!canAccess) {
|
|
// Check if it's a permission issue or role issue
|
|
const hasRequiredRoles = !route.requiredRoles ||
|
|
route.requiredRoles.some(role => userRoles.includes(role));
|
|
|
|
if (!hasRequiredRoles) {
|
|
return <UnauthorizedPage />;
|
|
} else {
|
|
return <ForbiddenPage />;
|
|
}
|
|
}
|
|
|
|
// User has access, render the protected content
|
|
return <>{children}</>;
|
|
};
|
|
|
|
// Higher-order component for route protection
|
|
export const withProtectedRoute = <P extends object>(
|
|
Component: React.ComponentType<P>,
|
|
route: RouteConfig,
|
|
options?: {
|
|
fallback?: React.ReactNode;
|
|
redirectTo?: string;
|
|
}
|
|
) => {
|
|
const ProtectedComponent = (props: P) => (
|
|
<ProtectedRoute
|
|
route={route}
|
|
fallback={options?.fallback}
|
|
redirectTo={options?.redirectTo}
|
|
>
|
|
<Component {...props} />
|
|
</ProtectedRoute>
|
|
);
|
|
|
|
ProtectedComponent.displayName = `withProtectedRoute(${Component.displayName || Component.name})`;
|
|
|
|
return ProtectedComponent;
|
|
};
|
|
|
|
// Hook for checking route access
|
|
export const useRouteAccess = (route: RouteConfig) => {
|
|
const user = useAuthUser();
|
|
const isAuthenticated = useIsAuthenticated();
|
|
const isLoading = useAuthLoading();
|
|
|
|
const userRoles = user?.role ? [user.role] : [];
|
|
const userPermissions: string[] = [];
|
|
|
|
const canAccess = canAccessRoute(route, isAuthenticated, userRoles, userPermissions);
|
|
|
|
return {
|
|
canAccess,
|
|
isLoading,
|
|
isAuthenticated,
|
|
userRoles,
|
|
userPermissions,
|
|
hasRequiredRoles: !route.requiredRoles ||
|
|
route.requiredRoles.some(role => userRoles.includes(role)),
|
|
hasRequiredPermissions: !route.requiredPermissions ||
|
|
route.requiredPermissions.every(permission => userPermissions.includes(permission)),
|
|
};
|
|
};
|
|
|
|
// Component for conditionally rendering content based on permissions
|
|
interface ConditionalRenderProps {
|
|
children: React.ReactNode;
|
|
requiredPermissions?: string[];
|
|
requiredRoles?: string[];
|
|
requireAll?: boolean; // If true, requires ALL permissions/roles, otherwise ANY
|
|
fallback?: React.ReactNode;
|
|
}
|
|
|
|
export const ConditionalRender: React.FC<ConditionalRenderProps> = ({
|
|
children,
|
|
requiredPermissions = [],
|
|
requiredRoles = [],
|
|
requireAll = true,
|
|
fallback = null,
|
|
}) => {
|
|
const user = useAuthUser();
|
|
const isAuthenticated = useIsAuthenticated();
|
|
|
|
if (!isAuthenticated || !user) {
|
|
return <>{fallback}</>;
|
|
}
|
|
|
|
const userRoles = user.role ? [user.role] : [];
|
|
const userPermissions: string[] = [];
|
|
|
|
// Check roles
|
|
let hasRoles = true;
|
|
if (requiredRoles.length > 0) {
|
|
if (requireAll) {
|
|
hasRoles = requiredRoles.every(role => userRoles.includes(role));
|
|
} else {
|
|
hasRoles = requiredRoles.some(role => userRoles.includes(role));
|
|
}
|
|
}
|
|
|
|
// Check permissions
|
|
let hasPermissions = true;
|
|
if (requiredPermissions.length > 0) {
|
|
if (requireAll) {
|
|
hasPermissions = requiredPermissions.every(permission => userPermissions.includes(permission));
|
|
} else {
|
|
hasPermissions = requiredPermissions.some(permission => userPermissions.includes(permission));
|
|
}
|
|
}
|
|
|
|
if (hasRoles && hasPermissions) {
|
|
return <>{children}</>;
|
|
}
|
|
|
|
return <>{fallback}</>;
|
|
};
|
|
|
|
// Route guard for admin-only routes
|
|
export const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
return (
|
|
<ConditionalRender
|
|
requiredRoles={['admin', 'super_admin']}
|
|
requireAll={false}
|
|
fallback={<UnauthorizedPage />}
|
|
>
|
|
{children}
|
|
</ConditionalRender>
|
|
);
|
|
};
|
|
|
|
// Route guard for manager-level routes
|
|
export const ManagerRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
return (
|
|
<ConditionalRender
|
|
requiredRoles={['admin', 'super_admin', 'manager']}
|
|
requireAll={false}
|
|
fallback={<UnauthorizedPage />}
|
|
>
|
|
{children}
|
|
</ConditionalRender>
|
|
);
|
|
}; |