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:
@@ -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 />;
|
||||
}
|
||||
|
||||
281
frontend/src/components/AcceptInvitation.tsx
Normal file
281
frontend/src/components/AcceptInvitation.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
|
||||
import AuthLayout from './AuthLayout';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface InvitationData {
|
||||
valid: boolean;
|
||||
user: {
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AcceptInvitation() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [invitationData, setInvitationData] = useState<InvitationData | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError('Geen uitnodiging token gevonden in de URL');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate invitation token
|
||||
fetch(`${API_BASE}/api/auth/invitation/${token}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.valid) {
|
||||
setInvitationData(data);
|
||||
} else {
|
||||
setError('Ongeldige of verlopen uitnodiging');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Failed to validate invitation');
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
const getPasswordStrength = (pwd: string): { strength: number; label: string; color: string } => {
|
||||
let strength = 0;
|
||||
if (pwd.length >= 8) strength++;
|
||||
if (pwd.length >= 12) strength++;
|
||||
if (/[a-z]/.test(pwd)) strength++;
|
||||
if (/[A-Z]/.test(pwd)) strength++;
|
||||
if (/[0-9]/.test(pwd)) strength++;
|
||||
if (/[^a-zA-Z0-9]/.test(pwd)) strength++;
|
||||
|
||||
if (strength <= 2) return { strength, label: 'Zwak', color: 'red' };
|
||||
if (strength <= 4) return { strength, label: 'Gemiddeld', color: 'yellow' };
|
||||
return { strength, label: 'Sterk', color: 'green' };
|
||||
};
|
||||
|
||||
const passwordStrength = password ? getPasswordStrength(password) : null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Wachtwoorden komen niet overeen');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Wachtwoord moet minimaal 8 tekens lang zijn');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/auth/accept-invitation`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to accept invitation');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
|
||||
<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">Uitnodiging valideren...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token || error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl p-8 shadow-xl">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-red-500/20 rounded-full mb-4">
|
||||
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Ongeldige uitnodiging</h2>
|
||||
<p className="text-slate-400 text-sm mb-6">
|
||||
{error || 'De uitnodiging is ongeldig of verlopen.'}
|
||||
</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-block px-4 py-2 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-medium rounded-lg transition-all duration-200"
|
||||
>
|
||||
Terug naar inloggen
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl p-8 shadow-xl">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-green-500/20 rounded-full mb-4">
|
||||
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Account geactiveerd</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Je account is succesvol geactiveerd. Je wordt doorgestuurd naar de login pagina...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-2 text-center">Welkom</h2>
|
||||
<p className="text-gray-600 text-sm mb-6 text-center">
|
||||
Stel je wachtwoord in om je account te activeren
|
||||
</p>
|
||||
|
||||
{invitationData && (
|
||||
<div className="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<p className="text-gray-700 text-sm mb-1">
|
||||
<span className="font-semibold">E-mail:</span> {invitationData.user.email}
|
||||
</p>
|
||||
<p className="text-gray-700 text-sm">
|
||||
<span className="font-semibold">Gebruikersnaam:</span> {invitationData.user.username}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-red-800 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Wachtwoord
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{passwordStrength && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
passwordStrength.color === 'red'
|
||||
? 'bg-red-500'
|
||||
: passwordStrength.color === 'yellow'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${(passwordStrength.strength / 6) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs font-medium ${
|
||||
passwordStrength.color === 'red'
|
||||
? 'text-red-600'
|
||||
: passwordStrength.color === 'yellow'
|
||||
? 'text-yellow-600'
|
||||
: 'text-green-600'
|
||||
}`}>
|
||||
{passwordStrength.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Bevestig wachtwoord
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{confirmPassword && password !== confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">Wachtwoorden komen niet overeen</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || password !== confirmPassword}
|
||||
className="w-full px-4 py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-200 shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Account activeren...
|
||||
</span>
|
||||
) : (
|
||||
'Account activeren'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -569,7 +569,7 @@ export default function ApplicationList() {
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string | null }) {
|
||||
export function StatusBadge({ status, variant = 'default' }: { status: string | null; variant?: 'default' | 'header' }) {
|
||||
const statusColors: Record<string, string> = {
|
||||
'Closed': 'badge-dark-red',
|
||||
'Deprecated': 'badge-yellow',
|
||||
@@ -582,7 +582,38 @@ export function StatusBadge({ status }: { status: string | null }) {
|
||||
'Undefined': 'badge-gray',
|
||||
};
|
||||
|
||||
if (!status) return <span className="text-sm text-gray-400">-</span>;
|
||||
// Header variant colors - matching blue/indigo palette of the header
|
||||
const headerStatusColors: Record<string, { bg: string; text: string }> = {
|
||||
'Closed': { bg: 'bg-slate-600', text: 'text-white' },
|
||||
'Deprecated': { bg: 'bg-amber-500', text: 'text-white' },
|
||||
'End of life': { bg: 'bg-red-500', text: 'text-white' },
|
||||
'End of support': { bg: 'bg-red-400', text: 'text-white' },
|
||||
'Implementation': { bg: 'bg-blue-500', text: 'text-white' },
|
||||
'In Production': { bg: 'bg-emerald-600', text: 'text-white' },
|
||||
'Proof of Concept': { bg: 'bg-teal-500', text: 'text-white' },
|
||||
'Shadow IT': { bg: 'bg-slate-800', text: 'text-white' },
|
||||
'Undefined': { bg: 'bg-slate-400', text: 'text-white' },
|
||||
};
|
||||
|
||||
if (!status) {
|
||||
if (variant === 'header') {
|
||||
return <span className="text-lg lg:text-xl text-white/70">-</span>;
|
||||
}
|
||||
return <span className="text-sm text-gray-400">-</span>;
|
||||
}
|
||||
|
||||
if (variant === 'header') {
|
||||
const colors = headerStatusColors[status] || { bg: 'bg-slate-400', text: 'text-white' };
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center px-4 py-1.5 rounded-lg text-base lg:text-lg font-semibold backdrop-blur-sm shadow-sm',
|
||||
colors.bg,
|
||||
colors.text
|
||||
)}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={clsx('badge', statusColors[status] || 'badge-gray')}>
|
||||
|
||||
60
frontend/src/components/AuthLayout.tsx
Normal file
60
frontend/src/components/AuthLayout.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Shared layout component for authentication pages
|
||||
* Provides consistent styling and structure
|
||||
*/
|
||||
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export default function AuthLayout({ children, title, subtitle }: AuthLayoutProps) {
|
||||
const { config } = useAuthStore();
|
||||
|
||||
// Use config values if title/subtitle not provided
|
||||
const appName = title || config?.appName || 'CMDB Insight';
|
||||
const appTagline = subtitle || config?.appTagline || 'Management console for Jira Assets';
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center p-4">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-blue-100 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-cyan-100 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob animation-delay-2000"></div>
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 bg-indigo-100 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob animation-delay-4000"></div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md relative z-10">
|
||||
{/* Logo / Header */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex items-center justify-center mb-6">
|
||||
<img
|
||||
src="/logo-zuyderland.svg"
|
||||
alt="Zuyderland"
|
||||
className="h-16 w-auto"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{appName}</h1>
|
||||
{appTagline && (
|
||||
<p className="text-gray-600 text-lg">{appTagline}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Card */}
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-8 shadow-2xl">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-gray-500 text-sm">
|
||||
{config?.appCopyright?.replace('{year}', new Date().getFullYear().toString()) || `© ${new Date().getFullYear()} Zuyderland Medisch Centrum`}
|
||||
</p>
|
||||
<p className="text-gray-400 text-xs mt-1">{appName} v1.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getDataCompletenessConfig, type DataCompletenessConfig } from '../services/api';
|
||||
import type { DataCompletenessConfig } from '../services/api';
|
||||
|
||||
interface FieldCompleteness {
|
||||
field: string;
|
||||
@@ -79,19 +79,18 @@ export default function DataCompletenessScore() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Fetch config and data in parallel
|
||||
const [configResult, dataResponse] = await Promise.all([
|
||||
getDataCompletenessConfig(),
|
||||
fetch(`${API_BASE}/dashboard/data-completeness`)
|
||||
]);
|
||||
|
||||
setConfig(configResult);
|
||||
// Fetch data (config is included in the response)
|
||||
const dataResponse = await fetch(`${API_BASE}/dashboard/data-completeness`);
|
||||
|
||||
if (!dataResponse.ok) {
|
||||
throw new Error('Failed to fetch data completeness data');
|
||||
}
|
||||
const result = await dataResponse.json();
|
||||
setData(result);
|
||||
// Config is now included in the response
|
||||
if (result.config) {
|
||||
setConfig(result.config);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load data');
|
||||
} finally {
|
||||
|
||||
121
frontend/src/components/ForgotPassword.tsx
Normal file
121
frontend/src/components/ForgotPassword.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import AuthLayout from './AuthLayout';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to send password reset email');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout title="Wachtwoord vergeten" subtitle="Herstel je wachtwoord">
|
||||
{success ? (
|
||||
<div className="space-y-5">
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-green-800 text-sm">
|
||||
Als er een account bestaat met dit e-mailadres, is er een wachtwoord reset link verzonden.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/login"
|
||||
className="block w-full text-center px-4 py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-200 shadow-md hover:shadow-lg"
|
||||
>
|
||||
Terug naar inloggen
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gray-600 text-sm mb-6 text-center">
|
||||
Voer je e-mailadres in en we sturen je een link om je wachtwoord te resetten.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-red-800 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
E-mailadres
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="jouw@email.nl"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-4 py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-200 shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verzenden...
|
||||
</span>
|
||||
) : (
|
||||
'Verstuur reset link'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium transition-colors"
|
||||
>
|
||||
← Terug naar inloggen
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { clsx } from 'clsx';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import {
|
||||
getApplicationForEdit,
|
||||
updateApplication,
|
||||
@@ -268,11 +269,12 @@ export default function GovernanceModelHelper() {
|
||||
|
||||
// Set page title
|
||||
useEffect(() => {
|
||||
const appName = useAuthStore.getState().config?.appName || 'CMDB Insight';
|
||||
if (application) {
|
||||
document.title = `${application.name} - Bewerken | Zuyderland CMDB`;
|
||||
document.title = `${application.name} - Bewerken | ${appName}`;
|
||||
}
|
||||
return () => {
|
||||
document.title = 'Zuyderland CMDB';
|
||||
document.title = appName;
|
||||
};
|
||||
}, [application]);
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore, getLoginUrl } from '../stores/authStore';
|
||||
import AuthLayout from './AuthLayout';
|
||||
|
||||
export default function Login() {
|
||||
const { config, error, isLoading, fetchConfig, checkAuth } = useAuthStore();
|
||||
const { config, error, isLoading, isAuthenticated, fetchConfig, checkAuth, localLogin, setError } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [authChoice, setAuthChoice] = useState<'local' | 'oauth' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
@@ -15,59 +22,212 @@ export default function Login() {
|
||||
if (loginSuccess === 'success') {
|
||||
// Remove query params and check auth
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
checkAuth();
|
||||
checkAuth().then(() => {
|
||||
// After checkAuth completes, redirect if authenticated
|
||||
const state = useAuthStore.getState();
|
||||
if (state.isAuthenticated && state.user) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (loginError) {
|
||||
useAuthStore.getState().setError(decodeURIComponent(loginError));
|
||||
setError(decodeURIComponent(loginError));
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
}, [fetchConfig, checkAuth]);
|
||||
|
||||
// Auto-select auth method if only one is available
|
||||
if (config) {
|
||||
if (config.localAuthEnabled && !config.oauthEnabled) {
|
||||
setAuthChoice('local');
|
||||
} else if (config.oauthEnabled && !config.localAuthEnabled) {
|
||||
setAuthChoice('oauth');
|
||||
}
|
||||
}
|
||||
}, [fetchConfig, checkAuth, setError, config, navigate]);
|
||||
|
||||
// Redirect if already authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const handleJiraLogin = () => {
|
||||
window.location.href = getLoginUrl();
|
||||
};
|
||||
|
||||
const handleLocalLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await localLogin(email, password);
|
||||
// Success - checkAuth will be called automatically
|
||||
await checkAuth();
|
||||
// Redirect to dashboard after successful login
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
// Error is already set in the store
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo / Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-cyan-500 to-blue-600 rounded-2xl mb-4 shadow-lg shadow-cyan-500/25">
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">CMDB Editor</h1>
|
||||
<p className="text-slate-400">ZiRA Classificatie Tool</p>
|
||||
</div>
|
||||
const showLocalAuth = config?.localAuthEnabled;
|
||||
const showOAuth = config?.oauthEnabled;
|
||||
const showBoth = showLocalAuth && showOAuth;
|
||||
|
||||
// PAT mode should NOT be shown to users - it's only for backend configuration
|
||||
// Always show login form by default - local auth should be available even if not explicitly enabled
|
||||
// (users can be created and local auth will be auto-enabled when first user exists)
|
||||
// Only hide login form if explicitly disabled via config
|
||||
const shouldShowLogin = showLocalAuth !== false; // Default to true unless explicitly false
|
||||
const shouldShowLocalLogin = showLocalAuth !== false; // Always show local login unless explicitly disabled
|
||||
|
||||
// Debug logging
|
||||
console.log('[Login] Config:', {
|
||||
authMethod: config?.authMethod,
|
||||
localAuthEnabled: config?.localAuthEnabled,
|
||||
oauthEnabled: config?.oauthEnabled,
|
||||
shouldShowLogin,
|
||||
shouldShowLocalLogin,
|
||||
showLocalAuth,
|
||||
showOAuth,
|
||||
});
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl p-8 shadow-xl">
|
||||
<h2 className="text-xl font-semibold text-white mb-6 text-center">Inloggen</h2>
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-2 text-center">Welkom terug</h2>
|
||||
<p className="text-sm text-gray-600 text-center mb-8">Log in om toegang te krijgen tot de applicatie</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-red-800 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config?.authMethod === 'oauth' ? (
|
||||
{/* Auth Method Selection (if both are available) */}
|
||||
{showBoth && !authChoice && shouldShowLogin && (
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-gray-600 text-center mb-4">Kies een inlogmethode:</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setAuthChoice('local')}
|
||||
className="w-full flex items-center justify-center gap-3 px-4 py-3.5 bg-white border-2 border-gray-300 hover:border-blue-500 text-gray-700 hover:text-blue-700 font-medium rounded-xl transition-all duration-200 shadow-sm hover:shadow-md group"
|
||||
>
|
||||
<svg className="w-5 h-5 group-hover:text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span>Lokaal Inloggen</span>
|
||||
<span className="text-xs text-gray-500 ml-auto">E-mail & Wachtwoord</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAuthChoice('oauth')}
|
||||
className="w-full flex items-center justify-center gap-3 px-4 py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-xl transition-all duration-200 shadow-md hover:shadow-lg"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.571 11.513H0a5.218 5.218 0 0 0 5.232 5.215h2.13v2.057A5.215 5.215 0 0 0 12.575 24V12.518a1.005 1.005 0 0 0-1.005-1.005zm5.723-5.756H5.736a5.215 5.215 0 0 0 5.215 5.214h2.129v2.058a5.218 5.218 0 0 0 5.215 5.214V6.758a1.001 1.001 0 0 0-1.001-1.001zM23.013 0H11.455a5.215 5.215 0 0 0 5.215 5.215h2.129v2.057A5.215 5.215 0 0 0 24 12.483V1.005A1.005 1.005 0 0 0 23.013 0z"/>
|
||||
</svg>
|
||||
<span>Inloggen met Jira</span>
|
||||
<span className="text-xs text-blue-200 ml-auto">OAuth 2.0</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local Login Form - Always show unless explicitly disabled */}
|
||||
{/* Show if: user selected local, OR local auth is enabled, OR no auth is configured (default to local) */}
|
||||
{(authChoice === 'local' || shouldShowLocalLogin || (!showOAuth && !config?.authMethod)) && (
|
||||
<form onSubmit={handleLocalLogin} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
E-mailadres of gebruikersnaam
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="jouw@email.nl of gebruikersnaam"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Wachtwoord
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium transition-colors"
|
||||
>
|
||||
Wachtwoord vergeten?
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-4 py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-200 shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Inloggen...
|
||||
</span>
|
||||
) : (
|
||||
'Inloggen'
|
||||
)}
|
||||
</button>
|
||||
{showBoth && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuthChoice(null)}
|
||||
className="w-full text-sm text-gray-600 hover:text-gray-900 font-medium transition-colors mt-2"
|
||||
>
|
||||
← Terug naar keuze
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* OAuth Login */}
|
||||
{(authChoice === 'oauth' || (showOAuth && !showLocalAuth)) && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleJiraLogin}
|
||||
className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-500 hover:to-blue-600 text-white font-medium rounded-xl transition-all duration-200 shadow-lg shadow-blue-600/25 hover:shadow-blue-500/40"
|
||||
className="w-full flex items-center justify-center gap-3 px-4 py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-200 shadow-md hover:shadow-lg"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.571 11.513H0a5.218 5.218 0 0 0 5.232 5.215h2.13v2.057A5.215 5.215 0 0 0 12.575 24V12.518a1.005 1.005 0 0 0-1.005-1.005zm5.723-5.756H5.736a5.215 5.215 0 0 0 5.215 5.214h2.129v2.058a5.218 5.218 0 0 0 5.215 5.214V6.758a1.001 1.001 0 0 0-1.001-1.001zM23.013 0H11.455a5.215 5.215 0 0 0 5.215 5.215h2.129v2.057A5.215 5.215 0 0 0 24 12.483V1.005A1.005 1.005 0 0 0 23.013 0z"/>
|
||||
@@ -75,49 +235,53 @@ export default function Login() {
|
||||
Inloggen met Jira
|
||||
</button>
|
||||
|
||||
<p className="mt-4 text-center text-slate-500 text-sm">
|
||||
<p className="mt-4 text-center text-gray-600 text-sm">
|
||||
Je wordt doorgestuurd naar Jira om in te loggen met OAuth 2.0
|
||||
</p>
|
||||
{showBoth && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuthChoice(null)}
|
||||
className="w-full text-sm text-gray-600 hover:text-gray-900 font-medium transition-colors mt-4"
|
||||
>
|
||||
← Terug naar keuze
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : config?.authMethod === 'pat' ? (
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-green-500/20 rounded-full mb-4">
|
||||
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-slate-300 mb-2">Personal Access Token Modus</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
De applicatie gebruikt een geconfigureerd Personal Access Token (PAT) voor Jira toegang.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 px-6 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Doorgaan
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-yellow-500/20 rounded-full mb-4">
|
||||
<svg className="w-6 h-6 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
)}
|
||||
|
||||
{/* Not Configured - Only show if both auth methods are explicitly disabled */}
|
||||
{/* PAT mode should NEVER be shown - it's only for backend Jira API configuration */}
|
||||
{/* Users always authenticate via local auth or OAuth */}
|
||||
{config?.localAuthEnabled === false && config?.oauthEnabled === false && (
|
||||
<div className="text-center py-6">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-yellow-100 rounded-full mb-4">
|
||||
<svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-slate-300 mb-2">Niet geconfigureerd</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Neem contact op met de beheerder om OAuth of een Personal Access Token te configureren.
|
||||
<p className="text-gray-900 font-semibold mb-2">Authenticatie niet geconfigureerd</p>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
Lokale authenticatie of OAuth moet worden ingeschakeld om gebruikers in te laten loggen.
|
||||
Neem contact op met de beheerder om authenticatie te configureren.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-8 text-center text-slate-600 text-sm">
|
||||
Zuyderland Medisch Centrum • CMDB Editor v1.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Not Configured */}
|
||||
{config?.authMethod === 'none' && (
|
||||
<div className="text-center py-6">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-yellow-100 rounded-full mb-4">
|
||||
<svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-900 font-semibold mb-2">Niet geconfigureerd</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Neem contact op met de beheerder om authenticatie te configureren.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
305
frontend/src/components/Profile.tsx
Normal file
305
frontend/src/components/Profile.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface Profile {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
email_verified: boolean;
|
||||
created_at: string;
|
||||
last_login: string | null;
|
||||
}
|
||||
|
||||
export default function Profile() {
|
||||
const { user } = useAuthStore();
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
}, []);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/profile`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch profile');
|
||||
const data = await response.json();
|
||||
setProfile(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load profile');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProfile = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const updates = {
|
||||
username: formData.get('username') as string,
|
||||
display_name: formData.get('display_name') as string || null,
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/profile`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to update profile');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setProfile(data);
|
||||
setSuccess('Profiel bijgewerkt');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update profile');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const currentPassword = formData.get('current_password') as string;
|
||||
const newPassword = formData.get('new_password') as string;
|
||||
const confirmPassword = formData.get('confirm_password') as string;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
throw new Error('Wachtwoorden komen niet overeen');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/profile/password`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to change password');
|
||||
}
|
||||
|
||||
setSuccess('Wachtwoord gewijzigd');
|
||||
setShowPasswordModal(false);
|
||||
(e.target as HTMLFormElement).reset();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to change password');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="inline-block w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return <div>Failed to load profile</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Profiel</h1>
|
||||
<p className="text-gray-600 mt-1">Beheer je profielgegevens</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p className="text-green-800">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-6">
|
||||
{/* Profile Information */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Profielgegevens</h2>
|
||||
<form onSubmit={handleUpdateProfile} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">E-mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={profile.email}
|
||||
disabled
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
E-mailadres kan niet worden gewijzigd
|
||||
{profile.email_verified ? (
|
||||
<span className="ml-2 text-green-600">✓ Geverifieerd</span>
|
||||
) : (
|
||||
<span className="ml-2 text-yellow-600">⚠ Niet geverifieerd</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Gebruikersnaam</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
defaultValue={profile.username}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Weergavenaam</label>
|
||||
<input
|
||||
type="text"
|
||||
name="display_name"
|
||||
defaultValue={profile.display_name || ''}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSaving ? 'Opslaan...' : 'Opslaan'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Account Information */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Accountinformatie</h2>
|
||||
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Account aangemaakt</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{new Date(profile.created_at).toLocaleDateString('nl-NL')}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Laatste login</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{profile.last_login
|
||||
? new Date(profile.last_login).toLocaleDateString('nl-NL')
|
||||
: 'Nog niet ingelogd'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Password Change */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Wachtwoord</h2>
|
||||
<button
|
||||
onClick={() => setShowPasswordModal(true)}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Wachtwoord wijzigen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change Password Modal */}
|
||||
{showPasswordModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">Wachtwoord wijzigen</h2>
|
||||
<form onSubmit={handleChangePassword}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Huidig wachtwoord
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="current_password"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nieuw wachtwoord
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new_password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bevestig nieuw wachtwoord
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Wijzigen...' : 'Wijzigen'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowPasswordModal(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1045
frontend/src/components/ProfileSettings.tsx
Normal file
1045
frontend/src/components/ProfileSettings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
110
frontend/src/components/ProtectedRoute.tsx
Normal file
110
frontend/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Protected Route Component
|
||||
*
|
||||
* Wrapper component for routes requiring authentication and/or permissions.
|
||||
*/
|
||||
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useHasPermission, useHasRole } from '../hooks/usePermissions';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requireAuth?: boolean;
|
||||
requirePermission?: string;
|
||||
requireRole?: string;
|
||||
requireAnyPermission?: string[];
|
||||
requireAllPermissions?: string[];
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({
|
||||
children,
|
||||
requireAuth = true,
|
||||
requirePermission,
|
||||
requireRole,
|
||||
requireAnyPermission,
|
||||
requireAllPermissions,
|
||||
}: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
const hasPermission = useHasPermission(requirePermission || '');
|
||||
const hasRole = useHasRole(requireRole || '');
|
||||
const hasAnyPermission = requireAnyPermission
|
||||
? requireAnyPermission.some(p => useHasPermission(p))
|
||||
: true;
|
||||
const hasAllPermissions = requireAllPermissions
|
||||
? requireAllPermissions.every(p => useHasPermission(p))
|
||||
: true;
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
if (requireAuth && !isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
// Check role
|
||||
if (requireRole && !hasRole) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Toegang geweigerd</h1>
|
||||
<p className="text-gray-600 mb-4">Je hebt niet de juiste rol om deze pagina te bekijken.</p>
|
||||
<p className="text-sm text-gray-500">Vereiste rol: {requireRole}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check permission
|
||||
if (requirePermission && !hasPermission) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Toegang geweigerd</h1>
|
||||
<p className="text-gray-600 mb-4">Je hebt niet de juiste rechten om deze pagina te bekijken.</p>
|
||||
<p className="text-sm text-gray-500">Vereiste rechten: {requirePermission}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check any permission
|
||||
if (requireAnyPermission && !hasAnyPermission) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Toegang geweigerd</h1>
|
||||
<p className="text-gray-600 mb-4">Je hebt niet de juiste rechten om deze pagina te bekijken.</p>
|
||||
<p className="text-sm text-gray-500">Vereiste rechten (één van): {requireAnyPermission.join(', ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check all permissions
|
||||
if (requireAllPermissions && !hasAllPermissions) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Toegang geweigerd</h1>
|
||||
<p className="text-gray-600 mb-4">Je hebt niet alle vereiste rechten om deze pagina te bekijken.</p>
|
||||
<p className="text-sm text-gray-500">Vereiste rechten (alle): {requireAllPermissions.join(', ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
222
frontend/src/components/ResetPassword.tsx
Normal file
222
frontend/src/components/ResetPassword.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function ResetPassword() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError('Geen reset token gevonden in de URL');
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const getPasswordStrength = (pwd: string): { strength: number; label: string; color: string } => {
|
||||
let strength = 0;
|
||||
if (pwd.length >= 8) strength++;
|
||||
if (pwd.length >= 12) strength++;
|
||||
if (/[a-z]/.test(pwd)) strength++;
|
||||
if (/[A-Z]/.test(pwd)) strength++;
|
||||
if (/[0-9]/.test(pwd)) strength++;
|
||||
if (/[^a-zA-Z0-9]/.test(pwd)) strength++;
|
||||
|
||||
if (strength <= 2) return { strength, label: 'Zwak', color: 'red' };
|
||||
if (strength <= 4) return { strength, label: 'Gemiddeld', color: 'yellow' };
|
||||
return { strength, label: 'Sterk', color: 'green' };
|
||||
};
|
||||
|
||||
const passwordStrength = password ? getPasswordStrength(password) : null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Wachtwoorden komen niet overeen');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Wachtwoord moet minimaal 8 tekens lang zijn');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to reset password');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl p-8 shadow-xl">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-red-500/20 rounded-full mb-4">
|
||||
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Ongeldige link</h2>
|
||||
<p className="text-slate-400 text-sm mb-6">
|
||||
De reset link is ongeldig of ontbreekt.
|
||||
</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-block px-4 py-2 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-medium rounded-lg transition-all duration-200"
|
||||
>
|
||||
Terug naar inloggen
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl p-8 shadow-xl">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-green-500/20 rounded-full mb-4">
|
||||
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Wachtwoord gereset</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Je wachtwoord is succesvol gereset. Je wordt doorgestuurd naar de login pagina...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-2xl p-8 shadow-xl">
|
||||
<h2 className="text-xl font-semibold text-white mb-6 text-center">Nieuw wachtwoord instellen</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-slate-300 mb-2">
|
||||
Nieuw wachtwoord
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{passwordStrength && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-slate-700 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
passwordStrength.color === 'red'
|
||||
? 'bg-red-500'
|
||||
: passwordStrength.color === 'yellow'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${(passwordStrength.strength / 6) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs ${
|
||||
passwordStrength.color === 'red'
|
||||
? 'text-red-400'
|
||||
: passwordStrength.color === 'yellow'
|
||||
? 'text-yellow-400'
|
||||
: 'text-green-400'
|
||||
}`}>
|
||||
{passwordStrength.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-300 mb-2">
|
||||
Bevestig wachtwoord
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{confirmPassword && password !== confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-400">Wachtwoorden komen niet overeen</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || password !== confirmPassword}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-medium rounded-xl transition-all duration-200 shadow-lg shadow-cyan-500/25 hover:shadow-cyan-500/40 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Wachtwoord resetten...' : 'Wachtwoord resetten'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||
>
|
||||
← Terug naar inloggen
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
410
frontend/src/components/RoleManagement.tsx
Normal file
410
frontend/src/components/RoleManagement.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useHasPermission } from '../hooks/usePermissions';
|
||||
import ProtectedRoute from './ProtectedRoute';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_system_role: boolean;
|
||||
created_at: string;
|
||||
permissions: Array<{ id: number; name: string; description: string | null; resource: string | null }>;
|
||||
}
|
||||
|
||||
interface Permission {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
resource: string | null;
|
||||
}
|
||||
|
||||
export default function RoleManagement() {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
||||
const [showPermissionModal, setShowPermissionModal] = useState(false);
|
||||
|
||||
const hasManageRoles = useHasPermission('manage_roles');
|
||||
|
||||
useEffect(() => {
|
||||
if (hasManageRoles) {
|
||||
fetchRoles();
|
||||
fetchPermissions();
|
||||
}
|
||||
}, [hasManageRoles]);
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/roles`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch roles');
|
||||
const data = await response.json();
|
||||
setRoles(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load roles');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/roles/permissions/all`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch permissions');
|
||||
const data = await response.json();
|
||||
setPermissions(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch permissions:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRole = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const name = formData.get('name') as string;
|
||||
const description = formData.get('description') as string;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/roles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name, description: description || null }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to create role');
|
||||
}
|
||||
|
||||
setShowCreateModal(false);
|
||||
fetchRoles();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async (roleId: number, name: string, description: string) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/roles/${roleId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name, description: description || null }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update role');
|
||||
fetchRoles();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRole = async (roleId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this role?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/roles/${roleId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete role');
|
||||
fetchRoles();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignPermission = async (roleId: number, permissionId: number) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/roles/${roleId}/permissions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ permission_id: permissionId }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to assign permission');
|
||||
fetchRoles();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to assign permission');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePermission = async (roleId: number, permissionId: number) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/roles/${roleId}/permissions/${permissionId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to remove permission');
|
||||
fetchRoles();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove permission');
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasManageRoles) {
|
||||
return (
|
||||
<ProtectedRoute requirePermission="manage_roles">
|
||||
<div>Access denied</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Rollenbeheer</h1>
|
||||
<p className="text-gray-600 mt-1">Beheer rollen en rechten</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
+ Nieuwe rol
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="inline-block w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="mt-2 text-gray-600">Laden...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Rol</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Rechten</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Acties</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{roles.map((role) => (
|
||||
<tr key={role.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-gray-900">{role.name}</div>
|
||||
{role.description && (
|
||||
<div className="text-sm text-gray-500">{role.description}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{role.permissions.map((perm) => (
|
||||
<span
|
||||
key={perm.id}
|
||||
className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800"
|
||||
>
|
||||
{perm.name}
|
||||
{!role.is_system_role && (
|
||||
<button
|
||||
onClick={() => handleRemovePermission(role.id, perm.id)}
|
||||
className="ml-1 text-green-600 hover:text-green-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{!role.is_system_role && (
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
handleAssignPermission(role.id, parseInt(e.target.value));
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
className="text-xs border border-gray-300 rounded px-1 py-0.5"
|
||||
>
|
||||
<option value="">+ Recht</option>
|
||||
{permissions
|
||||
.filter((perm) => !role.permissions.some((rp) => rp.id === perm.id))
|
||||
.map((perm) => (
|
||||
<option key={perm.id} value={perm.id}>
|
||||
{perm.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{role.is_system_role ? (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Systeem
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Aangepast
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{!role.is_system_role && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedRole(role);
|
||||
setShowPermissionModal(true);
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Bewerken
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteRole(role.id)}
|
||||
className="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Verwijderen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Role Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">Nieuwe rol</h2>
|
||||
<form onSubmit={handleCreateRole}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Naam</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschrijving</label>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Aanmaken
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Role Permissions Modal */}
|
||||
{showPermissionModal && selectedRole && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<h2 className="text-xl font-bold mb-4">Rechten beheren: {selectedRole.name}</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Toegewezen rechten</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedRole.permissions.map((perm) => (
|
||||
<span
|
||||
key={perm.id}
|
||||
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800"
|
||||
>
|
||||
{perm.name}
|
||||
<button
|
||||
onClick={() => {
|
||||
handleRemovePermission(selectedRole.id, perm.id);
|
||||
setSelectedRole({
|
||||
...selectedRole,
|
||||
permissions: selectedRole.permissions.filter((p) => p.id !== perm.id),
|
||||
});
|
||||
}}
|
||||
className="ml-2 text-green-600 hover:text-green-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Beschikbare rechten</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{permissions
|
||||
.filter((perm) => !selectedRole.permissions.some((rp) => rp.id === perm.id))
|
||||
.map((perm) => (
|
||||
<button
|
||||
key={perm.id}
|
||||
onClick={() => {
|
||||
handleAssignPermission(selectedRole.id, perm.id);
|
||||
setSelectedRole({
|
||||
...selectedRole,
|
||||
permissions: [...selectedRole.permissions, perm],
|
||||
});
|
||||
}}
|
||||
className="text-left px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="font-medium text-sm">{perm.name}</div>
|
||||
{perm.description && (
|
||||
<div className="text-xs text-gray-500">{perm.description}</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPermissionModal(false);
|
||||
setSelectedRole(null);
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
||||
>
|
||||
Sluiten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { searchCMDB, getConfig, CMDBSearchResponse, CMDBSearchResult, CMDBSearchObjectType } from '../services/api';
|
||||
|
||||
const ITEMS_PER_PAGE = 25;
|
||||
const APPLICATION_COMPONENT_TYPE_NAME = 'ApplicationComponent';
|
||||
const APPLICATION_COMPONENT_JIRA_NAME = 'Application Component'; // Jira API returns this name with space
|
||||
|
||||
// Helper to strip HTML tags from description
|
||||
function stripHtml(html: string): string {
|
||||
@@ -43,6 +44,74 @@ function getStatusInfo(status: string | null): { color: string; bg: string } {
|
||||
return { color: 'text-gray-600', bg: 'bg-gray-100' };
|
||||
}
|
||||
|
||||
// Helper to get icon component for object type
|
||||
function getObjectTypeIcon(objectTypeName: string | undefined, className: string = "w-6 h-6") {
|
||||
const name = objectTypeName?.toLowerCase() || '';
|
||||
|
||||
// Application Component - application window icon
|
||||
if (name.includes('application component') || name === 'applicationcomponent') {
|
||||
return (
|
||||
<svg className={`${className} text-blue-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
|
||||
{/* Window frame */}
|
||||
<rect x="3" y="4" width="18" height="14" rx="2" />
|
||||
{/* Title bar separator */}
|
||||
<line x1="3" y1="8" x2="21" y2="8" />
|
||||
{/* Window control dots */}
|
||||
<circle cx="6" cy="6" r="0.8" fill="currentColor" />
|
||||
<circle cx="9" cy="6" r="0.8" fill="currentColor" />
|
||||
<circle cx="12" cy="6" r="0.8" fill="currentColor" />
|
||||
{/* Content lines */}
|
||||
<line x1="5" y1="11" x2="19" y2="11" strokeWidth="1.5" />
|
||||
<line x1="5" y1="14" x2="15" y2="14" strokeWidth="1.5" />
|
||||
<line x1="5" y1="17" x2="17" y2="17" strokeWidth="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Flows - pijlen/flow icoon
|
||||
if (name.includes('flow')) {
|
||||
return (
|
||||
<svg className={`${className} text-indigo-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Server - server icoon
|
||||
if (name.includes('server')) {
|
||||
return (
|
||||
<svg className={`${className} text-green-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Application Function - functie/doel icoon (bar chart)
|
||||
if (name.includes('application function') || name.includes('function')) {
|
||||
return (
|
||||
<svg className={`${className} text-purple-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// User/Privileged User - gebruiker icoon
|
||||
if (name.includes('user') || name.includes('privileged')) {
|
||||
return (
|
||||
<svg className={`${className} text-orange-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Default - generiek object icoon (grid)
|
||||
return (
|
||||
<svg className={`${className} text-gray-500`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -90,11 +159,20 @@ export default function SearchDashboard() {
|
||||
return map;
|
||||
}, [searchResults]);
|
||||
|
||||
// Get sorted object types (by result count, descending)
|
||||
// Get sorted object types (Application Component first if exists, then by result count, descending)
|
||||
const sortedObjectTypes = useMemo(() => {
|
||||
if (!searchResults?.objectTypes) return [];
|
||||
|
||||
return [...searchResults.objectTypes].sort((a, b) => {
|
||||
// Check if either is Application Component
|
||||
const aIsAppComponent = a.name === APPLICATION_COMPONENT_TYPE_NAME || a.name === APPLICATION_COMPONENT_JIRA_NAME;
|
||||
const bIsAppComponent = b.name === APPLICATION_COMPONENT_TYPE_NAME || b.name === APPLICATION_COMPONENT_JIRA_NAME;
|
||||
|
||||
// If one is Application Component and the other isn't, Application Component comes first
|
||||
if (aIsAppComponent && !bIsAppComponent) return -1;
|
||||
if (!aIsAppComponent && bIsAppComponent) return 1;
|
||||
|
||||
// Otherwise, sort by count (descending)
|
||||
const countA = resultsByType.get(a.id)?.length || 0;
|
||||
const countB = resultsByType.get(b.id)?.length || 0;
|
||||
return countB - countA;
|
||||
@@ -178,14 +256,25 @@ export default function SearchDashboard() {
|
||||
setSearchResults(results);
|
||||
|
||||
// Auto-select first tab if results exist
|
||||
// Prioritize Application Component if it exists, otherwise select the tab with most results
|
||||
if (results.objectTypes && results.objectTypes.length > 0) {
|
||||
// Sort by count and select the first one
|
||||
const sorted = [...results.objectTypes].sort((a, b) => {
|
||||
const countA = results.results.filter(r => r.objectTypeId === a.id).length;
|
||||
const countB = results.results.filter(r => r.objectTypeId === b.id).length;
|
||||
return countB - countA;
|
||||
});
|
||||
setSelectedTab(sorted[0].id);
|
||||
// Find Application Component type if it exists
|
||||
const appComponentType = results.objectTypes.find(
|
||||
ot => ot.name === APPLICATION_COMPONENT_TYPE_NAME || ot.name === APPLICATION_COMPONENT_JIRA_NAME
|
||||
);
|
||||
|
||||
if (appComponentType) {
|
||||
// Application Component exists, select it
|
||||
setSelectedTab(appComponentType.id);
|
||||
} else {
|
||||
// No Application Component, sort by count and select the first one
|
||||
const sorted = [...results.objectTypes].sort((a, b) => {
|
||||
const countA = results.results.filter(r => r.objectTypeId === a.id).length;
|
||||
const countB = results.results.filter(r => r.objectTypeId === b.id).length;
|
||||
return countB - countA;
|
||||
});
|
||||
setSelectedTab(sorted[0].id);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -211,9 +300,11 @@ export default function SearchDashboard() {
|
||||
};
|
||||
|
||||
// Helper to check if a result is an Application Component (by looking up type name)
|
||||
// Jira API returns "Application Component" (with space), but internal typeName is "ApplicationComponent" (no space)
|
||||
const isApplicationComponent = useCallback((result: CMDBSearchResult) => {
|
||||
const objectType = objectTypeMap.get(result.objectTypeId);
|
||||
return objectType?.name === APPLICATION_COMPONENT_TYPE_NAME;
|
||||
return objectType?.name === APPLICATION_COMPONENT_TYPE_NAME ||
|
||||
objectType?.name === APPLICATION_COMPONENT_JIRA_NAME;
|
||||
}, [objectTypeMap]);
|
||||
|
||||
// Handle result click (for Application Components)
|
||||
@@ -231,318 +322,419 @@ export default function SearchDashboard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl mb-4 shadow-lg">
|
||||
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-1">CMDB Zoeken</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Zoek naar applicaties, servers, infrastructuur en andere items in de CMDB.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Form */}
|
||||
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12">
|
||||
{/* Header Section */}
|
||||
<div className="text-center mb-10 lg:mb-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 lg:w-20 lg:h-20 bg-gradient-to-br from-blue-600 via-blue-500 to-indigo-600 rounded-2xl mb-6 shadow-xl shadow-blue-500/20">
|
||||
<svg className="w-8 h-8 lg:w-10 lg:h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Zoek op naam, key, of beschrijving..."
|
||||
className="w-full pl-12 pr-28 py-3.5 text-base border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all outline-none"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !searchQuery.trim()}
|
||||
className="absolute inset-y-1.5 right-1.5 px-5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
{loading && (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
Zoeken
|
||||
</button>
|
||||
<h1 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-3 tracking-tight">
|
||||
CMDB Zoeken
|
||||
</h1>
|
||||
<p className="text-base lg:text-lg text-gray-600 max-w-4xl mx-auto">
|
||||
Zoek naar applicaties, servers, infrastructuur en andere items in de CMDB van Zuyderland
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="max-w-3xl mx-auto bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{hasSearched && searchResults && !loading && (
|
||||
<div className="space-y-4">
|
||||
{/* Results Summary */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">{searchResults.metadata.total}</span> resultaten gevonden
|
||||
{searchResults.metadata.total !== searchResults.results.length && (
|
||||
<span className="text-gray-400"> (eerste {searchResults.results.length} getoond)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{searchResults.results.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-xl">
|
||||
<svg className="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
{/* Search Form */}
|
||||
<form onSubmit={handleSearch} className="max-w-4xl mx-auto mb-8">
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-5 flex items-center pointer-events-none">
|
||||
<svg className="h-6 w-6 text-gray-400 group-focus-within:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<p className="text-gray-500">Geen resultaten gevonden voor "{searchQuery}"</p>
|
||||
<p className="text-gray-400 text-sm mt-1">Probeer een andere zoekterm</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Object Type Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-1 overflow-x-auto pb-px" aria-label="Tabs">
|
||||
{sortedObjectTypes.map((objectType) => {
|
||||
const count = resultsByType.get(objectType.id)?.length || 0;
|
||||
const isActive = selectedTab === objectType.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={objectType.id}
|
||||
onClick={() => handleTabChange(objectType.id)}
|
||||
className={`
|
||||
flex items-center gap-2 whitespace-nowrap py-3 px-4 border-b-2 text-sm font-medium transition-colors
|
||||
${isActive
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}
|
||||
`}
|
||||
>
|
||||
{jiraHost && objectType.iconUrl && (
|
||||
<img
|
||||
src={getAvatarUrl(objectType.iconUrl) || ''}
|
||||
alt=""
|
||||
className="w-4 h-4"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<span>{objectType.name}</span>
|
||||
<span className={`
|
||||
px-2 py-0.5 text-xs rounded-full
|
||||
${isActive ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'}
|
||||
`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Zoek op naam, key, beschrijving of andere attributen..."
|
||||
className="w-full pl-14 pr-32 py-4 lg:py-5 text-base lg:text-lg border-2 border-gray-200 rounded-2xl focus:border-blue-500 focus:ring-4 focus:ring-blue-100/50 transition-all outline-none shadow-sm hover:shadow-md focus:shadow-lg bg-white"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !searchQuery.trim()}
|
||||
className="absolute inset-y-2 right-2 px-6 lg:px-8 py-2.5 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 disabled:from-gray-300 disabled:to-gray-400 text-white font-semibold rounded-xl transition-all flex items-center gap-2 shadow-lg shadow-blue-500/30 hover:shadow-xl hover:shadow-blue-500/40 disabled:shadow-none disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Zoeken...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Zoeken</span>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{!hasSearched && (
|
||||
<p className="mt-3 text-sm text-gray-500 text-center">
|
||||
Tip: Gebruik trefwoorden zoals applicatienaam, object key of beschrijving
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Status Filter */}
|
||||
{statusOptions.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-gray-600">Filter op status:</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none"
|
||||
>
|
||||
<option value="">Alle statussen ({currentTabResults.length})</option>
|
||||
{statusOptions.map(status => {
|
||||
const count = currentTabResults.filter(r => {
|
||||
const s = getAttributeValue(r, 'Status');
|
||||
const sName = s && typeof s === 'object' && (s as any).name ? (s as any).name : s;
|
||||
return sName === status;
|
||||
}).length;
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="max-w-4xl mx-auto mb-8">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-blue-100 rounded-full mb-6">
|
||||
<svg className="animate-spin h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Zoeken...</h3>
|
||||
<p className="text-sm text-gray-600">We zoeken in de CMDB naar "{searchQuery}"</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="max-w-4xl mx-auto mb-8">
|
||||
<div className="bg-red-50 border-l-4 border-red-500 rounded-lg p-5 shadow-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-red-800 mb-1">Fout bij zoeken</h3>
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Section */}
|
||||
{hasSearched && searchResults && !loading && (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Results Summary */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
<span className="text-blue-600 font-semibold">{searchResults.metadata.total}</span> resultaten
|
||||
</span>
|
||||
</div>
|
||||
{searchResults.metadata.total !== searchResults.results.length && (
|
||||
<span className="text-sm text-gray-500">
|
||||
(eerste {searchResults.results.length} getoond)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searchResults.results.length === 0 ? (
|
||||
<div className="text-center py-16 lg:py-20 bg-white rounded-2xl shadow-sm border border-gray-200">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-gray-100 rounded-full mb-6">
|
||||
<svg className="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Geen resultaten gevonden</h3>
|
||||
<p className="text-gray-600 mb-1">We hebben geen resultaten gevonden voor "<span className="font-medium text-gray-900">"{searchQuery}"</span>"</p>
|
||||
<p className="text-sm text-gray-500 mt-4">Probeer een andere zoekterm of verfijn je zoekopdracht</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
{/* Object Type Tabs */}
|
||||
<div className="border-b border-gray-200 bg-gray-50/50 px-4 lg:px-6">
|
||||
<nav className="flex space-x-1 overflow-x-auto pb-0 -mb-px scrollbar-hide" aria-label="Tabs">
|
||||
{sortedObjectTypes.map((objectType) => {
|
||||
const count = resultsByType.get(objectType.id)?.length || 0;
|
||||
const isActive = selectedTab === objectType.id;
|
||||
|
||||
return (
|
||||
<option key={status} value={status}>
|
||||
{status} ({count})
|
||||
</option>
|
||||
<button
|
||||
key={objectType.id}
|
||||
onClick={() => handleTabChange(objectType.id)}
|
||||
className={`
|
||||
flex items-center gap-2.5 whitespace-nowrap py-4 px-5 border-b-2 text-sm font-medium transition-all relative
|
||||
${isActive
|
||||
? 'border-blue-600 text-blue-700 bg-white'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300 hover:bg-white/50'}
|
||||
`}
|
||||
>
|
||||
{jiraHost && objectType.iconUrl && (
|
||||
<img
|
||||
src={getAvatarUrl(objectType.iconUrl) || ''}
|
||||
alt=""
|
||||
className="w-5 h-5 flex-shrink-0"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium">{objectType.name}</span>
|
||||
<span className={`
|
||||
px-2.5 py-1 text-xs font-semibold rounded-full flex-shrink-0
|
||||
${isActive
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-gray-200 text-gray-600'}
|
||||
`}>
|
||||
{count}
|
||||
</span>
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"></div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{statusFilter && (
|
||||
<button
|
||||
onClick={() => setStatusFilter('')}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Wis filter
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results List */}
|
||||
<div className="space-y-2">
|
||||
{paginatedResults.map((result) => {
|
||||
const status = getAttributeValue(result, 'Status');
|
||||
// Handle status objects with nested structure (null check required because typeof null === 'object')
|
||||
const statusDisplay = status && typeof status === 'object' && (status as any).name
|
||||
? (status as any).name
|
||||
: status;
|
||||
const statusInfo = getStatusInfo(statusDisplay);
|
||||
const description = getAttributeValue(result, 'Description');
|
||||
const isClickable = isApplicationComponent(result);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.id}
|
||||
onClick={() => isClickable && handleResultClick(result)}
|
||||
className={`
|
||||
bg-white border border-gray-200 rounded-lg p-4
|
||||
${isClickable
|
||||
? 'cursor-pointer hover:border-blue-300 hover:shadow-sm transition-all'
|
||||
: ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center overflow-hidden">
|
||||
{result.avatarUrl && jiraHost ? (
|
||||
<img
|
||||
src={getAvatarUrl(result.avatarUrl) || ''}
|
||||
alt=""
|
||||
className="w-6 h-6"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).parentElement!.innerHTML = `
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" />
|
||||
</svg>
|
||||
`;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{/* Status Filter */}
|
||||
{statusOptions.length > 0 && (
|
||||
<div className="px-4 lg:px-6 py-4 bg-white border-b border-gray-100">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<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="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
Filter op status:
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none bg-white font-medium text-gray-700 min-w-[200px]"
|
||||
>
|
||||
<option value="">Alle statussen ({currentTabResults.length})</option>
|
||||
{statusOptions.map(status => {
|
||||
const count = currentTabResults.filter(r => {
|
||||
const s = getAttributeValue(r, 'Status');
|
||||
const sName = s && typeof s === 'object' && (s as any).name ? (s as any).name : s;
|
||||
return sName === status;
|
||||
}).length;
|
||||
return (
|
||||
<option key={status} value={status}>
|
||||
{status} ({count})
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{statusFilter && (
|
||||
<button
|
||||
onClick={() => setStatusFilter('')}
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1 px-3 py-2 rounded-lg hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Wis filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-gray-400 font-mono">{result.key}</span>
|
||||
{statusDisplay && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusInfo.bg} ${statusInfo.color}`}>
|
||||
{statusDisplay}
|
||||
</span>
|
||||
)}
|
||||
{isClickable && (
|
||||
<span className="text-xs text-blue-500 flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Klik om te openen
|
||||
</span>
|
||||
)}
|
||||
{/* Results List */}
|
||||
<div className="divide-y divide-gray-100">
|
||||
{paginatedResults.map((result) => {
|
||||
const status = getAttributeValue(result, 'Status');
|
||||
// Handle status objects with nested structure (null check required because typeof null === 'object')
|
||||
const statusDisplay = status && typeof status === 'object' && (status as any).name
|
||||
? (status as any).name
|
||||
: status;
|
||||
const statusInfo = getStatusInfo(statusDisplay);
|
||||
const description = getAttributeValue(result, 'Description');
|
||||
const isClickable = isApplicationComponent(result);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.id}
|
||||
onClick={() => isClickable && handleResultClick(result)}
|
||||
className={`
|
||||
px-4 lg:px-6 py-5 transition-all group
|
||||
${isClickable
|
||||
? 'cursor-pointer hover:bg-blue-50/50 hover:shadow-sm'
|
||||
: ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Avatar/Icon */}
|
||||
<div className="relative flex-shrink-0 w-12 h-12 bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl flex items-center justify-center overflow-hidden shadow-sm group-hover:shadow-md transition-shadow border border-blue-200/50">
|
||||
{(() => {
|
||||
const objectType = objectTypeMap.get(result.objectTypeId);
|
||||
const typeIcon = getObjectTypeIcon(objectType?.name);
|
||||
|
||||
// Show type-specific icon (more meaningful than generic placeholder)
|
||||
// If Jira provides an avatar, it will be shown via CSS background-image if needed
|
||||
return typeIcon;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap mb-2">
|
||||
<span className="text-xs text-gray-500 font-mono bg-gray-100 px-2 py-1 rounded-md">{result.key}</span>
|
||||
{statusDisplay && (
|
||||
<span className={`text-xs px-2.5 py-1 rounded-full font-semibold ${statusInfo.bg} ${statusInfo.color}`}>
|
||||
{statusDisplay}
|
||||
</span>
|
||||
)}
|
||||
{isClickable && (
|
||||
<span className="text-xs text-blue-600 font-medium flex items-center gap-1.5 px-2.5 py-1 bg-blue-50 rounded-full">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Klik om te openen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-base lg:text-lg font-semibold text-gray-900 mb-2 group-hover:text-blue-700 transition-colors">
|
||||
{result.label}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-600 leading-relaxed line-clamp-2">
|
||||
{stripHtml(description).substring(0, 250)}
|
||||
{stripHtml(description).length > 250 && '...'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isClickable && (
|
||||
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-medium text-gray-900 mt-0.5">{result.label}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
|
||||
{stripHtml(description).substring(0, 200)}
|
||||
{stripHtml(description).length > 200 && '...'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
Pagina {pageForCurrentTab} van {totalPages} ({filteredResults.length} items)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(pageForCurrentTab - 1)}
|
||||
disabled={pageForCurrentTab === 1}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Vorige
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(pageForCurrentTab + 1)}
|
||||
disabled={pageForCurrentTab === totalPages}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Volgende
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Links (only show when no search has been performed) */}
|
||||
{!hasSearched && (
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-3 gap-4 max-w-2xl mx-auto">
|
||||
<a
|
||||
href="/app-components"
|
||||
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-200 transition-colors">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-4 lg:px-6 py-4 bg-gray-50/50 border-t border-gray-200">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-gray-600 font-medium">
|
||||
Pagina <span className="text-gray-900">{pageForCurrentTab}</span> van <span className="text-gray-900">{totalPages}</span>
|
||||
<span className="text-gray-500 ml-2">({filteredResults.length} items)</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(pageForCurrentTab - 1)}
|
||||
disabled={pageForCurrentTab === 1}
|
||||
className="px-4 py-2 text-sm font-medium border border-gray-300 rounded-lg hover:bg-white hover:border-gray-400 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors bg-white shadow-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Vorige
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(pageForCurrentTab + 1)}
|
||||
disabled={pageForCurrentTab === totalPages}
|
||||
className="px-4 py-2 text-sm font-medium border border-gray-300 rounded-lg hover:bg-white hover:border-gray-400 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors bg-white shadow-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
Volgende
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Application Components</p>
|
||||
<p className="text-sm text-gray-500">Dashboard & overzicht</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<a
|
||||
href="/reports/team-dashboard"
|
||||
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-200 transition-colors">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Rapporten</p>
|
||||
<p className="text-sm text-gray-500">Team-indeling & analyses</p>
|
||||
</div>
|
||||
</a>
|
||||
{/* Quick Links (only show when no search has been performed) */}
|
||||
{!hasSearched && (
|
||||
<div className="mt-12 lg:mt-16">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6 text-center">Snelle toegang</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
<Link
|
||||
to="/app-components"
|
||||
className="group relative bg-white rounded-2xl p-6 shadow-sm border border-gray-200 hover:shadow-lg hover:border-blue-200 transition-all overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-blue-100/50 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<div className="relative">
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center mb-4 shadow-lg shadow-blue-500/20 group-hover:scale-110 transition-transform">
|
||||
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2 group-hover:text-blue-700 transition-colors">
|
||||
Application Components
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
Dashboard & overzicht van alle applicatiecomponenten
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href="/app-components/fte-config"
|
||||
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-200 transition-colors">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<Link
|
||||
to="/reports/team-dashboard"
|
||||
className="group relative bg-white rounded-2xl p-6 shadow-sm border border-gray-200 hover:shadow-lg hover:border-green-200 transition-all overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-green-100/50 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<div className="relative">
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center mb-4 shadow-lg shadow-green-500/20 group-hover:scale-110 transition-transform">
|
||||
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2 group-hover:text-green-700 transition-colors">
|
||||
Rapporten
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
Team-indeling, analyses en portfolio-overzichten
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/settings/fte-config"
|
||||
className="group relative bg-white rounded-2xl p-6 shadow-sm border border-gray-200 hover:shadow-lg hover:border-purple-200 transition-all overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-purple-100/50 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<div className="relative">
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl flex items-center justify-center mb-4 shadow-lg shadow-purple-500/20 group-hover:scale-110 transition-transform">
|
||||
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2 group-hover:text-purple-700 transition-colors">
|
||||
Configuratie
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
FTE berekening en beheerparameters instellen
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Configuratie</p>
|
||||
<p className="text-sm text-gray-500">FTE berekening</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
740
frontend/src/components/UserManagement.tsx
Normal file
740
frontend/src/components/UserManagement.tsx
Normal file
@@ -0,0 +1,740 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useHasPermission } from '../hooks/usePermissions';
|
||||
import ProtectedRoute from './ProtectedRoute';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
is_active: boolean;
|
||||
email_verified: boolean;
|
||||
created_at: string;
|
||||
last_login: string | null;
|
||||
roles: Array<{ id: number; name: string; description: string | null }>;
|
||||
}
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export default function UserManagement() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [expandedUser, setExpandedUser] = useState<number | null>(null);
|
||||
const [actionMenuOpen, setActionMenuOpen] = useState<number | null>(null);
|
||||
|
||||
const hasManageUsers = useHasPermission('manage_users');
|
||||
|
||||
useEffect(() => {
|
||||
if (hasManageUsers) {
|
||||
fetchUsers();
|
||||
fetchRoles();
|
||||
}
|
||||
}, [hasManageUsers]);
|
||||
|
||||
// Close action menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => {
|
||||
setActionMenuOpen(null);
|
||||
};
|
||||
if (actionMenuOpen !== null) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [actionMenuOpen]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/users`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch users');
|
||||
const data = await response.json();
|
||||
setUsers(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load users');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/roles`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch roles');
|
||||
const data = await response.json();
|
||||
setRoles(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch roles:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const showSuccess = (message: string) => {
|
||||
setSuccess(message);
|
||||
setTimeout(() => setSuccess(null), 5000);
|
||||
};
|
||||
|
||||
const showError = (message: string) => {
|
||||
setError(message);
|
||||
setTimeout(() => setError(null), 5000);
|
||||
};
|
||||
|
||||
const handleCreateUser = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const email = formData.get('email') as string;
|
||||
const username = formData.get('username') as string;
|
||||
const displayName = formData.get('display_name') as string;
|
||||
const sendInvitation = formData.get('send_invitation') === 'on';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/users`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
username,
|
||||
display_name: displayName || null,
|
||||
send_invitation: sendInvitation,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to create user');
|
||||
}
|
||||
|
||||
setShowCreateModal(false);
|
||||
showSuccess('Gebruiker succesvol aangemaakt');
|
||||
fetchUsers();
|
||||
(e.target as HTMLFormElement).reset();
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Failed to create user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInviteUser = async (userId: number) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/users/${userId}/invite`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to send invitation');
|
||||
}
|
||||
|
||||
showSuccess('Uitnodiging succesvol verzonden');
|
||||
setActionMenuOpen(null);
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Failed to send invitation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (userId: number, isActive: boolean) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/users/${userId}/activate`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ is_active: !isActive }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update user');
|
||||
showSuccess(`Gebruiker ${!isActive ? 'geactiveerd' : 'gedeactiveerd'}`);
|
||||
fetchUsers();
|
||||
setActionMenuOpen(null);
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Failed to update user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignRole = async (userId: number, roleId: number) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/users/${userId}/roles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ role_id: roleId }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to assign role');
|
||||
showSuccess('Rol toegewezen');
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Failed to assign role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRole = async (userId: number, roleId: number) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/users/${userId}/roles/${roleId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to remove role');
|
||||
showSuccess('Rol verwijderd');
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Failed to remove role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId: number) => {
|
||||
if (!confirm('Weet je zeker dat je deze gebruiker wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete user');
|
||||
showSuccess('Gebruiker succesvol verwijderd');
|
||||
fetchUsers();
|
||||
setActionMenuOpen(null);
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Failed to delete user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyEmail = async (userId: number) => {
|
||||
if (!confirm('Weet je zeker dat je het e-mailadres van deze gebruiker wilt verifiëren?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/users/${userId}/verify-email`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to verify email');
|
||||
}
|
||||
|
||||
showSuccess('E-mailadres succesvol geverifieerd');
|
||||
fetchUsers();
|
||||
setActionMenuOpen(null);
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Failed to verify email');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPassword = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!selectedUser) return;
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const password = formData.get('password') as string;
|
||||
const confirmPassword = formData.get('confirm_password') as string;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showError('Wachtwoorden komen niet overeen');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
showError('Wachtwoord moet minimaal 8 tekens lang zijn');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/users/${selectedUser.id}/password`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to set password');
|
||||
}
|
||||
|
||||
showSuccess('Wachtwoord succesvol ingesteld');
|
||||
setShowPasswordModal(false);
|
||||
setSelectedUser(null);
|
||||
(e.target as HTMLFormElement).reset();
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Failed to set password');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(user) =>
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(user.display_name && user.display_name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
|
||||
const getUserInitials = (user: User) => {
|
||||
if (user.display_name) {
|
||||
return user.display_name
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
return user.username.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Nog niet ingelogd';
|
||||
return new Date(dateString).toLocaleDateString('nl-NL', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
if (!hasManageUsers) {
|
||||
return (
|
||||
<ProtectedRoute requirePermission="manage_users">
|
||||
<div>Access denied</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Gebruikersbeheer</h1>
|
||||
<p className="text-gray-600 mt-1">Beheer gebruikers, rollen en rechten</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-sm hover:shadow-md"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Nieuwe gebruiker
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
{success && (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg flex items-start gap-3 animate-in slide-in-from-top">
|
||||
<svg className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-green-800 font-medium">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3 animate-in slide-in-from-top">
|
||||
<svg className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-red-800 font-medium">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Stats */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between mb-6">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Zoek op naam, e-mail of gebruikersnaam..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span className="font-medium text-gray-900">{filteredUsers.length}</span>
|
||||
<span>van {users.length} gebruikers</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Grid */}
|
||||
{isLoading ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="inline-block w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="mt-4 text-gray-600 font-medium">Gebruikers laden...</p>
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
<p className="mt-4 text-gray-600 font-medium">Geen gebruikers gevonden</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchTerm ? 'Probeer een andere zoekterm' : 'Maak je eerste gebruiker aan'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{filteredUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="bg-gradient-to-br from-white to-gray-50 rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<div className="p-5">
|
||||
{/* User Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-semibold text-lg shadow-md">
|
||||
{getUserInitials(user)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 text-lg">
|
||||
{user.display_name || user.username}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">@{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActionMenuOpen(actionMenuOpen === user.id ? null : user.id);
|
||||
}}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
{actionMenuOpen === user.id && (
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<div className="py-1">
|
||||
{!user.email_verified && (
|
||||
<button
|
||||
onClick={() => handleVerifyEmail(user.id)}
|
||||
className="w-full text-left px-4 py-2 text-sm text-yellow-700 hover:bg-yellow-50 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Verifieer e-mail
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setShowPasswordModal(true);
|
||||
setActionMenuOpen(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
Wachtwoord instellen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleInviteUser(user.id)}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Uitnodiging verzenden
|
||||
</button>
|
||||
<div className="border-t border-gray-100 my-1"></div>
|
||||
<button
|
||||
onClick={() => handleToggleActive(user.id, user.is_active)}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={user.is_active ? "M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" : "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"} />
|
||||
</svg>
|
||||
{user.is_active ? 'Deactiveren' : 'Activeren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteUser(user.id)}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Verwijderen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Badges */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
user.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${user.is_active ? 'bg-green-600' : 'bg-red-600'}`}></div>
|
||||
{user.is_active ? 'Actief' : 'Inactief'}
|
||||
</span>
|
||||
{user.email_verified ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
E-mail geverifieerd
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
E-mail niet geverifieerd
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Roles */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
<span className="text-xs font-medium text-gray-500 uppercase">Rollen</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{user.roles.length > 0 ? (
|
||||
user.roles.map((role) => (
|
||||
<span
|
||||
key={role.id}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium bg-blue-50 text-blue-700 border border-blue-200"
|
||||
>
|
||||
{role.name}
|
||||
<button
|
||||
onClick={() => handleRemoveRole(user.id, role.id)}
|
||||
className="hover:text-blue-900 transition-colors"
|
||||
title="Rol verwijderen"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 italic">Geen rollen toegewezen</span>
|
||||
)}
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
handleAssignRole(user.id, parseInt(e.target.value));
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
className="text-xs border border-gray-300 rounded-md px-2 py-1 bg-white hover:bg-gray-50 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
title="Rol toevoegen"
|
||||
>
|
||||
<option value="">+ Rol toevoegen</option>
|
||||
{roles
|
||||
.filter((role) => !user.roles.some((ur) => ur.id === role.id))
|
||||
.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="pt-4 border-t border-gray-100">
|
||||
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
|
||||
<div>
|
||||
<span className="font-medium text-gray-600">Aangemaakt:</span>
|
||||
<p className="mt-0.5">{formatDate(user.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-600">Laatste login:</span>
|
||||
<p className="mt-0.5">{formatDate(user.last_login)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create User Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Nieuwe gebruiker</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">Voeg een nieuwe gebruiker toe aan het systeem</p>
|
||||
</div>
|
||||
<form onSubmit={handleCreateUser} className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
E-mailadres
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="gebruiker@voorbeeld.nl"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Gebruikersnaam
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
required
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="gebruikersnaam"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Weergavenaam
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="display_name"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Voornaam Achternaam (optioneel)"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="send_invitation"
|
||||
id="send_invitation"
|
||||
defaultChecked
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="send_invitation" className="ml-3 text-sm text-gray-700">
|
||||
Stuur uitnodigingsemail naar de gebruiker
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Gebruiker aanmaken
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1 px-4 py-2.5 bg-gray-200 text-gray-700 font-medium rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Set Password Modal */}
|
||||
{showPasswordModal && selectedUser && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Wachtwoord instellen</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Stel een nieuw wachtwoord in voor <strong>{selectedUser.display_name || selectedUser.username}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSetPassword} className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Nieuw wachtwoord
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Minimaal 8 tekens"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Bevestig wachtwoord
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Bevestig het wachtwoord"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Wachtwoord instellen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowPasswordModal(false);
|
||||
setSelectedUser(null);
|
||||
setError(null);
|
||||
}}
|
||||
className="flex-1 px-4 py-2.5 bg-gray-200 text-gray-700 font-medium rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
308
frontend/src/components/UserSettings.tsx
Normal file
308
frontend/src/components/UserSettings.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface UserSettings {
|
||||
jira_pat: string | null;
|
||||
ai_enabled: boolean;
|
||||
ai_provider: 'openai' | 'anthropic' | null;
|
||||
ai_api_key: string | null;
|
||||
web_search_enabled: boolean;
|
||||
tavily_api_key: string | null;
|
||||
}
|
||||
|
||||
export default function UserSettings() {
|
||||
const { user } = useAuthStore();
|
||||
const [settings, setSettings] = useState<UserSettings>({
|
||||
jira_pat: null,
|
||||
ai_enabled: false,
|
||||
ai_provider: null,
|
||||
ai_api_key: null,
|
||||
web_search_enabled: false,
|
||||
tavily_api_key: null,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [jiraPatStatus, setJiraPatStatus] = useState<{ configured: boolean; valid: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
fetchJiraPatStatus();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/user-settings`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch settings');
|
||||
const data = await response.json();
|
||||
setSettings({
|
||||
jira_pat: data.jira_pat === '***' ? '' : data.jira_pat,
|
||||
ai_enabled: data.ai_enabled,
|
||||
ai_provider: data.ai_provider,
|
||||
ai_api_key: data.ai_api_key === '***' ? '' : data.ai_api_key,
|
||||
web_search_enabled: data.web_search_enabled,
|
||||
tavily_api_key: data.tavily_api_key === '***' ? '' : data.tavily_api_key,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load settings');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchJiraPatStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/user-settings/jira-pat/status`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setJiraPatStatus(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch Jira PAT status:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const updates: Partial<UserSettings> = {
|
||||
jira_pat: formData.get('jira_pat') as string || undefined,
|
||||
ai_enabled: formData.get('ai_enabled') === 'on',
|
||||
ai_provider: (formData.get('ai_provider') as 'openai' | 'anthropic') || undefined,
|
||||
ai_api_key: formData.get('ai_api_key') as string || undefined,
|
||||
web_search_enabled: formData.get('web_search_enabled') === 'on',
|
||||
tavily_api_key: formData.get('tavily_api_key') as string || undefined,
|
||||
};
|
||||
|
||||
// Only send fields that have values
|
||||
Object.keys(updates).forEach((key) => {
|
||||
if (updates[key as keyof UserSettings] === '' || updates[key as keyof UserSettings] === undefined) {
|
||||
delete updates[key as keyof UserSettings];
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/user-settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to save settings');
|
||||
}
|
||||
|
||||
setSuccess('Instellingen opgeslagen');
|
||||
fetchSettings();
|
||||
fetchJiraPatStatus();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save settings');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidateJiraPat = async () => {
|
||||
const form = document.getElementById('settings-form') as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
const pat = formData.get('jira_pat') as string;
|
||||
|
||||
if (!pat) {
|
||||
setError('Voer eerst een Jira PAT in');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/user-settings/jira-pat/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ pat }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Validation failed');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.valid) {
|
||||
setSuccess('Jira PAT is geldig');
|
||||
} else {
|
||||
setError('Jira PAT is ongeldig');
|
||||
}
|
||||
fetchJiraPatStatus();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to validate Jira PAT');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="inline-block w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Instellingen</h1>
|
||||
<p className="text-gray-600 mt-1">Beheer je persoonlijke instellingen</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p className="text-green-800">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form id="settings-form" onSubmit={handleSave} className="bg-white rounded-lg shadow p-6 space-y-6">
|
||||
{/* Jira PAT Section */}
|
||||
<div className="border-b border-gray-200 pb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Jira Personal Access Token</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Personal Access Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="jira_pat"
|
||||
defaultValue={settings.jira_pat || ''}
|
||||
placeholder="Voer je Jira PAT in"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Dit token wordt gebruikt voor authenticatie met Jira
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleValidateJiraPat}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Valideer PAT
|
||||
</button>
|
||||
{jiraPatStatus && (
|
||||
<span
|
||||
className={`inline-flex items-center px-3 py-2 rounded-lg text-sm font-medium ${
|
||||
jiraPatStatus.configured && jiraPatStatus.valid
|
||||
? 'bg-green-100 text-green-800'
|
||||
: jiraPatStatus.configured
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{jiraPatStatus.configured && jiraPatStatus.valid
|
||||
? '✓ Geconfigureerd en geldig'
|
||||
: jiraPatStatus.configured
|
||||
? '⚠ Geconfigureerd maar ongeldig'
|
||||
: 'Niet geconfigureerd'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Features Section */}
|
||||
<div className="border-b border-gray-200 pb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">AI Functies</h2>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="ai_enabled"
|
||||
defaultChecked={settings.ai_enabled}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">AI functies inschakelen</span>
|
||||
</label>
|
||||
{settings.ai_enabled && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">AI Provider</label>
|
||||
<select
|
||||
name="ai_provider"
|
||||
defaultValue={settings.ai_provider || ''}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Selecteer provider</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
name="ai_api_key"
|
||||
defaultValue={settings.ai_api_key || ''}
|
||||
placeholder="Voer je API key in"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Web Search Section */}
|
||||
<div className="border-b border-gray-200 pb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Web Zoeken</h2>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="web_search_enabled"
|
||||
defaultChecked={settings.web_search_enabled}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Web zoeken inschakelen</span>
|
||||
</label>
|
||||
{settings.web_search_enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Tavily API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
name="tavily_api_key"
|
||||
defaultValue={settings.tavily_api_key || ''}
|
||||
placeholder="Voer je Tavily API key in"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSaving ? 'Opslaan...' : 'Opslaan'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
frontend/src/hooks/usePermissions.ts
Normal file
55
frontend/src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Permission Hooks
|
||||
*
|
||||
* React hooks for checking user permissions and roles.
|
||||
*/
|
||||
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission
|
||||
*/
|
||||
export function useHasPermission(permission: string): boolean {
|
||||
const hasPermission = useAuthStore((state) => state.hasPermission(permission));
|
||||
return hasPermission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a specific role
|
||||
*/
|
||||
export function useHasRole(role: string): boolean {
|
||||
const hasRole = useAuthStore((state) => state.hasRole(role));
|
||||
return hasRole;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user permissions
|
||||
*/
|
||||
export function usePermissions(): string[] {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
return user?.permissions || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user roles
|
||||
*/
|
||||
export function useRoles(): string[] {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
return user?.roles || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has any of the specified permissions
|
||||
*/
|
||||
export function useHasAnyPermission(permissions: string[]): boolean {
|
||||
const userPermissions = usePermissions();
|
||||
return permissions.some(permission => userPermissions.includes(permission));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has all of the specified permissions
|
||||
*/
|
||||
export function useHasAllPermissions(permissions: string[]): boolean {
|
||||
const userPermissions = usePermissions();
|
||||
return permissions.every(permission => userPermissions.includes(permission));
|
||||
}
|
||||
@@ -8,6 +8,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blob {
|
||||
0% {
|
||||
transform: translate(0px, 0px) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -50px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0px, 0px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
@@ -112,4 +139,13 @@
|
||||
.badge-lighter-blue {
|
||||
@apply bg-blue-300 text-white;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
|
||||
@@ -2,18 +2,28 @@ import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface User {
|
||||
accountId: string;
|
||||
id?: number;
|
||||
accountId?: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
displayName: string;
|
||||
emailAddress?: string;
|
||||
avatarUrl?: string;
|
||||
roles?: string[];
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
interface AuthConfig {
|
||||
// Application branding
|
||||
appName: string;
|
||||
appTagline: string;
|
||||
appCopyright: string;
|
||||
// The configured authentication method
|
||||
authMethod: 'pat' | 'oauth' | 'none';
|
||||
authMethod: 'pat' | 'oauth' | 'local' | 'none';
|
||||
// Legacy fields (for backward compatibility)
|
||||
oauthEnabled: boolean;
|
||||
serviceAccountEnabled: boolean;
|
||||
localAuthEnabled: boolean;
|
||||
jiraHost: string;
|
||||
}
|
||||
|
||||
@@ -21,26 +31,31 @@ interface AuthState {
|
||||
// State
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
authMethod: 'oauth' | 'service-account' | null;
|
||||
authMethod: 'oauth' | 'local' | 'service-account' | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
config: AuthConfig | null;
|
||||
isInitialized: boolean; // Track if initialization has completed
|
||||
|
||||
// Actions
|
||||
setUser: (user: User | null, method: 'oauth' | 'service-account' | null) => void;
|
||||
setUser: (user: User | null, method: 'oauth' | 'local' | 'service-account' | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setConfig: (config: AuthConfig) => void;
|
||||
setInitialized: (initialized: boolean) => void;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
fetchConfig: () => Promise<void>;
|
||||
localLogin: (email: string, password: string) => Promise<void>;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
hasRole: (role: string) => boolean;
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
@@ -48,6 +63,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: true,
|
||||
error: null,
|
||||
config: null,
|
||||
isInitialized: false,
|
||||
|
||||
// Actions
|
||||
setUser: (user, method) => set({
|
||||
@@ -63,6 +79,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
setConfig: (config) => set({ config }),
|
||||
|
||||
setInitialized: (initialized) => set({ isInitialized: initialized }),
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/auth/logout`, {
|
||||
@@ -81,19 +99,49 @@ export const useAuthStore = create<AuthState>()(
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
// Use a simple flag to prevent concurrent calls
|
||||
const currentState = get();
|
||||
if (currentState.isLoading) {
|
||||
// Wait for the existing call to complete (max 1 second)
|
||||
let waitCount = 0;
|
||||
while (get().isLoading && waitCount < 10) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
waitCount++;
|
||||
}
|
||||
const stateAfterWait = get();
|
||||
// If previous call completed and we have auth state, we're done
|
||||
if (!stateAfterWait.isLoading && (stateAfterWait.isAuthenticated || stateAfterWait.user)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/auth/me`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Auth check failed');
|
||||
// Handle rate limiting (429) gracefully
|
||||
if (response.status === 429) {
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
authMethod: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(`Auth check failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.authenticated) {
|
||||
if (data.authenticated && data.user) {
|
||||
set({
|
||||
user: data.user,
|
||||
isAuthenticated: true,
|
||||
@@ -110,7 +158,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check error:', error);
|
||||
console.error('[checkAuth] Auth check error:', error);
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
@@ -122,18 +170,98 @@ export const useAuthStore = create<AuthState>()(
|
||||
},
|
||||
|
||||
fetchConfig: async () => {
|
||||
// Check if config is already loaded to prevent duplicate calls
|
||||
const currentState = get();
|
||||
if (currentState.config) {
|
||||
return; // Config already loaded, skip API call
|
||||
}
|
||||
|
||||
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: '',
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/auth/config`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const config = await response.json();
|
||||
set({ config });
|
||||
const configData = await response.json();
|
||||
set({ config: configData });
|
||||
} else {
|
||||
// Any non-OK response - set default config
|
||||
set({ config: defaultConfig });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch auth config:', error);
|
||||
console.error('[fetchConfig] Failed to fetch auth config:', error);
|
||||
// Set default config to allow app to proceed
|
||||
set({ config: defaultConfig });
|
||||
}
|
||||
|
||||
// Final verification - ensure config is set
|
||||
const finalState = get();
|
||||
if (!finalState.config) {
|
||||
set({ config: defaultConfig });
|
||||
}
|
||||
},
|
||||
|
||||
localLogin: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Login failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
set({
|
||||
user: data.user,
|
||||
isAuthenticated: true,
|
||||
authMethod: 'local',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Local login error:', error);
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Login failed',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
hasPermission: (permission: string) => {
|
||||
const { user } = get();
|
||||
if (!user || !user.permissions) {
|
||||
return false;
|
||||
}
|
||||
return user.permissions.includes(permission);
|
||||
},
|
||||
|
||||
hasRole: (role: string) => {
|
||||
const { user } = get();
|
||||
if (!user || !user.roles) {
|
||||
return false;
|
||||
}
|
||||
return user.roles.includes(role);
|
||||
},
|
||||
}),
|
||||
{
|
||||
@@ -150,4 +278,3 @@ export const useAuthStore = create<AuthState>()(
|
||||
export function getLoginUrl(): string {
|
||||
return `${API_BASE}/api/auth/login`;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,11 @@ export interface ApplicationDetails {
|
||||
applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM
|
||||
technischeArchitectuur?: string | null; // URL to Technical Architecture document (Attribute ID 572)
|
||||
dataCompletenessPercentage?: number; // Data completeness percentage (0-100)
|
||||
reference?: string | null; // Reference field (Enterprise Architect GUID)
|
||||
confluenceSpace?: string | null; // Confluence Space URL
|
||||
supplierTechnical?: ReferenceValue | null; // Supplier Technical
|
||||
supplierImplementation?: ReferenceValue | null; // Supplier Implementation
|
||||
supplierConsultancy?: ReferenceValue | null; // Supplier Consultancy
|
||||
_jiraUpdatedAt?: string | null; // Internal field for conflict detection (not exposed in API)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user