import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { toast } from 'react-toastify';
const API = process.env.REACT_APP_API_URL || '/api';
const authFetch = (url, opts = {}) => {
const token = localStorage.getItem('token');
return fetch(url, { ...opts, headers: { ...(opts.headers || {}), Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } });
};
/* ── Helpers ─────────────────────────────────────────────────────── */
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }) : '—';
const fmtTime = (d) => {
if (!d) return '—';
const now = Date.now(), dt = new Date(d).getTime(), diff = Math.floor((now - dt) / 60000);
if (diff < 1) return 'Gerade eben';
if (diff < 60) return `vor ${diff} Min.`;
const h = Math.floor(diff / 60);
if (h < 24) return `vor ${h} Std.`;
const days = Math.floor(h / 24);
if (days < 30) return `vor ${days} T.`;
return new Date(d).toLocaleDateString('de-DE');
};
const fmtEuro = (n) => n ? new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n) : '—';
const daysBetween = (from, to = new Date()) => Math.floor((new Date(to) - new Date(from)) / 86400000);
const ROLE_COLORS = {
super_admin: { bg: 'rgba(239,68,68,0.12)', color: '#fca5a5', border: 'rgba(239,68,68,0.25)', label: 'Super Admin' },
admin: { bg: 'rgba(239,68,68,0.12)', color: '#fca5a5', border: 'rgba(239,68,68,0.25)', label: 'Admin' },
support: { bg: 'rgba(52,211,153,0.1)', color: '#6ee7b7', border: 'rgba(52,211,153,0.25)', label: 'Support' },
bearbeiter: { bg: 'rgba(52,211,153,0.1)', color: '#6ee7b7', border: 'rgba(52,211,153,0.25)', label: 'Bearbeiter' },
hr_personal: { bg: 'rgba(96,165,250,0.1)', color: '#93c5fd', border: 'rgba(96,165,250,0.25)', label: 'HR / Personal' },
buchhaltung: { bg: 'rgba(96,165,250,0.1)', color: '#93c5fd', border: 'rgba(96,165,250,0.25)', label: 'Buchhaltung' },
produktion: { bg: 'rgba(245,158,11,0.1)', color: '#fcd34d', border: 'rgba(245,158,11,0.25)', label: 'Produktion' },
benutzer: { bg: 'rgba(148,163,184,0.1)', color: '#94a3b8', border: 'rgba(148,163,184,0.2)', label: 'Benutzer' },
};
const ROLE_AV_COLORS = {
super_admin: '#b91c1c', admin: '#dc2626',
support: '#059669', bearbeiter: '#10b981',
hr_personal: '#2563eb', buchhaltung: '#3b82f6',
produktion: '#d97706', benutzer: '#475569',
};
const IS_ADMIN_ROLE = (r) => ['super_admin', 'admin'].includes(r);
const ROLE_FILTER_MAP = {
admin: ['super_admin', 'admin'],
staff: ['support', 'bearbeiter'],
hr: ['hr_personal', 'buchhaltung'],
production: ['produktion'],
user: ['benutzer'],
};
const LICENSE_CATEGORIES = {
'SPE:ENTERPRISEPREMIUM': 'Produktivität', 'SPE:ENTERPRISEPACK': 'Produktivität',
'OFFICESUBSCRIPTION': 'Produktivität', 'MCOEV': 'Produktivität',
'FLOW_FREE': 'Produktivität', 'POWERBI_PRO': 'Analytics',
'POWER_BI_STANDARD': 'Analytics', 'INTUNE_A': 'Security',
'AAD_PREMIUM': 'Security', 'AAD_PREMIUM_P2': 'Security',
'EMS': 'Security', 'EMSPREMIUM': 'Security',
'AX7': 'Entwicklung', 'VISIOCLIENT': 'Produktivität',
'PROJECTCLIENT': 'Produktivität',
};
const getLicCategory = (skuPartNumber) => {
if (!skuPartNumber) return 'Sonstiges';
for (const [k, v] of Object.entries(LICENSE_CATEGORIES)) {
if (skuPartNumber.toUpperCase().includes(k.split(':').pop())) return v;
}
return 'Sonstiges';
};
/* ── SVG Icons ───────────────────────────────────────────────────── */
const Ico = ({ d, children, size = 16, ...p }) => (
);
const CheckIcon = () => ;
const UserIcon = () => ;
const BriefIcon = () => ;
const PhoneIcon = () => ;
const MapIcon = () => ;
const UsersIcon = () => ;
const CalIcon = () => ;
const ShieldIcon = () => ;
const KeyIcon = () => ;
const LockIcon = () => ;
const MonitorIcon = () => ;
const ClockIcon = () => ;
const SearchIcon = () => ;
const EditIcon = () => ;
const MoreIcon = () => ;
const DownloadIcon = () => ;
const PlusIcon = () => ;
const CopyIcon = () => ;
const LogoutIcon = () => ;
const MailIcon = () => ;
const LaptopIcon = () => ;
const RefreshIcon = () => ;
const CloudIcon = () => ;
const ServerIcon = () => ;
const XIcon = () => ;
const ChevronIcon = ({ open }) => ;
const BarIcon = () => ;
/* ── Avatar ──────────────────────────────────────────────────────── */
const Avatar = ({ user, size = 36, radius = 10, fontSize = 13 }) => {
const initials = ((user.first_name?.[0] || '') + (user.last_name?.[0] || '')).toUpperCase() || (user.username?.[0] || '?').toUpperCase();
const bg = ROLE_AV_COLORS[user.role_name] || '#475569';
if (user.avatar_url) {
return (
{ e.target.style.display = 'none'; e.target.nextSibling && (e.target.nextSibling.style.display = 'flex'); }}
/>
);
}
return (
{initials}
);
};
/* ── Presence dot ────────────────────────────────────────────────── */
const presenceFromLastSeen = (lastSeen) => {
if (!lastSeen) return 'offline';
const diff = (Date.now() - new Date(lastSeen).getTime()) / 60000;
if (diff < 5) return 'online';
if (diff < 60) return 'away';
return 'offline';
};
/* ══════════════════════════════════════════════════════════════════
TAB: Übersicht
══════════════════════════════════════════════════════════════════ */
const OverviewTab = ({ user, full, auditLogs }) => {
const rc = ROLE_COLORS[user.role_name] || ROLE_COLORS.benutzer;
const mfaMethod = user.mfa_methods?.join(' + ') || '—';
const riskLevel = user.risk_level || 'low';
const riskLabel = { low: 'Niedrig', medium: 'Mittel', high: 'Hoch' }[riskLevel] || 'Unbekannt';
const pwdAgeDays = user.updated_at ? daysBetween(user.updated_at) : null;
const pwdOk = !pwdAgeDays || pwdAgeDays < 90;
const activityIcons = { login: 'ad-login', asset: 'ad-asset', password: 'ad-pwd', fido: 'ad-key', group: 'ad-perm' };
const mapLogToActivity = (log) => {
const a = log.action?.toLowerCase() || '';
let type = 'ad-perm';
if (a.includes('login') || a.includes('anmeld')) type = 'ad-login';
else if (a.includes('asset')) type = 'ad-asset';
else if (a.includes('password') || a.includes('passwort')) type = 'ad-pwd';
else if (a.includes('fido') || a.includes('key')) type = 'ad-key';
return { ...log, dotClass: type };
};
const activities = (auditLogs || []).slice(0, 5).map(mapLogToActivity);
const managerName = full?.manager_first_name
? `${full.manager_first_name} ${full.manager_last_name}`
: user.manager_username || '—';
const managerInit = full?.manager_first_name
? (full.manager_first_name[0] + (full.manager_last_name?.[0] || '')).toUpperCase()
: '';
return (
{/* Stat Row */}
{fmtTime(user.last_login)}
{user.is_active ? 'Aktiv' : 'Inaktiv'}
Konto Status
{full?.asset_count ?? '—'}
Assets
{user.license_count ?? '—'}
Lizenzen
{full?.fido_count > 0 ? 'gepaart' : '—'}
{full?.fido_count ?? '—'}
FIDO-Keys
{/* Grid 2 */}
{/* Persönliche Informationen */}
Persönliche Informationen
Position
{user.position || —}
Abteilung
{user.department || —}
{user.phone || '—'}
{user.phone && (
)}
Standort
{user.location || —}
Vorgesetzter
{managerInit &&
{managerInit}
}
{managerName}
Eintritt
{fmtDate(user.joined_date || full?.onboarding_protocol?.start_date)}
{/* Sicherheit & MFA */}
Sicherheit & MFA
Risiko: {riskLabel}
Mehrfaktor-Authentifizierung
{mfaMethod}
Aktiv
Passwort
{pwdAgeDays !== null ? `Letzte Änderung vor ${pwdAgeDays} Tagen` : 'Unbekannt'}
{pwdOk ? 'OK' : 'Veraltet'}
Conditional Access
{user.ca_policy_count ?? '—'} Richtlinien zugewiesen · Compliant
OK
Aktive Sessions
Letzte Anmeldung {fmtTime(user.last_login)}
{user.is_active ? 'Aktiv' : 'Keine'}
{/* Grid 3 (2 cols) */}
{/* Letzte Aktivitäten */}
Letzte Aktivitäten
Audit-Log
{activities.length === 0 ? (
Keine Aktivitäten vorhanden
) : (
{activities.map((log, i) => (
{log.dotClass === 'ad-login' && }
{log.dotClass === 'ad-asset' && }
{log.dotClass === 'ad-pwd' && }
{log.dotClass === 'ad-key' && }
{log.dotClass === 'ad-perm' && }
{log.action}
{log.details || log.entity_type}
{fmtTime(log.created_at)}
))}
)}
{/* Gruppen & Rollen */}
IT Nexus Rolle
{rc.label}
{user.entra_groups?.securityGroups?.length > 0 && (
Sicherheitsgruppen
{user.entra_groups.securityGroups.slice(0, 5).map((g, i) => (
{g.displayName}
))}
)}
{user.entra_groups?.distributionGroups?.length > 0 && (
Verteiler
{user.entra_groups.distributionGroups.slice(0, 3).map((g, i) => (
{g.mail || g.displayName}
))}
)}
App-Berechtigungen
IT Nexus Portal
{user.role_name}
{user.azure_id &&
}
);
};
/* ══════════════════════════════════════════════════════════════════
TAB: Assets
══════════════════════════════════════════════════════════════════ */
const AssetsTab = ({ user, full }) => {
const [assets, setAssets] = useState([]);
const [loading, setLoading] = useState(true);
const [agents, setAgents] = useState({});
useEffect(() => {
setLoading(true);
Promise.all([
authFetch(`${API}/assets`).then(r => r.json()),
authFetch(`${API}/monitoring/agents`).then(r => r.json()).catch(() => ({ data: [] })),
]).then(([aRes, mRes]) => {
const all = aRes.data || [];
const userAssets = all.filter(a =>
a.assigned_to_user_id === user.id ||
(user.username && a.assigned_to_username === user.username)
);
setAssets(userAssets);
const agentMap = {};
for (const ag of (mRes.data || [])) agentMap[ag.hostname?.toLowerCase()] = ag;
setAgents(agentMap);
}).finally(() => setLoading(false));
}, [user.id]);
const totalValue = assets.reduce((s, a) => s + (a.purchase_price || 0), 0);
const agentForAsset = (asset) => agents[asset.name?.toLowerCase()] || agents[asset.hostname?.toLowerCase()];
const onlineCount = assets.filter(a => {
const ag = agentForAsset(a);
return ag && presenceFromLastSeen(ag.last_seen) === 'online';
}).length;
const assetIcon = (type) => {
const t = (type || '').toLowerCase();
if (t.includes('notebook') || t.includes('laptop')) return ;
if (t.includes('monitor')) return ;
if (t.includes('phone') || t.includes('smartphone')) return ;
return ;
};
const warrantyPct = (asset) => {
if (!asset.warranty_expiry_date || !asset.purchase_date) return null;
const total = daysBetween(asset.purchase_date, asset.warranty_expiry_date);
const elapsed = daysBetween(asset.purchase_date);
return Math.max(0, Math.min(100, Math.round((1 - elapsed / total) * 100)));
};
const oldestDate = full?.asset_oldest_date;
if (loading) return Lade Assets…
;
return (
{assets.length}
Geräte zugewiesen
{fmtEuro(totalValue)}
Buchwert gesamt
{onlineCount}/{assets.length}
Aktuell online
{oldestDate ? `${daysBetween(oldestDate)} T.` : '—'}
Ältestes Gerät
{assets.length === 0 ? (
Keine Assets zugewiesen
) : (
<>
Zugewiesene Hardware
{assets.map(asset => {
const ag = agentForAsset(asset);
const isOnline = ag && presenceFromLastSeen(ag.last_seen) === 'online';
const cpuPct = ag?.cpu_usage || 0;
const ramPct = ag && ag.ram_total_gb > 0 ? Math.round((ag.ram_used_gb / ag.ram_total_gb) * 100) : 0;
const wPct = warrantyPct(asset);
const barColor = (v) => v > 85 ? 'red' : v > 65 ? 'amber' : '';
return (
{assetIcon(asset.category || asset.type)}
{asset.name}
{asset.model || '—'}
{ag ? (
<>
>
) : null}
{wPct !== null &&
}
{asset.category || asset.type || 'Gerät'}
{asset.serial_number || asset.inventory_number || ''}
);
})}
>
)}
);
};
/* ══════════════════════════════════════════════════════════════════
TAB: Lizenzen
══════════════════════════════════════════════════════════════════ */
const SKU_DISPLAY = {
'SPE:ENTERPRISEPREMIUM': { name: 'Microsoft 365 E5', color: '#0078d4' },
'SPE:ENTERPRISEPACK': { name: 'Microsoft 365 E3', color: '#0078d4' },
'O365_BUSINESS_PREMIUM': { name: 'Microsoft 365 Business', color: '#0078d4' },
'INTUNE_A': { name: 'Microsoft Intune', color: '#4caf50' },
'AAD_PREMIUM': { name: 'Entra ID P1', color: '#00bcf2' },
'AAD_PREMIUM_P2': { name: 'Entra ID P2', color: '#00bcf2' },
'EMS': { name: 'EMS E3', color: '#7b5ea7' },
'EMSPREMIUM': { name: 'EMS E5', color: '#7b5ea7' },
'POWER_BI_PRO': { name: 'Power BI Pro', color: '#f2c811' },
'FLOW_FREE': { name: 'Power Automate', color: '#0066ff' },
};
const LicensesTab = ({ user }) => {
const [licenses, setLicenses] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user.azure_id) { setLoading(false); return; }
authFetch(`${API}/entra/users/${encodeURIComponent(user.azure_id)}/licenses`)
.then(r => r.json())
.then(d => setLicenses(d.data || []))
.catch(() => setLicenses([]))
.finally(() => setLoading(false));
}, [user.azure_id]);
const grouped = useMemo(() => {
const g = {};
for (const lic of licenses) {
const cat = getLicCategory(lic.skuPartNumber);
if (!g[cat]) g[cat] = [];
g[cat].push(lic);
}
return g;
}, [licenses]);
const getLicDisplay = (lic) => {
const key = Object.keys(SKU_DISPLAY).find(k => {
const part = k.includes(':') ? k.split(':')[1] : k;
return lic.skuPartNumber?.toUpperCase().includes(part);
});
return key ? SKU_DISPLAY[key] : { name: lic.skuPartNumber || 'Unbekannte Lizenz', color: '#64748b' };
};
const getInitials = (name) => name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
if (!user.azure_id) return Kein Azure-Konto verknüpft — Lizenzdaten nicht verfügbar
;
if (loading) return Lade Lizenzen…
;
return (
Aktive Lizenzen
{licenses.length}
Aus {licenses.length} zugewiesenen Plätzen
Kosten pro Monat
—
Preise in Entra konfigurierbar
Verlängerung fällig
—
Keine fälligen Verlängerungen
{licenses.length === 0 ? (
Keine Lizenzen zugewiesen
) : (
Object.entries(grouped).map(([cat, lics]) => (
{cat} {lics.length}
{lics.map((lic, i) => {
const display = getLicDisplay(lic);
return (
{getInitials(display.name)}
{display.name}
{lic.skuInfo?.capabilityStatus || 'Enabled'}
);
})}
))
)}
);
};
/* ══════════════════════════════════════════════════════════════════
TAB: Entra/AD
══════════════════════════════════════════════════════════════════ */
const EntraTab = ({ user }) => {
const [profile, setProfile] = useState(null);
const [groups, setGroups] = useState([]);
const [risk, setRisk] = useState(null);
const [caps, setCaps] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user.azure_id) { setLoading(false); return; }
Promise.all([
authFetch(`${API}/entra/users/${encodeURIComponent(user.azure_id)}/profile`).then(r => r.json()).catch(() => ({})),
authFetch(`${API}/entra/users/${encodeURIComponent(user.azure_id)}/groups`).then(r => r.json()).catch(() => ({})),
authFetch(`${API}/entra/users/${encodeURIComponent(user.azure_id)}/signin-risk`).then(r => r.json()).catch(() => ({})),
authFetch(`${API}/entra/conditional-access-policies`).then(r => r.json()).catch(() => ({})),
]).then(([pRes, gRes, rRes, capRes]) => {
setProfile(pRes.data || null);
setGroups(gRes.data || []);
setRisk(rRes.data || null);
setCaps((capRes.data || []).slice(0, 4));
}).finally(() => setLoading(false));
}, [user.azure_id]);
const jwtPreview = profile ? JSON.stringify({
aud: 'api://it-nexus',
oid: profile.id,
preferred_username: profile.userPrincipalName,
roles: [user.role_name],
groups: (groups || []).slice(0, 3).map(g => g.displayName),
amr: ['pwd', 'mfa'],
iat: Math.floor(Date.now() / 1000)
}, null, 0) : null;
const capGrantType = (policy) => {
const controls = policy.grantControls;
if (!controls) return 'REPORT';
if (policy.state === 'disabled') return 'DISABLED';
const ops = controls.builtInControls || [];
if (ops.includes('block')) return 'BLOCK';
return 'GRANT';
};
if (!user.azure_id) return Kein Azure-Konto verknüpft
;
if (loading) return Lade Entra-Daten…
;
return (
{/* Hybrid Identity */}
Hybrid Identity
Synchronisiert
Entra ID
{profile?.userPrincipalName?.split('@')[1] || 'cereda.onmicrosoft.com'}
Active Directory
CEREDA.local
Object ID{profile?.id || '—'}
UPN{profile?.userPrincipalName || user.email}
sAMAccountName{profile?.onPremisesSamAccountName || user.username}
Distinguished Name{profile?.onPremisesDistinguishedName || `CN=${user.first_name} ${user.last_name},OU=Users,DC=cereda,DC=local`}
User Type{profile?.userType || 'Member'}
Source of Authority{profile?.onPremisesSyncEnabled ? 'Hybrid · Windows Server AD' : 'Cloud Only'}
Account Status
{profile?.accountEnabled !== false ? 'Enabled · Not locked' : 'Disabled'}
{jwtPreview && (
{jwtPreview}
)}
{/* Rechte Spalte */}
{/* Conditional Access */}
Conditional Access
{caps.length} zugewiesen
{caps.length === 0 ? (
Keine Richtlinien geladen
) : (
{caps.map((cap, i) => {
const grant = capGrantType(cap);
return (
{grant === 'GRANT' && }
{grant === 'BLOCK' && }
{grant === 'REPORT' && }
{cap.displayName}
Status: {cap.state}
{grant}
);
})}
)}
{/* Aktive Sessions */}
Letzte Anmeldung
{fmtTime(user.last_login)}
Sign-in Risk
{risk?.riskLevel ? risk.riskLevel.charAt(0).toUpperCase() + risk.riskLevel.slice(1) : 'Niedrig'}
User Risk
{risk?.riskDetail || 'Niedrig'}
Konto-Status
{user.is_active ? 'Aktiv' : 'Gesperrt'}
);
};
/* ══════════════════════════════════════════════════════════════════
TAB: FIDO-Keys
══════════════════════════════════════════════════════════════════ */
const FidoTab = ({ user }) => {
const [keys, setKeys] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
authFetch(`${API}/fido-keys?user_id=${user.id}`)
.then(r => r.json())
.then(d => setKeys(d.data || []))
.catch(() => setKeys([]))
.finally(() => setLoading(false));
}, [user.id]);
if (loading) return Lade FIDO-Keys…
;
const primaryKey = keys.find(k => k.key_type === 'primary') || keys[0];
const otherKeys = keys.filter(k => k !== primaryKey);
const hasEnoughKeys = keys.length >= 2;
return (
{keys.map((key, i) => (
{i === 0 ? 'PRIMÄR' : 'BACKUP'}
{key.name}
{key.manufacturer || 'Yubico'} · {key.connection_type || 'USB'} · SN {key.serial_number}
{fmtTime(key.last_used_at)}
Letzte Nutzung
))}
Weiteren Schlüssel registrieren
YubiKey, Titan, Feitian — WebAuthn
Authentifizierungs-Aktivität
Letzte 7 Tage
Erfolgreiche Anmeldungen— via FIDO2
Touch-Bestätigungen— · User Presence
PIN-Eingaben— · User Verification
Fehlversuche0 blockiert
Verwendete Browser—
{hasEnoughKeys ? : }
Mindestens 2 Schlüssel registriert
Primär & Backup vorhanden
{hasEnoughKeys ? 'Erfüllt' : 'Ausstehend'}
Schlüssel-Attestation aktiv
Nur zertifizierte Authenticators
Erfüllt
Passwortlose Anmeldung
Optional — Passwort noch aktiv
Empfohlen
);
};
/* ══════════════════════════════════════════════════════════════════
TAB: Lifecycle
══════════════════════════════════════════════════════════════════ */
const LifecycleTab = ({ user, full }) => {
const ob = full?.onboarding_protocol;
const offb = full?.offboarding_protocol;
const stage = full?.lifecycle_stage || 3;
const joinDate = user.joined_date || ob?.start_date;
const stages = [
{ n: 1, label: 'Pre-Onboarding', date: joinDate ? fmtDate(new Date(joinDate).getTime() - 7 * 86400000) : '— 7 Tage vor Start' },
{ n: 2, label: 'Onboarding', date: ob?.start_date ? fmtDate(ob.start_date) : '—' },
{ n: 3, label: 'Aktiv', date: ob?.start_date ? `Seit ${fmtDate(ob.start_date)}` : (joinDate ? `Seit ${fmtDate(joinDate)}` : '—') },
{ n: 4, label: 'Rollenwechsel', date: '— optional' },
{ n: 5, label: 'Offboarding', date: offb?.exit_date ? fmtDate(offb.exit_date) : '— offen' },
];
const daysUntil = (months) => months * 30 + (joinDate ? daysBetween(joinDate) % (months * 30) : 0);
const milestones = [
{ label: 'Vertrag unterzeichnet', sub: `IT & Infrastruktur · ${user.position || 'Mitarbeiter'}`, date: fmtDate(joinDate), type: joinDate ? 'done' : 'upcoming' },
{ label: 'Hardware ausgehändigt', sub: `${full?.asset_count || 0} Geräte · Setup durch IT`, date: fmtDate(joinDate), type: full?.asset_count > 0 ? 'done' : 'upcoming' },
{ label: 'IT-Schulung & Security Awareness', sub: 'ISO 27001 · Phishing-Simulation', date: joinDate ? `vor ${Math.min(90, daysBetween(joinDate))} T.` : '—', type: joinDate && daysBetween(joinDate) > 90 ? 'done' : joinDate ? 'current' : 'upcoming' },
{ label: 'Probezeit abgeschlossen', sub: '90 Tage Probezeit', date: joinDate ? (daysBetween(joinDate) > 90 ? `vor ${daysBetween(joinDate) - 90} T.` : `in ${90 - daysBetween(joinDate)} T.`) : '—', type: joinDate && daysBetween(joinDate) > 90 ? 'done' : 'upcoming' },
{ label: 'Letzte Recertification der Berechtigungen', sub: 'Quartalsweise', date: 'vor 14 T.', type: 'current' },
{ label: 'Nächstes Mitarbeitergespräch', sub: 'Jährlich geplant', date: 'in 42 T.', type: 'upcoming', future: true },
{ label: 'Hardware-Refresh fällig', sub: 'Notebook-Tausch nach 36 Monaten', date: joinDate && daysBetween(joinDate) < 36*30 ? `in ${36*30 - daysBetween(joinDate)} T.` : 'in 184 T.', type: 'upcoming', future: true },
{ label: 'Security-Schulung Wiederholung', sub: 'Jährliche Pflichtschulung', date: 'in 275 T.', type: 'upcoming', future: true },
];
const navigate = useNavigate();
return (
{/* Stage Bar */}
{stages.map(s => (
{String(s.n).padStart(2, '0')} · {s.n < stage ? 'ABGESCHLOSSEN' : s.n === stage ? 'LAUFEND' : ''}
{s.label}
{s.date}
))}
{/* Meilensteine */}
Meilensteine & Termine
Alle anzeigen
{milestones.map((ms, i) => (
))}
{/* Rechte Spalte */}
{/* Vertrag & Rolle */}
Eintritt
{fmtDate(joinDate)}
{user.employment_type || 'Unbefristet'}
Beschäftigung
{user.employment_type || 'Vollzeit'} · {user.work_hours || 40}h
TVöD-äquiv.
Standort
{user.location || '—'}
Hybrid 3:2
Kostenstelle
{user.cost_center || '—'}
{user.department || ''}
{/* Offboarding Bereitschaft */}
Offboarding-Bereitschaft
{offb ? 'Aktiv' : 'Nicht aktiv'}
Geplantes Austrittsdatum{offb?.exit_date ? fmtDate(offb.exit_date) : '— Keines hinterlegt'}
Datenübergabe-PlanAutomatisch · OneDrive, Mail-Archiv
Hardware-Rückgabe{full?.asset_count || 0} Geräte registriert
Lizenzfreigabe{user.license_count || '—'} Lizenzen
Wissenstransfer— Noch nicht initiiert
);
};
/* ══════════════════════════════════════════════════════════════════
Edit Modal
══════════════════════════════════════════════════════════════════ */
const EditModal = ({ user, allUsers, onClose, onSaved }) => {
const [form, setForm] = useState({
first_name: user.first_name || '',
last_name: user.last_name || '',
email: user.email || '',
department: user.department || '',
position: user.position || '',
phone: user.phone || '',
location: user.location || '',
manager_id: user.manager_id || '',
cost_center: user.cost_center || '',
employment_type: user.employment_type || 'Vollzeit',
work_hours: user.work_hours || 40,
joined_date: user.joined_date ? user.joined_date.split('T')[0] : '',
});
const [saving, setSaving] = useState(false);
const save = async () => {
setSaving(true);
try {
const r = await authFetch(`${API}/users/${user.id}`, { method: 'PUT', body: JSON.stringify(form) });
if (!r.ok) throw new Error('Fehler beim Speichern');
toast.success('Gespeichert');
onSaved({ ...user, ...form });
onClose();
} catch { toast.error('Fehler beim Speichern'); }
finally { setSaving(false); }
};
return (
e.target === e.currentTarget && onClose()}>
{[
['first_name','Vorname'],['last_name','Nachname'],['email','E-Mail'],
['department','Abteilung'],['position','Position'],['phone','Telefon'],
['location','Standort'],['cost_center','Kostenstelle'],
].map(([k, label]) => (
setForm(p => ({...p, [k]: e.target.value}))} />
))}
setForm(p => ({...p, work_hours: parseInt(e.target.value)}))} />
setForm(p => ({...p, joined_date: e.target.value}))} />
);
};
/* ══════════════════════════════════════════════════════════════════
More Actions Dropdown
══════════════════════════════════════════════════════════════════ */
const MoreDropdown = ({ user, onDeactivate, onDelete }) => {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, []);
return (
{open && (
)}
);
};
/* ══════════════════════════════════════════════════════════════════
MAIN PAGE
══════════════════════════════════════════════════════════════════ */
const UserManagementPage = () => {
const { isSuperAdmin, isAdmin } = useAuth();
const navigate = useNavigate();
const [users, setUsers] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [full, setFull] = useState(null);
const [auditLogs, setAuditLogs] = useState([]);
const [tab, setTab] = useState('overview');
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('all');
const [loading, setLoading] = useState(true);
const [showEdit, setShowEdit] = useState(false);
const [agents, setAgents] = useState([]);
// Load users + agents
useEffect(() => {
Promise.all([
authFetch(`${API}/users`).then(r => r.json()),
authFetch(`${API}/monitoring/agents`).then(r => r.json()).catch(() => ({ data: [] })),
]).then(([uRes, aRes]) => {
setUsers(uRes.data || []);
setAgents(aRes.data || []);
if ((uRes.data || []).length > 0) setSelectedId(uRes.data[0].id);
}).finally(() => setLoading(false));
}, []);
const selectedUser = useMemo(() => users.find(u => u.id === selectedId), [users, selectedId]);
// Load full profile when user changes
useEffect(() => {
if (!selectedId) return;
setFull(null);
setAuditLogs([]);
Promise.all([
authFetch(`${API}/users/${selectedId}/full`).then(r => r.json()).catch(() => ({})),
authFetch(`${API}/audit-logs/user/${selectedId}?limit=10`).then(r => r.json()).catch(() => ({})),
]).then(([fRes, lRes]) => {
setFull(fRes.data || null);
setAuditLogs(lRes.data || []);
});
}, [selectedId]);
// Listen for edit event from OverviewTab
useEffect(() => {
const h = () => setShowEdit(true);
document.addEventListener('bv-edit', h);
return () => document.removeEventListener('bv-edit', h);
}, []);
// Presence from agents
const presenceForUser = useCallback((u) => {
const ag = agents.find(a => a.username?.toLowerCase() === u.username?.toLowerCase() || a.hostname?.toLowerCase()?.includes(u.username?.toLowerCase()));
return ag ? presenceFromLastSeen(ag.last_seen) : 'offline';
}, [agents]);
// Filter + sort users
const filtered = useMemo(() => {
let list = [...users];
if (roleFilter !== 'all') {
const roles = ROLE_FILTER_MAP[roleFilter] || [];
list = list.filter(u => roles.includes(u.role_name));
}
if (search.trim()) {
const t = search.trim().toLowerCase();
list = list.filter(u => `${u.first_name} ${u.last_name} ${u.email} ${u.username}`.toLowerCase().includes(t));
}
return list;
}, [users, roleFilter, search]);
const favorites = filtered.filter(u => IS_ADMIN_ROLE(u.role_name));
const others = filtered.filter(u => !IS_ADMIN_ROLE(u.role_name));
const handleSelect = (id) => { setSelectedId(id); setTab('overview'); };
const handleDeactivate = async () => {
if (!selectedUser) return;
const r = await authFetch(`${API}/users/${selectedUser.id}/activate`, {
method: 'PUT', body: JSON.stringify({ is_active: !selectedUser.is_active })
});
if (r.ok) {
setUsers(p => p.map(u => u.id === selectedUser.id ? { ...u, is_active: !u.is_active } : u));
toast.success(selectedUser.is_active ? 'Benutzer deaktiviert' : 'Benutzer aktiviert');
}
};
const handleDelete = async () => {
if (!selectedUser || !window.confirm(`Benutzer "${selectedUser.username}" wirklich löschen?`)) return;
const r = await authFetch(`${API}/users/${selectedUser.id}`, { method: 'DELETE' });
if (r.ok) {
setUsers(p => p.filter(u => u.id !== selectedUser.id));
setSelectedId(users.find(u => u.id !== selectedUser.id)?.id || null);
toast.success('Benutzer gelöscht');
}
};
const handleResetEntra = async () => {
if (!selectedUser) return;
const r = await authFetch(`${API}/users/${selectedUser.id}/reset-entra`, { method: 'POST' });
const d = await r.json();
if (r.ok) toast.success(d.message || 'Entra-Sessions invalidiert');
else toast.error(d.message || 'Fehler beim Reset');
};
const handleExportCsv = () => {
const token = localStorage.getItem('token');
window.open(`${API}/users/export/csv?token=${token}`, '_blank');
};
const rc = selectedUser ? (ROLE_COLORS[selectedUser.role_name] || ROLE_COLORS.benutzer) : null;
const presence = selectedUser ? presenceForUser(selectedUser) : 'offline';
const initials = selectedUser ? ((selectedUser.first_name?.[0] || '') + (selectedUser.last_name?.[0] || '')).toUpperCase() || selectedUser.username?.[0]?.toUpperCase() || '?' : '?';
const TABS = [
{ id: 'overview', label: 'Übersicht' },
{ id: 'assets', label: 'Assets', count: full?.asset_count },
{ id: 'licenses', label: 'Lizenzen', count: selectedUser?.license_count },
{ id: 'entra', label: 'Entra/AD' },
{ id: 'fido', label: 'FIDO-Keys', count: full?.fido_count },
{ id: 'lifecycle', label: 'Lifecycle' },
];
const UserRow = ({ u }) => {
const p = presenceForUser(u);
const rcolor = ROLE_COLORS[u.role_name] || ROLE_COLORS.benutzer;
const avatarBg = ROLE_AV_COLORS[u.role_name] || '#475569';
const init = ((u.first_name?.[0] || '') + (u.last_name?.[0] || '')).toUpperCase() || u.username?.[0]?.toUpperCase() || '?';
return (
handleSelect(u.id)}>
{u.avatar_url ? (

{ e.target.style.display='none'; }} />
) : (
{init}
)}
{u.first_name} {u.last_name}
{u.email}
{rcolor.label}
);
};
if (loading) return Lade Benutzerdaten…
;
return (
{/* ── User List ─────────────────────────────────────── */}
Benutzer
{filtered.length} von {users.length}
Verwalte Identitäten, Rollen & Zugriffe
setSearch(e.target.value)} placeholder="Name oder E-Mail suchen…" />
{[
['all','Alle'],['admin','Admin'],['staff','Support'],
['hr','HR/Buchhaltung'],['production','Produktion'],['user','Benutzer'],
].map(([k, l]) => (
))}
{favorites.length > 0 && <>
Favoriten
{favorites.map(u =>
)}
>}
{others.length > 0 && <>
Alle Benutzer
{others.map(u =>
)}
>}
{filtered.length === 0 &&
Keine Benutzer gefunden
}
{/* ── Detail Panel ──────────────────────────────────── */}
{!selectedUser ? (
Benutzer auswählen
) : (
{/* Header */}
{selectedUser.avatar_url ? (

{ e.target.style.display='none'; e.target.closest('.bv-dh-avatar').style.background = ROLE_AV_COLORS[selectedUser.role_name] || '#475569'; }} />
) : (
initials
)}
{selectedUser.first_name} {selectedUser.last_name}
@{selectedUser.username}
{selectedUser.email}
{rc.label}
{selectedUser.department && (
{selectedUser.department}
)}
{selectedUser.is_active ? 'Aktiv' : 'Inaktiv'}
{selectedUser.azure_id && (
)}
{isSuperAdmin() && (
)}
{/* Tabs */}
{TABS.map(t => (
))}
{/* Tab Content */}
{tab === 'overview' &&
}
{tab === 'assets' &&
}
{tab === 'licenses' &&
}
{tab === 'entra' &&
}
{tab === 'fido' &&
}
{tab === 'lifecycle' &&
}
)}
{/* Edit Modal */}
{showEdit && selectedUser && (
setShowEdit(false)}
onSaved={(updated) => setUsers(p => p.map(u => u.id === updated.id ? { ...u, ...updated } : u))}
/>
)}
);
};
export default UserManagementPage;