150 lines
5.1 KiB
TypeScript
150 lines
5.1 KiB
TypeScript
import { useEffect, ReactNode, useState } from "react";
|
|
import { Navigate, useLocation } from "react-router-dom";
|
|
import { useAuthStore } from "../../store/authStore";
|
|
import { useErrorHandler } from "../../hooks/useErrorHandler";
|
|
import { trackLoading } from "../common/LoadingStateMonitor";
|
|
|
|
interface ProtectedRouteProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* ProtectedRoute component - guards routes requiring authentication
|
|
* Redirects to /signin if user is not authenticated
|
|
*/
|
|
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
|
const { isAuthenticated, loading, user, logout } = useAuthStore();
|
|
const location = useLocation();
|
|
const { addError } = useErrorHandler('ProtectedRoute');
|
|
const [showError, setShowError] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
|
const PLAN_ALLOWED_PATHS = [
|
|
'/account/plans',
|
|
'/account/purchase-credits',
|
|
'/account/settings',
|
|
'/account/team',
|
|
'/account/usage',
|
|
'/billing',
|
|
'/payment',
|
|
];
|
|
|
|
const isPlanAllowedPath = PLAN_ALLOWED_PATHS.some((prefix) =>
|
|
location.pathname.startsWith(prefix)
|
|
);
|
|
|
|
// Track loading state
|
|
useEffect(() => {
|
|
trackLoading('auth-loading', loading);
|
|
}, [loading]);
|
|
|
|
// Validate account + plan whenever auth/user changes
|
|
useEffect(() => {
|
|
console.log('[ProtectedRoute] Auth state changed:', {
|
|
isAuthenticated,
|
|
hasUser: !!user,
|
|
hasAccount: !!user?.account,
|
|
pathname: location.pathname
|
|
});
|
|
|
|
if (!isAuthenticated) {
|
|
console.log('[ProtectedRoute] Not authenticated, will redirect to signin');
|
|
return;
|
|
}
|
|
|
|
if (!user?.account) {
|
|
console.error('[ProtectedRoute] User has no account, logging out');
|
|
setErrorMessage('This user is not linked to an account. Please contact support.');
|
|
logout();
|
|
return;
|
|
}
|
|
|
|
console.log('[ProtectedRoute] Auth validation passed');
|
|
}, [isAuthenticated, user, logout]);
|
|
|
|
// Immediate check on mount: if loading is true, reset it immediately
|
|
useEffect(() => {
|
|
if (loading) {
|
|
console.warn('ProtectedRoute: Loading state is true on mount, resetting immediately');
|
|
useAuthStore.setState({ loading: false });
|
|
}
|
|
}, []);
|
|
|
|
// Safety timeout: if loading becomes true and stays stuck, show error
|
|
useEffect(() => {
|
|
if (loading) {
|
|
const timeout1 = setTimeout(() => {
|
|
setErrorMessage('Authentication check is taking longer than expected. This may indicate a network or server issue.');
|
|
setShowError(true);
|
|
addError(new Error('Auth loading stuck for 3 seconds'), 'ProtectedRoute');
|
|
}, 3000);
|
|
|
|
const timeout2 = setTimeout(() => {
|
|
console.error('ProtectedRoute: Loading state stuck for 5 seconds, forcing reset');
|
|
useAuthStore.setState({ loading: false });
|
|
setShowError(false);
|
|
}, 5000);
|
|
|
|
return () => {
|
|
clearTimeout(timeout1);
|
|
clearTimeout(timeout2);
|
|
};
|
|
} else {
|
|
setShowError(false);
|
|
}
|
|
}, [loading, addError]);
|
|
|
|
// Show loading state while checking authentication
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900">
|
|
<div className="text-center max-w-md px-4">
|
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500 mb-4"></div>
|
|
<p className="text-lg font-medium text-gray-800 dark:text-white mb-2">Loading...</p>
|
|
|
|
{showError && (
|
|
<div className="mt-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
|
<p className="text-sm text-yellow-800 dark:text-yellow-200 mb-3">
|
|
{errorMessage}
|
|
</p>
|
|
<button
|
|
onClick={() => {
|
|
useAuthStore.setState({ loading: false });
|
|
setShowError(false);
|
|
window.location.reload();
|
|
}}
|
|
className="px-4 py-2 text-sm bg-yellow-600 text-white rounded hover:bg-yellow-700"
|
|
>
|
|
Retry or Reload Page
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Redirect to signin if not authenticated
|
|
if (!isAuthenticated) {
|
|
console.log('[ProtectedRoute] Redirecting to /signin - not authenticated');
|
|
return <Navigate to="/signin" state={{ from: location }} replace />;
|
|
}
|
|
|
|
// If authenticated but missing an active plan, keep user inside billing/onboarding
|
|
const accountStatus = user?.account?.status;
|
|
const accountInactive = accountStatus && ['suspended', 'cancelled'].includes(accountStatus);
|
|
const pendingPayment = accountStatus === 'pending_payment';
|
|
const isPrivileged = user?.role === 'developer' || user?.is_superuser;
|
|
|
|
if (!isPrivileged) {
|
|
if (pendingPayment && !isPlanAllowedPath) {
|
|
return <Navigate to="/account/plans" state={{ from: location }} replace />;
|
|
}
|
|
if (accountInactive && !isPlanAllowedPath) {
|
|
return <Navigate to="/account/plans" state={{ from: location }} replace />;
|
|
}
|
|
}
|
|
|
|
return <>{children}</>;
|
|
}
|
|
|