- Convert all route components to lazy-loaded with React.lazy() - Add Suspense boundaries with loading fallback components - Configure manual chunks in Vite for better code organization: - Separate React vendor libraries (react-vendor) - Group components by feature (reports, settings, admin, apps, auth) - Isolate other node_modules (vendor) - Reduce initial bundle from ~1,080 kB to under 500 kB - Components now load on-demand when routes are accessed - Improves initial page load performance and caching
625 lines
28 KiB
TypeScript
625 lines
28 KiB
TypeScript
import { useEffect, useState, useRef, Suspense, lazy } from 'react';
|
|
import { Routes, Route, Link, useLocation, Navigate, useParams, useNavigate } from 'react-router-dom';
|
|
import { clsx } from 'clsx';
|
|
import ProtectedRoute from './components/ProtectedRoute';
|
|
import { ToastContainerComponent } from './components/Toast';
|
|
import { useAuthStore } from './stores/authStore';
|
|
|
|
// Core components (loaded immediately - used on main routes)
|
|
const SearchDashboard = lazy(() => import('./components/SearchDashboard'));
|
|
const Dashboard = lazy(() => import('./components/Dashboard'));
|
|
const ApplicationList = lazy(() => import('./components/ApplicationList'));
|
|
const ApplicationInfo = lazy(() => import('./components/ApplicationInfo'));
|
|
const GovernanceModelHelper = lazy(() => import('./components/GovernanceModelHelper'));
|
|
|
|
// Reports components (code-split into reports chunk)
|
|
const ReportsDashboard = lazy(() => import('./components/ReportsDashboard'));
|
|
const TeamDashboard = lazy(() => import('./components/TeamDashboard'));
|
|
const GovernanceAnalysis = lazy(() => import('./components/GovernanceAnalysis'));
|
|
const TechnicalDebtHeatmap = lazy(() => import('./components/TechnicalDebtHeatmap'));
|
|
const LifecyclePipeline = lazy(() => import('./components/LifecyclePipeline'));
|
|
const DataCompletenessScore = lazy(() => import('./components/DataCompletenessScore'));
|
|
const ZiRADomainCoverage = lazy(() => import('./components/ZiRADomainCoverage'));
|
|
const FTEPerZiRADomain = lazy(() => import('./components/FTEPerZiRADomain'));
|
|
const ComplexityDynamicsBubbleChart = lazy(() => import('./components/ComplexityDynamicsBubbleChart'));
|
|
const BusinessImportanceComparison = lazy(() => import('./components/BusinessImportanceComparison'));
|
|
|
|
// Apps components (code-split into apps chunk)
|
|
const BIASyncDashboard = lazy(() => import('./components/BIASyncDashboard'));
|
|
const FTECalculator = lazy(() => import('./components/FTECalculator'));
|
|
|
|
// Settings components (code-split into settings chunk)
|
|
const ConfigurationV25 = lazy(() => import('./components/ConfigurationV25'));
|
|
const DataCompletenessConfig = lazy(() => import('./components/DataCompletenessConfig'));
|
|
const SchemaConfigurationSettings = lazy(() => import('./components/SchemaConfigurationSettings'));
|
|
const DataValidationDashboard = lazy(() => import('./components/DataValidationDashboard'));
|
|
const ProfileSettings = lazy(() => import('./components/ProfileSettings'));
|
|
|
|
// Admin components (code-split into admin chunk)
|
|
const UserManagement = lazy(() => import('./components/UserManagement'));
|
|
const RoleManagement = lazy(() => import('./components/RoleManagement'));
|
|
const ArchitectureDebugPage = lazy(() => import('./components/ArchitectureDebugPage'));
|
|
|
|
// Auth components (code-split into auth chunk - loaded separately since they're outside main layout)
|
|
const Login = lazy(() => import('./components/Login'));
|
|
const ForgotPassword = lazy(() => import('./components/ForgotPassword'));
|
|
const ResetPassword = lazy(() => import('./components/ResetPassword'));
|
|
const AcceptInvitation = lazy(() => import('./components/AcceptInvitation'));
|
|
|
|
// Loading component for Suspense fallback
|
|
function LoadingFallback() {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
|
<p className="text-gray-600 font-medium">Laden...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Module-level singleton to prevent duplicate initialization across StrictMode remounts
|
|
let initializationPromise: Promise<void> | null = null;
|
|
|
|
// Redirect component for old app-components/overview/:id paths
|
|
function RedirectToApplicationEdit() {
|
|
const { id } = useParams<{ id: string }>();
|
|
return <Navigate to={`/application/${id}/edit`} replace />;
|
|
}
|
|
|
|
// Dropdown menu item type
|
|
interface NavItem {
|
|
path: string;
|
|
label: string;
|
|
exact?: boolean;
|
|
requiredPermission?: string; // Permission required to see this menu item
|
|
}
|
|
|
|
interface NavDropdown {
|
|
label: string;
|
|
icon?: React.ReactNode;
|
|
items: NavItem[];
|
|
basePath: string;
|
|
}
|
|
|
|
// Dropdown component for navigation
|
|
function NavDropdown({ dropdown, isActive, hasPermission }: { dropdown: NavDropdown; isActive: boolean; hasPermission: (permission: string) => boolean }) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const location = useLocation();
|
|
|
|
// Filter items based on permissions
|
|
const visibleItems = dropdown.items.filter(item => {
|
|
if (!item.requiredPermission) {
|
|
return true; // No permission required, show item
|
|
}
|
|
return hasPermission(item.requiredPermission);
|
|
});
|
|
|
|
// Don't render dropdown if no items are visible
|
|
if (visibleItems.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Close dropdown when clicking outside
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
// Close dropdown on route change
|
|
useEffect(() => {
|
|
setIsOpen(false);
|
|
}, [location.pathname]);
|
|
|
|
return (
|
|
<div className="relative" ref={dropdownRef}>
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className={clsx(
|
|
'flex items-center gap-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
|
isActive
|
|
? 'bg-blue-50 text-blue-700'
|
|
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
|
)}
|
|
>
|
|
{dropdown.label}
|
|
<svg
|
|
className={clsx('w-4 h-4 transition-transform', isOpen && 'rotate-180')}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="absolute left-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
|
|
{visibleItems.map((item) => {
|
|
const itemActive = item.exact
|
|
? location.pathname === item.path
|
|
: location.pathname.startsWith(item.path);
|
|
|
|
return (
|
|
<Link
|
|
key={item.path}
|
|
to={item.path}
|
|
className={clsx(
|
|
'block px-4 py-2 text-sm transition-colors',
|
|
itemActive
|
|
? 'bg-blue-50 text-blue-700'
|
|
: 'text-gray-700 hover:bg-gray-50'
|
|
)}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UserMenu() {
|
|
const { user, authMethod, logout } = useAuthStore();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const navigate = useNavigate();
|
|
|
|
if (!user) return null;
|
|
|
|
const displayName = user.displayName || user.username || user.email || 'User';
|
|
const email = user.email || user.emailAddress || '';
|
|
|
|
const initials = displayName
|
|
.split(' ')
|
|
.map(n => n[0])
|
|
.join('')
|
|
.toUpperCase()
|
|
.slice(0, 2);
|
|
|
|
const handleLogout = async () => {
|
|
setIsOpen(false);
|
|
await logout();
|
|
navigate('/login');
|
|
};
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-gray-100 transition-colors"
|
|
>
|
|
{user.avatarUrl ? (
|
|
<img
|
|
src={user.avatarUrl}
|
|
alt={displayName}
|
|
className="w-8 h-8 rounded-full"
|
|
/>
|
|
) : (
|
|
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
|
|
<span className="text-white text-sm font-medium">{initials}</span>
|
|
</div>
|
|
)}
|
|
<span className="text-sm text-gray-700 hidden sm:block">{displayName}</span>
|
|
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-10"
|
|
onClick={() => setIsOpen(false)}
|
|
/>
|
|
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-20">
|
|
<div className="px-4 py-3 border-b border-gray-100">
|
|
<p className="text-sm font-medium text-gray-900">{displayName}</p>
|
|
{email && (
|
|
<p className="text-xs text-gray-500 truncate">{email}</p>
|
|
)}
|
|
{user.username && email !== user.username && (
|
|
<p className="text-xs text-gray-500 truncate">@{user.username}</p>
|
|
)}
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
{authMethod === 'oauth' ? 'Jira OAuth' : authMethod === 'local' ? 'Lokaal Account' : 'Service Account'}
|
|
</p>
|
|
</div>
|
|
<div className="py-1">
|
|
{(authMethod === 'local' || authMethod === 'oauth') && (
|
|
<>
|
|
<Link
|
|
to="/settings/profile"
|
|
onClick={() => setIsOpen(false)}
|
|
className="block px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
|
>
|
|
Profiel & Instellingen
|
|
</Link>
|
|
<div className="border-t border-gray-100 my-1"></div>
|
|
</>
|
|
)}
|
|
<button
|
|
onClick={handleLogout}
|
|
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 transition-colors"
|
|
>
|
|
Uitloggen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AppContent() {
|
|
const location = useLocation();
|
|
const hasPermission = useAuthStore((state) => state.hasPermission);
|
|
const config = useAuthStore((state) => state.config);
|
|
|
|
// Navigation structure - Logical flow for CMDB setup and data management
|
|
// Flow: 1. Setup (Schema Discovery → Configuration → Sync) → 2. Data (Model → Validation) → 3. Application Component → 4. Reports
|
|
|
|
const appComponentsDropdown: NavDropdown = {
|
|
label: 'Application Component',
|
|
basePath: '/application',
|
|
items: [
|
|
{ path: '/app-components', label: 'Dashboard', exact: true, requiredPermission: 'search' },
|
|
{ path: '/application/overview', label: 'Overzicht', exact: false, requiredPermission: 'search' },
|
|
],
|
|
};
|
|
|
|
const reportsDropdown: NavDropdown = {
|
|
label: 'Rapporten',
|
|
basePath: '/reports',
|
|
items: [
|
|
{ path: '/reports', label: 'Overzicht', exact: true, requiredPermission: 'view_reports' },
|
|
{ path: '/reports/team-dashboard', label: 'Team-indeling', exact: true, requiredPermission: 'view_reports' },
|
|
{ path: '/reports/governance-analysis', label: 'Analyse Regiemodel', exact: true, requiredPermission: 'view_reports' },
|
|
{ path: '/reports/technical-debt-heatmap', label: 'Technical Debt Heatmap', exact: true, requiredPermission: 'view_reports' },
|
|
{ path: '/reports/lifecycle-pipeline', label: 'Lifecycle Pipeline', exact: true, requiredPermission: 'view_reports' },
|
|
{ path: '/reports/data-completeness', label: 'Data Completeness Score', exact: true, requiredPermission: 'view_reports' },
|
|
{ path: '/reports/zira-domain-coverage', label: 'ZiRA Domain Coverage', exact: true, requiredPermission: 'view_reports' },
|
|
{ path: '/reports/fte-per-zira-domain', label: 'FTE per ZiRA Domain', exact: true, requiredPermission: 'view_reports' },
|
|
{ path: '/reports/complexity-dynamics-bubble', label: 'Complexity vs Dynamics Bubble Chart', exact: true, requiredPermission: 'view_reports' },
|
|
{ path: '/reports/business-importance-comparison', label: 'Business Importance vs BIA', exact: true, requiredPermission: 'view_reports' },
|
|
],
|
|
};
|
|
|
|
const appsDropdown: NavDropdown = {
|
|
label: 'Apps',
|
|
basePath: '/apps',
|
|
items: [
|
|
{ path: '/apps/bia-sync', label: 'BIA Sync', exact: true, requiredPermission: 'search' },
|
|
{ path: '/apps/fte-calculator', label: 'FTE Calculator', exact: true, requiredPermission: 'search' },
|
|
],
|
|
};
|
|
|
|
const settingsDropdown: NavDropdown = {
|
|
label: 'Instellingen',
|
|
basePath: '/settings',
|
|
items: [
|
|
{ path: '/settings/data-completeness-config', label: 'Data Completeness Config', exact: true, requiredPermission: 'manage_settings' },
|
|
{ path: '/settings/fte-config', label: 'FTE Config', exact: true, requiredPermission: 'manage_settings' },
|
|
],
|
|
};
|
|
|
|
const adminDropdown: NavDropdown = {
|
|
label: 'Beheer',
|
|
basePath: '/admin',
|
|
items: [
|
|
{ path: '/settings/schema-configuration', label: 'Schema Configuratie & Datamodel', exact: true, requiredPermission: 'manage_settings' },
|
|
{ path: '/settings/data-validation', label: 'Data Validatie', exact: true, requiredPermission: 'manage_settings' },
|
|
{ path: '/admin/users', label: 'Gebruikers', exact: true, requiredPermission: 'manage_users' },
|
|
{ path: '/admin/roles', label: 'Rollen', exact: true, requiredPermission: 'manage_roles' },
|
|
{ path: '/admin/debug', label: 'Architecture Debug', exact: true, requiredPermission: 'admin' },
|
|
],
|
|
};
|
|
|
|
const isAppComponentsActive = location.pathname.startsWith('/app-components') || location.pathname.startsWith('/application');
|
|
const isReportsActive = location.pathname.startsWith('/reports');
|
|
// Settings is active for /settings paths EXCEPT admin items (schema-configuration, data-model, data-validation)
|
|
const isSettingsActive = location.pathname.startsWith('/settings')
|
|
&& !location.pathname.startsWith('/settings/schema-configuration')
|
|
&& !location.pathname.startsWith('/settings/data-model')
|
|
&& !location.pathname.startsWith('/settings/data-validation');
|
|
const isAppsActive = location.pathname.startsWith('/apps');
|
|
const isAdminActive = location.pathname.startsWith('/admin') || location.pathname.startsWith('/settings/schema-configuration') || location.pathname.startsWith('/settings/data-model') || location.pathname.startsWith('/settings/data-validation');
|
|
|
|
return (
|
|
<div className="min-h-screen bg-white">
|
|
{/* Header */}
|
|
<header className="bg-white shadow-sm border-b border-gray-200">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div className="flex justify-between items-center h-16">
|
|
<div className="flex items-center space-x-8">
|
|
<Link to="/" className="flex items-center space-x-3">
|
|
<img src="/logo-zuyderland.svg" alt="Zuyderland" className="w-9 h-9" />
|
|
<div>
|
|
<h1 className="text-lg font-semibold text-gray-900">
|
|
{config?.appName || 'CMDB Insight'}
|
|
</h1>
|
|
<p className="text-xs text-gray-500">{config?.appTagline || 'Management console for Jira Assets'}</p>
|
|
</div>
|
|
</Link>
|
|
|
|
<nav className="hidden md:flex items-center space-x-1">
|
|
{/* Application Component Dropdown */}
|
|
<NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} hasPermission={hasPermission} />
|
|
|
|
{/* Reports Dropdown */}
|
|
<NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} hasPermission={hasPermission} />
|
|
|
|
{/* Apps Dropdown */}
|
|
<NavDropdown dropdown={appsDropdown} isActive={isAppsActive} hasPermission={hasPermission} />
|
|
|
|
{/* Settings Dropdown - Advanced configuration */}
|
|
<NavDropdown dropdown={settingsDropdown} isActive={isSettingsActive} hasPermission={hasPermission} />
|
|
|
|
{/* Admin Dropdown - Setup (Schema Config + Data Model + Data Validation) + Administration */}
|
|
<NavDropdown dropdown={adminDropdown} isActive={isAdminActive} hasPermission={hasPermission} />
|
|
</nav>
|
|
</div>
|
|
|
|
<UserMenu />
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Toast Notifications */}
|
|
<ToastContainerComponent />
|
|
|
|
{/* Main content */}
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<Suspense fallback={<LoadingFallback />}>
|
|
<Routes>
|
|
{/* Main Dashboard (Search) */}
|
|
<Route path="/" element={<ProtectedRoute><SearchDashboard /></ProtectedRoute>} />
|
|
|
|
{/* Application routes (new structure) - specific routes first, then dynamic */}
|
|
<Route path="/application/overview" element={<ProtectedRoute requirePermission="search"><ApplicationList /></ProtectedRoute>} />
|
|
<Route path="/application/:id/edit" element={<ProtectedRoute requirePermission="edit_applications"><GovernanceModelHelper /></ProtectedRoute>} />
|
|
<Route path="/application/:id" element={<ProtectedRoute requirePermission="search"><ApplicationInfo /></ProtectedRoute>} />
|
|
|
|
{/* Application Component routes */}
|
|
<Route path="/app-components" element={<ProtectedRoute requirePermission="search"><Dashboard /></ProtectedRoute>} />
|
|
|
|
{/* Reports routes */}
|
|
<Route path="/reports" element={<ProtectedRoute requirePermission="view_reports"><ReportsDashboard /></ProtectedRoute>} />
|
|
<Route path="/reports/team-dashboard" element={<ProtectedRoute requirePermission="view_reports"><TeamDashboard /></ProtectedRoute>} />
|
|
<Route path="/reports/governance-analysis" element={<ProtectedRoute requirePermission="view_reports"><GovernanceAnalysis /></ProtectedRoute>} />
|
|
<Route path="/reports/technical-debt-heatmap" element={<ProtectedRoute requirePermission="view_reports"><TechnicalDebtHeatmap /></ProtectedRoute>} />
|
|
<Route path="/reports/lifecycle-pipeline" element={<ProtectedRoute requirePermission="view_reports"><LifecyclePipeline /></ProtectedRoute>} />
|
|
<Route path="/reports/data-completeness" element={<ProtectedRoute requirePermission="view_reports"><DataCompletenessScore /></ProtectedRoute>} />
|
|
<Route path="/reports/zira-domain-coverage" element={<ProtectedRoute requirePermission="view_reports"><ZiRADomainCoverage /></ProtectedRoute>} />
|
|
<Route path="/reports/fte-per-zira-domain" element={<ProtectedRoute requirePermission="view_reports"><FTEPerZiRADomain /></ProtectedRoute>} />
|
|
<Route path="/reports/complexity-dynamics-bubble" element={<ProtectedRoute requirePermission="view_reports"><ComplexityDynamicsBubbleChart /></ProtectedRoute>} />
|
|
<Route path="/reports/business-importance-comparison" element={<ProtectedRoute requirePermission="view_reports"><BusinessImportanceComparison /></ProtectedRoute>} />
|
|
|
|
{/* Apps routes */}
|
|
<Route path="/apps/bia-sync" element={<ProtectedRoute requirePermission="search"><BIASyncDashboard /></ProtectedRoute>} />
|
|
<Route path="/apps/fte-calculator" element={<ProtectedRoute requirePermission="search"><FTECalculator /></ProtectedRoute>} />
|
|
|
|
{/* Settings routes */}
|
|
<Route path="/settings/schema-configuration" element={<ProtectedRoute requirePermission="manage_settings"><SchemaConfigurationSettings /></ProtectedRoute>} />
|
|
<Route path="/settings/fte-config" element={<ProtectedRoute requirePermission="manage_settings"><ConfigurationV25 /></ProtectedRoute>} />
|
|
<Route path="/settings/data-model" element={<Navigate to="/settings/schema-configuration" replace />} />
|
|
<Route path="/settings/data-validation" element={<ProtectedRoute requirePermission="manage_settings"><DataValidationDashboard /></ProtectedRoute>} />
|
|
<Route path="/settings/data-completeness-config" element={<ProtectedRoute requirePermission="manage_settings"><DataCompletenessConfig /></ProtectedRoute>} />
|
|
<Route path="/settings/profile" element={<ProtectedRoute><ProfileSettings /></ProtectedRoute>} />
|
|
{/* Legacy redirects for old routes */}
|
|
<Route path="/settings/user-settings" element={<Navigate to="/settings/profile" replace />} />
|
|
|
|
{/* Admin routes */}
|
|
<Route path="/admin/users" element={<ProtectedRoute requirePermission="manage_users"><UserManagement /></ProtectedRoute>} />
|
|
<Route path="/admin/roles" element={<ProtectedRoute requirePermission="manage_roles"><RoleManagement /></ProtectedRoute>} />
|
|
<Route path="/admin/debug" element={<ProtectedRoute requirePermission="admin"><ArchitectureDebugPage /></ProtectedRoute>} />
|
|
|
|
{/* Legacy redirects for bookmarks - redirect old paths to new ones */}
|
|
<Route path="/app-components/overview" element={<Navigate to="/application/overview" replace />} />
|
|
<Route path="/app-components/overview/:id" element={<RedirectToApplicationEdit />} />
|
|
<Route path="/app-components/fte-config" element={<Navigate to="/settings/fte-config" replace />} />
|
|
<Route path="/applications" element={<Navigate to="/application/overview" replace />} />
|
|
<Route path="/applications/:id" element={<RedirectToApplicationEdit />} />
|
|
<Route path="/application/fte-calculator" element={<Navigate to="/apps/fte-calculator" replace />} />
|
|
<Route path="/reports/data-model" element={<Navigate to="/settings/schema-configuration" replace />} />
|
|
<Route path="/reports/bia-sync" element={<Navigate to="/apps/bia-sync" replace />} />
|
|
<Route path="/teams" element={<ProtectedRoute requirePermission="view_reports"><TeamDashboard /></ProtectedRoute>} />
|
|
<Route path="/configuration" element={<Navigate to="/settings/fte-config" replace />} />
|
|
</Routes>
|
|
</Suspense>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function App() {
|
|
const { isAuthenticated, checkAuth, fetchConfig, config, user, authMethod, isInitialized, setInitialized, setConfig } = useAuthStore();
|
|
const location = useLocation();
|
|
|
|
useEffect(() => {
|
|
// Use singleton pattern to ensure initialization happens only once
|
|
// This works across React StrictMode remounts
|
|
|
|
// Check if already initialized by checking store state
|
|
const currentState = useAuthStore.getState();
|
|
if (currentState.config && currentState.isInitialized) {
|
|
return;
|
|
}
|
|
|
|
// If already initializing, wait for existing promise
|
|
if (initializationPromise) {
|
|
return;
|
|
}
|
|
|
|
// Create singleton initialization promise
|
|
// OPTIMIZATION: Run config and auth checks in parallel instead of sequentially
|
|
initializationPromise = (async () => {
|
|
try {
|
|
const state = useAuthStore.getState();
|
|
const defaultConfig = {
|
|
appName: 'CMDB Insight',
|
|
appTagline: 'Management console for Jira Assets',
|
|
appCopyright: `© ${new Date().getFullYear()} Zuyderland Medisch Centrum`,
|
|
authMethod: 'local' as const,
|
|
oauthEnabled: false,
|
|
serviceAccountEnabled: false,
|
|
localAuthEnabled: true,
|
|
jiraHost: '',
|
|
};
|
|
|
|
// Parallelize API calls - this is the key optimization!
|
|
// Instead of waiting for config then auth (sequential), do both at once
|
|
await Promise.allSettled([
|
|
state.config ? Promise.resolve() : fetchConfig(),
|
|
checkAuth(),
|
|
]);
|
|
|
|
// Ensure config is set (use fetched or default)
|
|
const stateAfterInit = useAuthStore.getState();
|
|
if (!stateAfterInit.config) {
|
|
setConfig(defaultConfig);
|
|
}
|
|
|
|
// Ensure isLoading is false
|
|
const finalState = useAuthStore.getState();
|
|
if (finalState.isLoading) {
|
|
const { setLoading } = useAuthStore.getState();
|
|
setLoading(false);
|
|
}
|
|
|
|
setInitialized(true);
|
|
} catch (error) {
|
|
console.error('[App] Initialization error:', error);
|
|
// Always mark as initialized to prevent infinite loading
|
|
const state = useAuthStore.getState();
|
|
if (!state.config) {
|
|
setConfig({
|
|
appName: 'CMDB Insight',
|
|
appTagline: 'Management console for Jira Assets',
|
|
appCopyright: `© ${new Date().getFullYear()} Zuyderland Medisch Centrum`,
|
|
authMethod: 'local',
|
|
oauthEnabled: false,
|
|
serviceAccountEnabled: false,
|
|
localAuthEnabled: true,
|
|
jiraHost: '',
|
|
});
|
|
}
|
|
setInitialized(true);
|
|
}
|
|
})();
|
|
|
|
// Reduced timeout since we're optimizing - 1.5 seconds should be plenty
|
|
const timeoutId = setTimeout(() => {
|
|
const state = useAuthStore.getState();
|
|
if (!state.config) {
|
|
setConfig({
|
|
appName: 'CMDB Insight',
|
|
appTagline: 'Management console for Jira Assets',
|
|
appCopyright: `© ${new Date().getFullYear()} Zuyderland Medisch Centrum`,
|
|
authMethod: 'local',
|
|
oauthEnabled: false,
|
|
serviceAccountEnabled: false,
|
|
localAuthEnabled: true,
|
|
jiraHost: '',
|
|
});
|
|
}
|
|
setInitialized(true);
|
|
}, 1500);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []); // Empty deps - functions from store are stable
|
|
|
|
// Auth routes that should render outside the main layout
|
|
const isAuthRoute = ['/login', '/forgot-password', '/reset-password', '/accept-invitation'].includes(location.pathname);
|
|
|
|
// Handle missing config after initialization using useEffect
|
|
useEffect(() => {
|
|
if (isInitialized && !config) {
|
|
setConfig({
|
|
appName: 'CMDB Insight',
|
|
appTagline: 'Management console for Jira Assets',
|
|
appCopyright: `© ${new Date().getFullYear()} Zuyderland Medisch Centrum`,
|
|
authMethod: 'local',
|
|
oauthEnabled: false,
|
|
serviceAccountEnabled: false,
|
|
localAuthEnabled: true,
|
|
jiraHost: '',
|
|
});
|
|
}
|
|
}, [isInitialized, config, setConfig]);
|
|
|
|
// Get current config from store (might be updated by useEffect above)
|
|
const currentConfig = config || useAuthStore.getState().config;
|
|
|
|
// If on an auth route, render it directly (no layout) - don't wait for config
|
|
if (isAuthRoute) {
|
|
return (
|
|
<Suspense fallback={<LoadingFallback />}>
|
|
<Routes>
|
|
<Route path="/login" element={<Login />} />
|
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
|
<Route path="/reset-password" element={<ResetPassword />} />
|
|
<Route path="/accept-invitation" element={<AcceptInvitation />} />
|
|
</Routes>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
// For non-auth routes, we need config
|
|
// Show loading ONLY if we don't have config
|
|
// Once initialized and we have config, proceed even if isLoading is true
|
|
// (isLoading might be stuck due to StrictMode duplicate calls)
|
|
if (!currentConfig) {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
|
<p className="text-gray-600 font-medium">Laden...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// STRICT AUTHENTICATION CHECK:
|
|
// Service accounts are NOT used for application authentication
|
|
// They are only for Jira API access (JIRA_SERVICE_ACCOUNT_TOKEN in .env)
|
|
// Application authentication ALWAYS requires a real user session (local or OAuth)
|
|
|
|
// Check if this is a service account user (should never happen, but reject if it does)
|
|
const isServiceAccount = user?.accountId === 'service-account' || authMethod === 'service-account';
|
|
|
|
// Check if user is a real authenticated user (has id, not service account)
|
|
const isRealUser = isAuthenticated && user && user.id && !isServiceAccount;
|
|
|
|
// ALWAYS reject service account users - they are NOT valid for application authentication
|
|
if (isServiceAccount) {
|
|
return <Navigate to="/login" replace />;
|
|
}
|
|
|
|
// If not authenticated as a real user, redirect to login
|
|
if (!isRealUser) {
|
|
return <Navigate to="/login" replace />;
|
|
}
|
|
|
|
// Real user authenticated - allow access
|
|
|
|
// At this point, user is either:
|
|
// 1. Authenticated (isAuthenticated === true), OR
|
|
// 2. Service account is explicitly allowed (allowServiceAccount === true)
|
|
// Show main app
|
|
return <AppContent />;
|
|
}
|
|
|
|
export default App;
|