import { useEffect, useState, useRef } from 'react'; import { Routes, Route, Link, useLocation, Navigate, useParams, useNavigate } from 'react-router-dom'; import { clsx } from 'clsx'; import SearchDashboard from './components/SearchDashboard'; import Dashboard from './components/Dashboard'; import ApplicationList from './components/ApplicationList'; import ApplicationInfo from './components/ApplicationInfo'; import GovernanceModelHelper from './components/GovernanceModelHelper'; import TeamDashboard from './components/TeamDashboard'; import ConfigurationV25 from './components/ConfigurationV25'; import ReportsDashboard from './components/ReportsDashboard'; import GovernanceAnalysis from './components/GovernanceAnalysis'; import TechnicalDebtHeatmap from './components/TechnicalDebtHeatmap'; import LifecyclePipeline from './components/LifecyclePipeline'; import DataCompletenessScore from './components/DataCompletenessScore'; import ZiRADomainCoverage from './components/ZiRADomainCoverage'; import FTEPerZiRADomain from './components/FTEPerZiRADomain'; import ComplexityDynamicsBubbleChart from './components/ComplexityDynamicsBubbleChart'; import FTECalculator from './components/FTECalculator'; import DataCompletenessConfig from './components/DataCompletenessConfig'; import BIASyncDashboard from './components/BIASyncDashboard'; import BusinessImportanceComparison from './components/BusinessImportanceComparison'; import DataValidationDashboard from './components/DataValidationDashboard'; import SchemaConfigurationSettings from './components/SchemaConfigurationSettings'; import ArchitectureDebugPage from './components/ArchitectureDebugPage'; import Login from './components/Login'; import ForgotPassword from './components/ForgotPassword'; import ResetPassword from './components/ResetPassword'; import AcceptInvitation from './components/AcceptInvitation'; import ProtectedRoute from './components/ProtectedRoute'; import UserManagement from './components/UserManagement'; import RoleManagement from './components/RoleManagement'; import ProfileSettings from './components/ProfileSettings'; import { ToastContainerComponent } from './components/Toast'; import { useAuthStore } from './stores/authStore'; // Module-level singleton to prevent duplicate initialization across StrictMode remounts let initializationPromise: Promise | null = null; // Redirect component for old app-components/overview/:id paths function RedirectToApplicationEdit() { const { id } = useParams<{ id: string }>(); return ; } // 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(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 (
{isOpen && (
{visibleItems.map((item) => { const itemActive = item.exact ? location.pathname === item.path : location.pathname.startsWith(item.path); return ( {item.label} ); })}
)}
); } 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 (
{isOpen && ( <>
setIsOpen(false)} />

{displayName}

{email && (

{email}

)} {user.username && email !== user.username && (

@{user.username}

)}

{authMethod === 'oauth' ? 'Jira OAuth' : authMethod === 'local' ? 'Lokaal Account' : 'Service Account'}

{(authMethod === 'local' || authMethod === 'oauth') && ( <> setIsOpen(false)} className="block px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 transition-colors" > Profiel & Instellingen
)}
)}
); } 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 (
{/* Header */}
Zuyderland

{config?.appName || 'CMDB Insight'}

{config?.appTagline || 'Management console for Jira Assets'}

{/* Toast Notifications */} {/* Main content */}
{/* Main Dashboard (Search) */} } /> {/* Application routes (new structure) - specific routes first, then dynamic */} } /> } /> } /> {/* Application Component routes */} } /> {/* Reports routes */} } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> {/* Apps routes */} } /> } /> {/* Settings routes */} } /> } /> } /> } /> } /> } /> {/* Legacy redirects for old routes */} } /> {/* Admin routes */} } /> } /> } /> {/* Legacy redirects for bookmarks - redirect old paths to new ones */} } /> } /> } /> } /> } /> } /> } /> } /> } /> } />
); } 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 ( } /> } /> } /> } /> ); } // 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 (

Laden...

); } // 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 ; } // If not authenticated as a real user, redirect to login if (!isRealUser) { return ; } // 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 ; } export default App;