132 lines
3.1 KiB
TypeScript
132 lines
3.1 KiB
TypeScript
import * as React from 'react';
|
|
import { useAuth } from './AuthProvider';
|
|
|
|
export interface ProtectedRouteProps {
|
|
children: React.ReactNode;
|
|
/** Component to render while loading */
|
|
fallback?: React.ReactNode;
|
|
/** Component to render if not authenticated */
|
|
unauthorized?: React.ReactNode;
|
|
/** Required role(s) - user must have at least one */
|
|
roles?: string[];
|
|
/** Required scope(s) - user must have at least one */
|
|
scopes?: string[];
|
|
/** Redirect path for unauthorized access (alternative to unauthorized component) */
|
|
redirectTo?: string;
|
|
/** Custom redirect function (e.g., router.push). Falls back to window.location.href. */
|
|
onRedirect?: (path: string) => void;
|
|
}
|
|
|
|
/**
|
|
* ProtectedRoute guards routes that require authentication.
|
|
*
|
|
* @example
|
|
* // Basic protection
|
|
* <Route path="/dashboard" element={
|
|
* <ProtectedRoute>
|
|
* <Dashboard />
|
|
* </ProtectedRoute>
|
|
* } />
|
|
*
|
|
* @example
|
|
* // With role requirement
|
|
* <Route path="/admin" element={
|
|
* <ProtectedRoute roles={['admin']}>
|
|
* <AdminPanel />
|
|
* </ProtectedRoute>
|
|
* } />
|
|
*
|
|
* @example
|
|
* // With custom unauthorized view
|
|
* <ProtectedRoute
|
|
* unauthorized={<AccessDenied />}
|
|
* fallback={<LoadingSpinner />}
|
|
* >
|
|
* <SecureContent />
|
|
* </ProtectedRoute>
|
|
*/
|
|
export function ProtectedRoute({
|
|
children,
|
|
fallback = <DefaultLoading />,
|
|
unauthorized = <DefaultUnauthorized />,
|
|
roles,
|
|
scopes,
|
|
redirectTo,
|
|
onRedirect,
|
|
}: ProtectedRouteProps) {
|
|
const { isLoading, isAuthenticated, user } = useAuth();
|
|
|
|
// Show loading state
|
|
if (isLoading) {
|
|
return <>{fallback}</>;
|
|
}
|
|
|
|
// Not authenticated
|
|
if (!isAuthenticated) {
|
|
if (redirectTo) {
|
|
if (onRedirect) {
|
|
onRedirect(redirectTo);
|
|
} else {
|
|
window.location.href = redirectTo;
|
|
}
|
|
return null;
|
|
}
|
|
return <>{unauthorized}</>;
|
|
}
|
|
|
|
// Check role requirements
|
|
if (roles && roles.length > 0) {
|
|
const hasRequiredRole = roles.some((role) => user?.roles?.includes(role));
|
|
if (!hasRequiredRole) {
|
|
return <>{unauthorized}</>;
|
|
}
|
|
}
|
|
|
|
// Check scope requirements
|
|
if (scopes && scopes.length > 0) {
|
|
const hasRequiredScope = scopes.some((scope) => user?.scopes?.includes(scope));
|
|
if (!hasRequiredScope) {
|
|
return <>{unauthorized}</>;
|
|
}
|
|
}
|
|
|
|
return <>{children}</>;
|
|
}
|
|
|
|
// Default loading component
|
|
function DefaultLoading() {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
minHeight: '100vh',
|
|
color: 'var(--text-muted)',
|
|
}}
|
|
>
|
|
Loading...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Default unauthorized component
|
|
function DefaultUnauthorized() {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
minHeight: '100vh',
|
|
gap: '1rem',
|
|
color: 'var(--text-primary)',
|
|
}}
|
|
>
|
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>Access Denied</h1>
|
|
<p style={{ color: 'var(--text-muted)' }}>You don't have permission to view this page.</p>
|
|
</div>
|
|
);
|
|
}
|