Add authentication, user management, and database migration features

- Implement OAuth 2.0 and PAT authentication methods
- Add user management, roles, and profile functionality
- Add database migrations and admin user scripts
- Update services for authentication and user settings
- Add protected routes and permission hooks
- Update documentation for authentication and database access
This commit is contained in:
2026-01-15 03:20:50 +01:00
parent f3637b85e1
commit 1fa424efb9
70 changed files with 15597 additions and 2098 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, useRef } from 'react';
import { Routes, Route, Link, useLocation, Navigate, useParams } from 'react-router-dom';
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';
@@ -22,8 +22,18 @@ import DataCompletenessConfig from './components/DataCompletenessConfig';
import BIASyncDashboard from './components/BIASyncDashboard';
import BusinessImportanceComparison from './components/BusinessImportanceComparison';
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 { useAuthStore } from './stores/authStore';
// 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 }>();
@@ -35,6 +45,7 @@ interface NavItem {
path: string;
label: string;
exact?: boolean;
requiredPermission?: string; // Permission required to see this menu item
}
interface NavDropdown {
@@ -45,11 +56,24 @@ interface NavDropdown {
}
// Dropdown component for navigation
function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive: boolean }) {
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) {
@@ -90,7 +114,7 @@ function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive:
{isOpen && (
<div className="absolute left-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
{dropdown.items.map((item) => {
{visibleItems.map((item) => {
const itemActive = item.exact
? location.pathname === item.path
: location.pathname.startsWith(item.path);
@@ -119,16 +143,26 @@ function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive:
function UserMenu() {
const { user, authMethod, logout } = useAuthStore();
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
if (!user) return null;
const initials = user.displayName
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
@@ -138,7 +172,7 @@ function UserMenu() {
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.displayName}
alt={displayName}
className="w-8 h-8 rounded-full"
/>
) : (
@@ -146,7 +180,7 @@ function UserMenu() {
<span className="text-white text-sm font-medium">{initials}</span>
</div>
)}
<span className="text-sm text-gray-700 hidden sm:block">{user.displayName}</span>
<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>
@@ -160,26 +194,36 @@ function UserMenu() {
/>
<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">{user.displayName}</p>
{user.emailAddress && (
<p className="text-xs text-gray-500 truncate">{user.emailAddress}</p>
<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' : 'Service Account'}
{authMethod === 'oauth' ? 'Jira OAuth' : authMethod === 'local' ? 'Lokaal Account' : 'Service Account'}
</p>
</div>
<div className="py-1">
{authMethod === 'oauth' && (
<button
onClick={() => {
setIsOpen(false);
logout();
}}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 transition-colors"
>
Uitloggen
</button>
{(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>
</>
@@ -190,15 +234,17 @@ function UserMenu() {
function AppContent() {
const location = useLocation();
const hasPermission = useAuthStore((state) => state.hasPermission);
const config = useAuthStore((state) => state.config);
// Navigation structure
const appComponentsDropdown: NavDropdown = {
label: 'Application Component',
basePath: '/application',
items: [
{ path: '/app-components', label: 'Dashboard', exact: true },
{ path: '/application/overview', label: 'Overzicht', exact: false },
{ path: '/application/fte-calculator', label: 'FTE Calculator', exact: true },
{ path: '/app-components', label: 'Dashboard', exact: true, requiredPermission: 'search' },
{ path: '/application/overview', label: 'Overzicht', exact: false, requiredPermission: 'search' },
{ path: '/application/fte-calculator', label: 'FTE Calculator', exact: true, requiredPermission: 'search' },
],
};
@@ -206,16 +252,16 @@ function AppContent() {
label: 'Rapporten',
basePath: '/reports',
items: [
{ path: '/reports', label: 'Overzicht', exact: true },
{ path: '/reports/team-dashboard', label: 'Team-indeling', exact: true },
{ path: '/reports/governance-analysis', label: 'Analyse Regiemodel', exact: true },
{ path: '/reports/technical-debt-heatmap', label: 'Technical Debt Heatmap', exact: true },
{ path: '/reports/lifecycle-pipeline', label: 'Lifecycle Pipeline', exact: true },
{ path: '/reports/data-completeness', label: 'Data Completeness Score', exact: true },
{ path: '/reports/zira-domain-coverage', label: 'ZiRA Domain Coverage', exact: true },
{ path: '/reports/fte-per-zira-domain', label: 'FTE per ZiRA Domain', exact: true },
{ path: '/reports/complexity-dynamics-bubble', label: 'Complexity vs Dynamics Bubble Chart', exact: true },
{ path: '/reports/business-importance-comparison', label: 'Business Importance vs BIA', exact: true },
{ 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' },
],
};
@@ -223,7 +269,7 @@ function AppContent() {
label: 'Apps',
basePath: '/apps',
items: [
{ path: '/apps/bia-sync', label: 'BIA Sync', exact: true },
{ path: '/apps/bia-sync', label: 'BIA Sync', exact: true, requiredPermission: 'search' },
],
};
@@ -231,9 +277,18 @@ function AppContent() {
label: 'Instellingen',
basePath: '/settings',
items: [
{ path: '/settings/fte-config', label: 'FTE Config', exact: true },
{ path: '/settings/data-model', label: 'Datamodel', exact: true },
{ path: '/settings/data-completeness-config', label: 'Data Completeness Config', exact: true },
{ path: '/settings/fte-config', label: 'FTE Config', exact: true, requiredPermission: 'manage_settings' },
{ path: '/settings/data-model', label: 'Datamodel', exact: true, requiredPermission: 'manage_settings' },
{ path: '/settings/data-completeness-config', label: 'Data Completeness Config', exact: true, requiredPermission: 'manage_settings' },
],
};
const adminDropdown: NavDropdown = {
label: 'Beheer',
basePath: '/admin',
items: [
{ path: '/admin/users', label: 'Gebruikers', exact: true, requiredPermission: 'manage_users' },
{ path: '/admin/roles', label: 'Rollen', exact: true, requiredPermission: 'manage_roles' },
],
};
@@ -241,7 +296,7 @@ function AppContent() {
const isReportsActive = location.pathname.startsWith('/reports');
const isSettingsActive = location.pathname.startsWith('/settings');
const isAppsActive = location.pathname.startsWith('/apps');
const isDashboardActive = location.pathname === '/';
const isAdminActive = location.pathname.startsWith('/admin');
return (
<div className="min-h-screen bg-white">
@@ -254,37 +309,27 @@ function AppContent() {
<img src="/logo-zuyderland.svg" alt="Zuyderland" className="w-9 h-9" />
<div>
<h1 className="text-lg font-semibold text-gray-900">
Analyse Tool
{config?.appName || 'CMDB Insight'}
</h1>
<p className="text-xs text-gray-500">Zuyderland CMDB</p>
<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">
{/* Dashboard (Search) */}
<Link
to="/"
className={clsx(
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
isDashboardActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
)}
>
Dashboard
</Link>
{/* Application Component Dropdown */}
<NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} />
<NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} hasPermission={hasPermission} />
{/* Apps Dropdown */}
<NavDropdown dropdown={appsDropdown} isActive={isAppsActive} />
<NavDropdown dropdown={appsDropdown} isActive={isAppsActive} hasPermission={hasPermission} />
{/* Reports Dropdown */}
<NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} />
<NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} hasPermission={hasPermission} />
{/* Settings Dropdown */}
<NavDropdown dropdown={settingsDropdown} isActive={isSettingsActive} />
<NavDropdown dropdown={settingsDropdown} isActive={isSettingsActive} hasPermission={hasPermission} />
{/* Admin Dropdown */}
<NavDropdown dropdown={adminDropdown} isActive={isAdminActive} hasPermission={hasPermission} />
</nav>
</div>
@@ -297,36 +342,43 @@ function AppContent() {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Routes>
{/* Main Dashboard (Search) */}
<Route path="/" element={<SearchDashboard />} />
<Route path="/" element={<ProtectedRoute><SearchDashboard /></ProtectedRoute>} />
{/* Application routes (new structure) */}
<Route path="/application/overview" element={<ApplicationList />} />
<Route path="/application/fte-calculator" element={<FTECalculator />} />
<Route path="/application/:id" element={<ApplicationInfo />} />
<Route path="/application/:id/edit" element={<GovernanceModelHelper />} />
{/* Application routes (new structure) - specific routes first, then dynamic */}
<Route path="/application/overview" element={<ProtectedRoute requirePermission="search"><ApplicationList /></ProtectedRoute>} />
<Route path="/application/fte-calculator" element={<ProtectedRoute requirePermission="search"><FTECalculator /></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={<Dashboard />} />
<Route path="/app-components" element={<ProtectedRoute requirePermission="search"><Dashboard /></ProtectedRoute>} />
{/* Reports routes */}
<Route path="/reports" element={<ReportsDashboard />} />
<Route path="/reports/team-dashboard" element={<TeamDashboard />} />
<Route path="/reports/governance-analysis" element={<GovernanceAnalysis />} />
<Route path="/reports/technical-debt-heatmap" element={<TechnicalDebtHeatmap />} />
<Route path="/reports/lifecycle-pipeline" element={<LifecyclePipeline />} />
<Route path="/reports/data-completeness" element={<DataCompletenessScore />} />
<Route path="/reports/zira-domain-coverage" element={<ZiRADomainCoverage />} />
<Route path="/reports/fte-per-zira-domain" element={<FTEPerZiRADomain />} />
<Route path="/reports/complexity-dynamics-bubble" element={<ComplexityDynamicsBubbleChart />} />
<Route path="/reports/business-importance-comparison" element={<BusinessImportanceComparison />} />
<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={<BIASyncDashboard />} />
<Route path="/apps/bia-sync" element={<ProtectedRoute requirePermission="search"><BIASyncDashboard /></ProtectedRoute>} />
{/* Settings routes */}
<Route path="/settings/fte-config" element={<ConfigurationV25 />} />
<Route path="/settings/data-model" element={<DataModelDashboard />} />
<Route path="/settings/data-completeness-config" element={<DataCompletenessConfig />} />
<Route path="/settings/fte-config" element={<ProtectedRoute requirePermission="manage_settings"><ConfigurationV25 /></ProtectedRoute>} />
<Route path="/settings/data-model" element={<ProtectedRoute requirePermission="manage_settings"><DataModelDashboard /></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>} />
{/* Legacy redirects for bookmarks - redirect old paths to new ones */}
<Route path="/app-components/overview" element={<Navigate to="/application/overview" replace />} />
@@ -336,7 +388,7 @@ function AppContent() {
<Route path="/applications/:id" element={<RedirectToApplicationEdit />} />
<Route path="/reports/data-model" element={<Navigate to="/settings/data-model" replace />} />
<Route path="/reports/bia-sync" element={<Navigate to="/apps/bia-sync" replace />} />
<Route path="/teams" element={<TeamDashboard />} />
<Route path="/teams" element={<ProtectedRoute requirePermission="view_reports"><TeamDashboard /></ProtectedRoute>} />
<Route path="/configuration" element={<Navigate to="/settings/fte-config" replace />} />
</Routes>
</main>
@@ -345,39 +397,180 @@ function AppContent() {
}
function App() {
const { isAuthenticated, isLoading, checkAuth, fetchConfig, config } = useAuthStore();
const { isAuthenticated, checkAuth, fetchConfig, config, user, authMethod, isInitialized, setInitialized, setConfig } = useAuthStore();
const location = useLocation();
useEffect(() => {
// Fetch auth config first, then check auth status
const init = async () => {
await fetchConfig();
await checkAuth();
};
init();
}, [fetchConfig, checkAuth]);
// 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;
}
// Show loading state
if (isLoading) {
// 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 (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<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>
);
}
// 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-cyan-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-400">Laden...</p>
<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>
);
}
// Show login if OAuth is enabled and not authenticated
if (config?.authMethod === 'oauth' && !isAuthenticated) {
return <Login />;
// 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 />;
}
// Show login if nothing is configured
if (config?.authMethod === 'none') {
return <Login />;
// 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 />;
}