Files
IT-Nexus/frontend/src/pages/UserManagementPage.jsx

1313 lines
80 KiB
JavaScript

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 }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" width={size} height={size} {...p}>
{d ? <path d={d} /> : children}
</svg>
);
const CheckIcon = () => <Ico><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="10"/></Ico>;
const UserIcon = () => <Ico><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></Ico>;
const BriefIcon = () => <Ico d="M3 21h18M5 21V7l7-4 7 4v14"/>;
const PhoneIcon = () => <Ico d="M22 16.92V21a1 1 0 0 1-1.11 1 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 3.18 4.11 1 1 0 0 1 4.18 3h4.09a1 1 0 0 1 1 .75c.12.93.34 1.84.66 2.71a1 1 0 0 1-.21 1L8 9.09a16 16 0 0 0 6 6l1.62-1.62a1 1 0 0 1 1-.21c.87.32 1.78.54 2.71.66a1 1 0 0 1 .67 1z"/>;
const MapIcon = () => <Ico><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></Ico>;
const UsersIcon = () => <Ico><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></Ico>;
const CalIcon = () => <Ico><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></Ico>;
const ShieldIcon = () => <Ico d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>;
const KeyIcon = () => <Ico d="m15.5 7.5 3 3L22 7l-3-3M21 2l-9.6 9.6a5.5 5.5 0 1 1-2 2L19 4"/>;
const LockIcon = () => <Ico><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></Ico>;
const MonitorIcon = () => <Ico><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></Ico>;
const ClockIcon = () => <Ico><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></Ico>;
const SearchIcon = () => <Ico><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></Ico>;
const EditIcon = () => <Ico><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></Ico>;
const MoreIcon = () => <Ico><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></Ico>;
const DownloadIcon = () => <Ico><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></Ico>;
const PlusIcon = () => <Ico><path d="M12 5v14M5 12h14"/></Ico>;
const CopyIcon = () => <Ico><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></Ico>;
const LogoutIcon = () => <Ico><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></Ico>;
const MailIcon = () => <Ico><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><path d="m22 6-10 7L2 6"/></Ico>;
const LaptopIcon = () => <Ico><rect x="2" y="7" width="20" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></Ico>;
const RefreshIcon = () => <Ico><path d="M21 12a9 9 0 1 1-9-9c2.5 0 4.78 1 6.43 2.57L21 8"/><path d="M21 3v5h-5"/></Ico>;
const CloudIcon = () => <Ico><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/></Ico>;
const ServerIcon = () => <Ico><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><path d="M6 6h.01M6 18h.01"/></Ico>;
const XIcon = () => <Ico><path d="M18 6 6 18M6 6l12 12"/></Ico>;
const ChevronIcon = ({ open }) => <Ico size={14}><path d={open ? "m18 15-6-6-6 6" : "m6 9 6 6 6-6"}/></Ico>;
const BarIcon = () => <Ico d="M22 12h-4l-3 9L9 3l-3 9H2"/>;
/* ── 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 (
<img
src={user.avatar_url}
alt={initials}
style={{ width: size, height: size, borderRadius: radius, objectFit: 'cover', flexShrink: 0 }}
onError={e => { e.target.style.display = 'none'; e.target.nextSibling && (e.target.nextSibling.style.display = 'flex'); }}
/>
);
}
return (
<div style={{ width: size, height: size, borderRadius: radius, background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize, fontWeight: 700, color: '#fff', flexShrink: 0 }}>
{initials}
</div>
);
};
/* ── 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 (
<div className="bv-tab-content">
{/* Stat Row */}
<div className="bv-stat-row">
<div className="bv-stat-card">
<div className="bv-stat-head">
<div className="bv-stat-icon s-status"><CheckIcon /></div>
<span className="bv-stat-trend">{fmtTime(user.last_login)}</span>
</div>
<div className="bv-stat-value">{user.is_active ? 'Aktiv' : 'Inaktiv'}</div>
<div className="bv-stat-label">Konto Status</div>
</div>
<div className="bv-stat-card">
<div className="bv-stat-head">
<div className="bv-stat-icon s-assets"><LaptopIcon /></div>
<span className="bv-stat-trend">zugewiesen</span>
</div>
<div className="bv-stat-value">{full?.asset_count ?? '—'}</div>
<div className="bv-stat-label">Assets</div>
</div>
<div className="bv-stat-card">
<div className="bv-stat-head">
<div className="bv-stat-icon s-lic"><Ico><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></Ico></div>
<span className="bv-stat-trend">aktiv</span>
</div>
<div className="bv-stat-value">{user.license_count ?? '—'}</div>
<div className="bv-stat-label">Lizenzen</div>
</div>
<div className="bv-stat-card">
<div className="bv-stat-head">
<div className="bv-stat-icon s-fido"><KeyIcon /></div>
<span className="bv-stat-trend">{full?.fido_count > 0 ? 'gepaart' : '—'}</span>
</div>
<div className="bv-stat-value">{full?.fido_count ?? '—'}</div>
<div className="bv-stat-label">FIDO-Keys</div>
</div>
</div>
{/* Grid 2 */}
<div className="bv-grid-2">
{/* Persönliche Informationen */}
<div className="bv-card">
<div className="bv-card-head">
<div className="bv-card-title"><UserIcon /> Persönliche Informationen</div>
<button className="bv-card-link" onClick={() => document.dispatchEvent(new CustomEvent('bv-edit'))}>Bearbeiten</button>
</div>
<div className="bv-info-list">
<div className="bv-info-row"><div className="bv-info-k"><UserIcon />Position</div><div className="bv-info-v">{user.position || <span style={{color:'var(--text-muted)'}}></span>}</div></div>
<div className="bv-info-row"><div className="bv-info-k"><BriefIcon />Abteilung</div><div className="bv-info-v">{user.department || <span style={{color:'var(--text-muted)'}}></span>}</div></div>
<div className="bv-info-row">
<div className="bv-info-k"><PhoneIcon />Telefon</div>
<div className="bv-info-v">
<span style={{fontFamily:'monospace',fontSize:12}}>{user.phone || '—'}</span>
{user.phone && (
<button className="bv-copy-btn" onClick={() => { navigator.clipboard.writeText(user.phone); toast.success('Kopiert'); }}><CopyIcon /></button>
)}
</div>
</div>
<div className="bv-info-row"><div className="bv-info-k"><MapIcon />Standort</div><div className="bv-info-v">{user.location || <span style={{color:'var(--text-muted)'}}></span>}</div></div>
<div className="bv-info-row">
<div className="bv-info-k"><UsersIcon />Vorgesetzter</div>
<div className="bv-info-v">
{managerInit && <div className="bv-mini-av">{managerInit}</div>}
{managerName}
</div>
</div>
<div className="bv-info-row"><div className="bv-info-k"><CalIcon />Eintritt</div><div className="bv-info-v">{fmtDate(user.joined_date || full?.onboarding_protocol?.start_date)}</div></div>
</div>
</div>
{/* Sicherheit & MFA */}
<div className="bv-card">
<div className="bv-card-head">
<div className="bv-card-title"><ShieldIcon /> Sicherheit &amp; MFA</div>
<span className={`bv-risk-badge ${riskLevel}`}>Risiko: {riskLabel}</span>
</div>
<div className="bv-sec-list">
<div className="bv-sec-item">
<div className="bv-sec-icon si-key"><KeyIcon /></div>
<div className="bv-sec-body">
<div className="bv-sec-label">Mehrfaktor-Authentifizierung</div>
<div className="bv-sec-sub">{mfaMethod}</div>
</div>
<span className="bv-sec-status ok">Aktiv</span>
</div>
<div className="bv-sec-item">
<div className="bv-sec-icon si-pwd"><LockIcon /></div>
<div className="bv-sec-body">
<div className="bv-sec-label">Passwort</div>
<div className="bv-sec-sub">{pwdAgeDays !== null ? `Letzte Änderung vor ${pwdAgeDays} Tagen` : 'Unbekannt'}</div>
</div>
<span className={`bv-sec-status ${pwdOk ? 'ok' : 'warn'}`}>{pwdOk ? 'OK' : 'Veraltet'}</span>
</div>
<div className="bv-sec-item">
<div className="bv-sec-icon si-ok"><CheckIcon /></div>
<div className="bv-sec-body">
<div className="bv-sec-label">Conditional Access</div>
<div className="bv-sec-sub">{user.ca_policy_count ?? '—'} Richtlinien zugewiesen · Compliant</div>
</div>
<span className="bv-sec-status ok">OK</span>
</div>
<div className="bv-sec-item">
<div className="bv-sec-icon si-ok"><MonitorIcon /></div>
<div className="bv-sec-body">
<div className="bv-sec-label">Aktive Sessions</div>
<div className="bv-sec-sub">Letzte Anmeldung {fmtTime(user.last_login)}</div>
</div>
<span className="bv-sec-status ok">{user.is_active ? 'Aktiv' : 'Keine'}</span>
</div>
</div>
</div>
</div>
{/* Grid 3 (2 cols) */}
<div className="bv-grid-3">
{/* Letzte Aktivitäten */}
<div className="bv-card">
<div className="bv-card-head">
<div className="bv-card-title"><ClockIcon /> Letzte Aktivitäten</div>
<span className="bv-card-link" style={{cursor:'default',opacity:0.5}}>Audit-Log</span>
</div>
{activities.length === 0 ? (
<div style={{color:'var(--text-muted)',fontSize:13,textAlign:'center',padding:'20px 0'}}>Keine Aktivitäten vorhanden</div>
) : (
<div className="bv-activity-list">
{activities.map((log, i) => (
<div key={i} className="bv-activity">
<div className={`bv-act-dot ${log.dotClass}`}>
{log.dotClass === 'ad-login' && <LogoutIcon />}
{log.dotClass === 'ad-asset' && <LaptopIcon />}
{log.dotClass === 'ad-pwd' && <LockIcon />}
{log.dotClass === 'ad-key' && <KeyIcon />}
{log.dotClass === 'ad-perm' && <CheckIcon />}
</div>
<div className="bv-act-body">
<div className="bv-act-text"><b>{log.action}</b></div>
<div className="bv-act-meta">{log.details || log.entity_type}</div>
</div>
<div className="bv-act-time">{fmtTime(log.created_at)}</div>
</div>
))}
</div>
)}
</div>
{/* Gruppen & Rollen */}
<div className="bv-card">
<div className="bv-card-head">
<div className="bv-card-title"><UsersIcon /> Gruppen &amp; Rollen</div>
</div>
<div className="bv-group-section">
<h4>IT Nexus Rolle</h4>
<div className="bv-group-list">
<span className="bv-group-chip role"><span className="bv-gdot" style={{background:'#ef4444'}} />{rc.label}</span>
</div>
</div>
{user.entra_groups?.securityGroups?.length > 0 && (
<div className="bv-group-section">
<h4>Sicherheitsgruppen</h4>
<div className="bv-group-list">
{user.entra_groups.securityGroups.slice(0, 5).map((g, i) => (
<span key={i} className="bv-group-chip entra"><span className="bv-gdot" style={{background:'#60a5fa'}} />{g.displayName}</span>
))}
</div>
</div>
)}
{user.entra_groups?.distributionGroups?.length > 0 && (
<div className="bv-group-section">
<h4>Verteiler</h4>
<div className="bv-group-list">
{user.entra_groups.distributionGroups.slice(0, 3).map((g, i) => (
<span key={i} className="bv-group-chip distrib"><span className="bv-gdot" style={{background:'#f59e0b'}} />{g.mail || g.displayName}</span>
))}
</div>
</div>
)}
<div className="bv-group-section" style={{marginBottom:0}}>
<h4>App-Berechtigungen</h4>
<div className="bv-app-perm"><div className="bv-app-perm-name"><CheckIcon />IT Nexus Portal</div><span className="bv-app-perm-level">{user.role_name}</span></div>
{user.azure_id && <div className="bv-app-perm"><div className="bv-app-perm-name"><CheckIcon />Microsoft 365</div><span className="bv-app-perm-level">aktiv</span></div>}
</div>
</div>
</div>
</div>
);
};
/* ══════════════════════════════════════════════════════════════════
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 <LaptopIcon />;
if (t.includes('monitor')) return <MonitorIcon />;
if (t.includes('phone') || t.includes('smartphone')) return <PhoneIcon />;
return <LaptopIcon />;
};
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 <div style={{padding:40,textAlign:'center',color:'var(--text-muted)'}}>Lade Assets</div>;
return (
<div className="bv-tab-content">
<div className="bv-asset-stats">
<div className="bv-stat-card"><div className="bv-stat-head"><div className="bv-stat-icon s-assets"><LaptopIcon /></div></div><div className="bv-stat-value">{assets.length}</div><div className="bv-stat-label">Geräte zugewiesen</div></div>
<div className="bv-stat-card"><div className="bv-stat-head"><div className="bv-stat-icon s-lic"><Ico d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></div></div><div className="bv-stat-value" style={{fontSize:18}}>{fmtEuro(totalValue)}</div><div className="bv-stat-label">Buchwert gesamt</div></div>
<div className="bv-stat-card"><div className="bv-stat-head"><div className="bv-stat-icon s-status"><CheckIcon /></div></div><div className="bv-stat-value">{onlineCount}/{assets.length}</div><div className="bv-stat-label">Aktuell online</div></div>
<div className="bv-stat-card"><div className="bv-stat-head"><div className="bv-stat-icon s-fido"><CalIcon /></div></div><div className="bv-stat-value" style={{fontSize:16}}>{oldestDate ? `${daysBetween(oldestDate)} T.` : ''}</div><div className="bv-stat-label">Ältestes Gerät</div></div>
</div>
{assets.length === 0 ? (
<div style={{textAlign:'center',padding:'48px 0',color:'var(--text-muted)',fontSize:14}}>Keine Assets zugewiesen</div>
) : (
<>
<div className="bv-section-title">Zugewiesene Hardware</div>
<div className="bv-asset-grid">
{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 (
<div key={asset.id} className="bv-asset-card">
<div className="bv-asset-card-top">
<div className="bv-asset-icon">{assetIcon(asset.category || asset.type)}</div>
<div className={`bv-asset-online ${isOnline ? '' : 'offline'}`} />
</div>
<div className="bv-asset-name">{asset.name}</div>
<div className="bv-asset-model">{asset.model || '—'}</div>
{ag ? (
<>
<div className="bv-bar-row"><div className="bv-bar-label"><span>CPU-Last</span><span>{cpuPct}%</span></div><div className="bv-bar-track"><div className={`bv-bar-fill ${barColor(cpuPct)}`} style={{width:`${cpuPct}%`}} /></div></div>
<div className="bv-bar-row"><div className="bv-bar-label"><span>Speicher</span><span>{ramPct}%</span></div><div className="bv-bar-track"><div className={`bv-bar-fill ${barColor(ramPct)}`} style={{width:`${ramPct}%`}} /></div></div>
</>
) : null}
{wPct !== null && <div className="bv-bar-row"><div className="bv-bar-label"><span>Garantie</span><span>{wPct}%</span></div><div className="bv-bar-track"><div className={`bv-bar-fill ${wPct < 20 ? 'amber' : ''}`} style={{width:`${wPct}%`}} /></div></div>}
<div className="bv-asset-footer">
<span className="bv-asset-type-badge">{asset.category || asset.type || 'Gerät'}</span>
<span className="bv-asset-sn">{asset.serial_number || asset.inventory_number || ''}</span>
</div>
</div>
);
})}
</div>
</>
)}
</div>
);
};
/* ══════════════════════════════════════════════════════════════════
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 <div style={{textAlign:'center',padding:'48px 0',color:'var(--text-muted)',fontSize:14}}>Kein Azure-Konto verknüpft Lizenzdaten nicht verfügbar</div>;
if (loading) return <div style={{padding:40,textAlign:'center',color:'var(--text-muted)'}}>Lade Lizenzen</div>;
return (
<div className="bv-tab-content">
<div className="bv-lic-summary">
<div className="bv-lic-sum-item">
<div className="bv-lic-sum-label">Aktive Lizenzen</div>
<div className="bv-lic-sum-value">{licenses.length}</div>
<div className="bv-lic-sum-sub">Aus {licenses.length} zugewiesenen Plätzen</div>
</div>
<div className="bv-lic-sum-item">
<div className="bv-lic-sum-label">Kosten pro Monat</div>
<div className="bv-lic-sum-value"></div>
<div className="bv-lic-sum-sub">Preise in Entra konfigurierbar</div>
</div>
<div className="bv-lic-sum-item">
<div className="bv-lic-sum-label">Verlängerung fällig</div>
<div className="bv-lic-sum-value"></div>
<div className="bv-lic-sum-sub">Keine fälligen Verlängerungen</div>
</div>
</div>
{licenses.length === 0 ? (
<div style={{textAlign:'center',padding:'48px 0',color:'var(--text-muted)',fontSize:14}}>Keine Lizenzen zugewiesen</div>
) : (
Object.entries(grouped).map(([cat, lics]) => (
<div key={cat}>
<div className="bv-lic-section-label">{cat} <span>{lics.length}</span></div>
<div className="bv-lic-grid">
{lics.map((lic, i) => {
const display = getLicDisplay(lic);
return (
<div key={i} className="bv-lic-card">
<div className="bv-lic-av" style={{background: display.color}}>{getInitials(display.name)}</div>
<div style={{flex:1,minWidth:0}}>
<div className="bv-lic-name">{display.name}</div>
<div className="bv-lic-desc">{lic.skuInfo?.capabilityStatus || 'Enabled'}</div>
</div>
<div>
<div className="bv-lic-price"></div>
<div className="bv-lic-per">/Monat</div>
</div>
</div>
);
})}
</div>
</div>
))
)}
</div>
);
};
/* ══════════════════════════════════════════════════════════════════
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 <div style={{textAlign:'center',padding:'48px 0',color:'var(--text-muted)',fontSize:14}}>Kein Azure-Konto verknüpft</div>;
if (loading) return <div style={{padding:40,textAlign:'center',color:'var(--text-muted)'}}>Lade Entra-Daten</div>;
return (
<div className="bv-tab-content">
<div className="bv-entra-grid">
{/* Hybrid Identity */}
<div className="bv-hybrid-card">
<div className="bv-card-head">
<div className="bv-card-title"><ServerIcon /> Hybrid Identity</div>
<span className="bv-sync-badge">Synchronisiert</span>
</div>
<div className="bv-hybrid-diagram">
<div className="bv-hybrid-node">
<div className="bv-hybrid-node-icon entra"><CloudIcon /></div>
<div className="bv-hybrid-node-label">Entra ID</div>
<div className="bv-hybrid-node-sub">{profile?.userPrincipalName?.split('@')[1] || 'cereda.onmicrosoft.com'}</div>
</div>
<div className="bv-hybrid-connector">
<div className="bv-hybrid-sync-label">CONNECT SYNC</div>
<div className="bv-hybrid-line" />
</div>
<div className="bv-hybrid-node">
<div className="bv-hybrid-node-icon ad"><ServerIcon /></div>
<div className="bv-hybrid-node-label">Active Directory</div>
<div className="bv-hybrid-node-sub">CEREDA.local</div>
</div>
</div>
<div className="bv-entra-props">
<div className="bv-entra-prop"><span className="bv-entra-prop-k">Object ID</span><span className="bv-entra-prop-v">{profile?.id || ''}</span></div>
<div className="bv-entra-prop"><span className="bv-entra-prop-k">UPN</span><span className="bv-entra-prop-v">{profile?.userPrincipalName || user.email}</span></div>
<div className="bv-entra-prop"><span className="bv-entra-prop-k">sAMAccountName</span><span className="bv-entra-prop-v">{profile?.onPremisesSamAccountName || user.username}</span></div>
<div className="bv-entra-prop"><span className="bv-entra-prop-k">Distinguished Name</span><span className="bv-entra-prop-v" style={{fontSize:10}}>{profile?.onPremisesDistinguishedName || `CN=${user.first_name} ${user.last_name},OU=Users,DC=cereda,DC=local`}</span></div>
<div className="bv-entra-prop"><span className="bv-entra-prop-k">User Type</span><span className="bv-entra-prop-v">{profile?.userType || 'Member'}</span></div>
<div className="bv-entra-prop"><span className="bv-entra-prop-k">Source of Authority</span><span className="bv-entra-prop-v">{profile?.onPremisesSyncEnabled ? 'Hybrid · Windows Server AD' : 'Cloud Only'}</span></div>
<div className="bv-entra-prop"><span className="bv-entra-prop-k">Account Status</span>
<span className={`bv-entra-prop-v ${profile?.accountEnabled !== false ? 'badge-enabled' : 'badge-disabled'}`}>
{profile?.accountEnabled !== false ? 'Enabled · Not locked' : 'Disabled'}
</span>
</div>
</div>
{jwtPreview && (
<div className="bv-jwt-preview">{jwtPreview}</div>
)}
</div>
{/* Rechte Spalte */}
<div style={{display:'flex',flexDirection:'column',gap:14}}>
{/* Conditional Access */}
<div className="bv-card">
<div className="bv-card-head">
<div className="bv-card-title"><ShieldIcon /> Conditional Access</div>
<span style={{fontSize:12,color:'var(--text-muted)'}}>{caps.length} zugewiesen</span>
</div>
{caps.length === 0 ? (
<div style={{color:'var(--text-muted)',fontSize:13}}>Keine Richtlinien geladen</div>
) : (
<div className="bv-cap-list">
{caps.map((cap, i) => {
const grant = capGrantType(cap);
return (
<div key={i} className="bv-cap-item">
<div className={`bv-cap-icon ${grant.toLowerCase()}`}>
{grant === 'GRANT' && <CheckIcon />}
{grant === 'BLOCK' && <XIcon />}
{grant === 'REPORT' && <BarIcon />}
</div>
<div className="bv-cap-body">
<div className="bv-cap-name">{cap.displayName}</div>
<div className="bv-cap-cond">Status: {cap.state}</div>
</div>
<span className={`bv-cap-badge ${grant}`}>{grant}</span>
</div>
);
})}
</div>
)}
</div>
{/* Aktive Sessions */}
<div className="bv-sessions-card">
<div className="bv-card-head">
<div className="bv-card-title"><MonitorIcon /> Aktive Sessions</div>
</div>
<div className="bv-sessions-grid">
<div className="bv-sessions-item"><div className="bv-si-label">Letzte Anmeldung</div><div className="bv-si-value">{fmtTime(user.last_login)}</div></div>
<div className="bv-sessions-item"><div className="bv-si-label">Sign-in Risk</div><div className="bv-si-value">{risk?.riskLevel ? risk.riskLevel.charAt(0).toUpperCase() + risk.riskLevel.slice(1) : 'Niedrig'}</div></div>
<div className="bv-sessions-item"><div className="bv-si-label">User Risk</div><div className="bv-si-value">{risk?.riskDetail || 'Niedrig'}</div></div>
<div className="bv-sessions-item"><div className="bv-si-label">Konto-Status</div><div className="bv-si-value">{user.is_active ? 'Aktiv' : 'Gesperrt'}</div></div>
</div>
</div>
</div>
</div>
</div>
);
};
/* ══════════════════════════════════════════════════════════════════
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 <div style={{padding:40,textAlign:'center',color:'var(--text-muted)'}}>Lade FIDO-Keys</div>;
const primaryKey = keys.find(k => k.key_type === 'primary') || keys[0];
const otherKeys = keys.filter(k => k !== primaryKey);
const hasEnoughKeys = keys.length >= 2;
return (
<div className="bv-tab-content">
<div className="bv-fido-hero">
{keys.map((key, i) => (
<div key={key.id} className="bv-fkc-card">
<div className="bv-fkc-header">
<div className="bv-fkc-visual">
<KeyIcon />
</div>
<span className="bv-fkc-type-badge">{i === 0 ? 'PRIMÄR' : 'BACKUP'}</span>
</div>
<div className="bv-fkc-name">{key.name}</div>
<div className="bv-fkc-sub">{key.manufacturer || 'Yubico'} · {key.connection_type || 'USB'} · SN {key.serial_number}</div>
<div className="bv-fkc-stats">
<div className="bv-fkc-stat">
<div className="bv-fkcs-v"></div>
<div className="bv-fkcs-l">Auth. Gesamt</div>
</div>
<div className="bv-fkc-stat">
<div className="bv-fkcs-v">{fmtTime(key.last_used_at)}</div>
<div className="bv-fkcs-l">Letzte Nutzung</div>
</div>
</div>
</div>
))}
<Link to="/fido-keys" className="bv-add-key-card">
<div className="bv-akc-plus"><PlusIcon /></div>
<div className="bv-akc-title">Weiteren Schlüssel registrieren</div>
<div style={{fontSize:11.5,color:'var(--text-muted)'}}>YubiKey, Titan, Feitian WebAuthn</div>
</Link>
</div>
<div className="bv-fido-bottom">
<div className="bv-card">
<div className="bv-card-head">
<div className="bv-card-title"><BarIcon /> Authentifizierungs-Aktivität</div>
<span style={{fontSize:11,color:'var(--text-muted)'}}>Letzte 7 Tage</span>
</div>
<div className="bv-fido-activity">
<div className="bv-fido-act-row"><span className="bv-fido-act-label">Erfolgreiche Anmeldungen</span><span className="bv-fido-act-value green"> via FIDO2</span></div>
<div className="bv-fido-act-row"><span className="bv-fido-act-label">Touch-Bestätigungen</span><span className="bv-fido-act-value"> · User Presence</span></div>
<div className="bv-fido-act-row"><span className="bv-fido-act-label">PIN-Eingaben</span><span className="bv-fido-act-value"> · User Verification</span></div>
<div className="bv-fido-act-row"><span className="bv-fido-act-label">Fehlversuche</span><span className="bv-fido-act-value green">0 blockiert</span></div>
<div className="bv-fido-act-row"><span className="bv-fido-act-label">Verwendete Browser</span><span className="bv-fido-act-value"></span></div>
</div>
</div>
<div className="bv-card">
<div className="bv-card-head">
<div className="bv-card-title"><CheckIcon /> Empfehlungen</div>
</div>
<div className="bv-rec-list">
<div className="bv-rec-item">
<div className={`bv-rec-icon ${hasEnoughKeys ? 'ok' : 'warn'}`}>{hasEnoughKeys ? <CheckIcon /> : <KeyIcon />}</div>
<div>
<div className="bv-rec-body">Mindestens 2 Schlüssel registriert</div>
<div className="bv-rec-body-sub">Primär &amp; Backup vorhanden</div>
</div>
<span className={`bv-rec-badge ${hasEnoughKeys ? 'ok' : 'warn'}`}>{hasEnoughKeys ? 'Erfüllt' : 'Ausstehend'}</span>
</div>
<div className="bv-rec-item">
<div className="bv-rec-icon ok"><CheckIcon /></div>
<div>
<div className="bv-rec-body">Schlüssel-Attestation aktiv</div>
<div className="bv-rec-body-sub">Nur zertifizierte Authenticators</div>
</div>
<span className="bv-rec-badge ok">Erfüllt</span>
</div>
<div className="bv-rec-item">
<div className="bv-rec-icon warn"><KeyIcon /></div>
<div>
<div className="bv-rec-body">Passwortlose Anmeldung</div>
<div className="bv-rec-body-sub">Optional Passwort noch aktiv</div>
</div>
<span className="bv-rec-badge warn">Empfohlen</span>
</div>
</div>
</div>
</div>
</div>
);
};
/* ══════════════════════════════════════════════════════════════════
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 (
<div className="bv-tab-content">
{/* Stage Bar */}
<div className="bv-lc-stage-bar">
{stages.map(s => (
<div key={s.n} className={`bv-lc-stage ${s.n < stage ? 'done' : s.n === stage ? 'current' : ''}`}>
<div className="bv-lcs-num">{String(s.n).padStart(2, '0')} · {s.n < stage ? 'ABGESCHLOSSEN' : s.n === stage ? 'LAUFEND' : ''}</div>
<div className="bv-lcs-label">{s.label}</div>
<div className="bv-lcs-date">{s.date}</div>
</div>
))}
</div>
<div className="bv-lc-grid">
{/* Meilensteine */}
<div className="bv-card">
<div className="bv-card-head">
<div className="bv-card-title"><ClockIcon /> Meilensteine &amp; Termine</div>
<span className="bv-card-link">Alle anzeigen</span>
</div>
<div className="bv-milestone-list">
{milestones.map((ms, i) => (
<div key={i} className="bv-milestone">
<div className={`bv-ms-dot ${ms.type}`}>
{ms.type === 'done' && <Ico size={10}><path d="M9 12l2 2 4-4" stroke="#052e1a" strokeWidth="3" strokeLinecap="round"/></Ico>}
</div>
<div className="bv-ms-body">
<div className="bv-ms-title">{ms.label}</div>
<div className="bv-ms-sub">{ms.sub}</div>
</div>
<div className={`bv-ms-time ${ms.future ? 'future' : ''}`}>{ms.date}</div>
</div>
))}
</div>
</div>
{/* Rechte Spalte */}
<div style={{display:'flex',flexDirection:'column',gap:14}}>
{/* Vertrag & Rolle */}
<div className="bv-card">
<div className="bv-card-head">
<div className="bv-card-title"><Ico><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></Ico> Vertrag &amp; Rolle</div>
</div>
<div className="bv-contract-grid">
<div className="bv-kv-card">
<div className="bv-kv-label">Eintritt</div>
<div className="bv-kv-value">{fmtDate(joinDate)}</div>
<div className="bv-kv-sub">{user.employment_type || 'Unbefristet'}</div>
</div>
<div className="bv-kv-card">
<div className="bv-kv-label">Beschäftigung</div>
<div className="bv-kv-value">{user.employment_type || 'Vollzeit'} · {user.work_hours || 40}h</div>
<div className="bv-kv-sub">TVöD-äquiv.</div>
</div>
<div className="bv-kv-card">
<div className="bv-kv-label">Standort</div>
<div className="bv-kv-value">{user.location || '—'}</div>
<div className="bv-kv-sub">Hybrid 3:2</div>
</div>
<div className="bv-kv-card">
<div className="bv-kv-label">Kostenstelle</div>
<div className="bv-kv-value">{user.cost_center || '—'}</div>
<div className="bv-kv-sub">{user.department || ''}</div>
</div>
</div>
</div>
{/* Offboarding Bereitschaft */}
<div className="bv-offboard-card">
<div className="bv-card-head">
<div className="bv-card-title"><ShieldIcon /> Offboarding-Bereitschaft</div>
<span style={{fontSize:11,color:'var(--text-muted)',background:'rgba(255,255,255,0.06)',padding:'2px 8px',borderRadius:999}}>{offb ? 'Aktiv' : 'Nicht aktiv'}</span>
</div>
<div className="bv-offboard-row"><span className="bv-offboard-row-k">Geplantes Austrittsdatum</span><span className="bv-offboard-row-v">{offb?.exit_date ? fmtDate(offb.exit_date) : ' Keines hinterlegt'}</span></div>
<div className="bv-offboard-row"><span className="bv-offboard-row-k">Datenübergabe-Plan</span><span className="bv-offboard-row-v">Automatisch · OneDrive, Mail-Archiv</span></div>
<div className="bv-offboard-row"><span className="bv-offboard-row-k">Hardware-Rückgabe</span><span className="bv-offboard-row-v">{full?.asset_count || 0} Geräte registriert</span></div>
<div className="bv-offboard-row"><span className="bv-offboard-row-k">Lizenzfreigabe</span><span className="bv-offboard-row-v">{user.license_count || ''} Lizenzen</span></div>
<div className="bv-offboard-row"><span className="bv-offboard-row-k">Wissenstransfer</span><span className="bv-offboard-row-v" style={{color:'var(--text-muted)'}}> Noch nicht initiiert</span></div>
<div className="bv-offboard-actions">
<button className="btn btn-outline" style={{flex:1}} onClick={() => navigate('/offboarding')}>Austritt planen</button>
<button className="btn btn-outline" style={{flex:1}}>Lifecycle Export</button>
</div>
</div>
</div>
</div>
</div>
);
};
/* ══════════════════════════════════════════════════════════════════
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 (
<div className="bv-modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="bv-modal">
<div className="bv-modal-head">
<div className="bv-modal-title">Benutzer bearbeiten</div>
<button className="bv-modal-close" onClick={onClose}><XIcon /></button>
</div>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:12}}>
{[
['first_name','Vorname'],['last_name','Nachname'],['email','E-Mail'],
['department','Abteilung'],['position','Position'],['phone','Telefon'],
['location','Standort'],['cost_center','Kostenstelle'],
].map(([k, label]) => (
<div key={k} className="bv-form-row">
<label className="bv-form-label">{label}</label>
<input className="form-input" value={form[k]} onChange={e => setForm(p => ({...p, [k]: e.target.value}))} />
</div>
))}
<div className="bv-form-row">
<label className="bv-form-label">Beschäftigungsart</label>
<select className="form-input" value={form.employment_type} onChange={e => setForm(p => ({...p, employment_type: e.target.value}))}>
<option>Vollzeit</option><option>Teilzeit</option><option>Minijob</option><option>Werkstudent</option>
</select>
</div>
<div className="bv-form-row">
<label className="bv-form-label">Wochenstunden</label>
<input className="form-input" type="number" value={form.work_hours} onChange={e => setForm(p => ({...p, work_hours: parseInt(e.target.value)}))} />
</div>
<div className="bv-form-row">
<label className="bv-form-label">Eintrittsdatum</label>
<input className="form-input" type="date" value={form.joined_date} onChange={e => setForm(p => ({...p, joined_date: e.target.value}))} />
</div>
<div className="bv-form-row">
<label className="bv-form-label">Vorgesetzter</label>
<select className="form-input" value={form.manager_id} onChange={e => setForm(p => ({...p, manager_id: e.target.value}))}>
<option value=""> kein Vorgesetzter </option>
{allUsers.filter(u => u.id !== user.id).map(u => (
<option key={u.id} value={u.id}>{u.first_name} {u.last_name} ({u.username})</option>
))}
</select>
</div>
</div>
<div className="bv-form-actions">
<button className="btn btn-outline" onClick={onClose}>Abbrechen</button>
<button className="btn btn-primary" onClick={save} disabled={saving}>{saving ? 'Speichern…' : 'Speichern'}</button>
</div>
</div>
</div>
);
};
/* ══════════════════════════════════════════════════════════════════
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 (
<div ref={ref} style={{position:'relative'}}>
<button className="btn" onClick={() => setOpen(p => !p)} title="Weitere Aktionen"><MoreIcon /></button>
{open && (
<div style={{position:'absolute',right:0,top:'calc(100% + 4px)',background:'var(--bg-secondary)',border:'1px solid var(--border-color)',borderRadius:10,padding:6,zIndex:100,minWidth:160,boxShadow:'0 8px 24px rgba(0,0,0,0.4)'}}>
<button onClick={() => { onDeactivate(); setOpen(false); }} style={{display:'block',width:'100%',textAlign:'left',background:'none',border:'none',color:'var(--text-primary)',padding:'8px 12px',borderRadius:7,cursor:'pointer',fontSize:13}}>
{user.is_active ? 'Deaktivieren' : 'Aktivieren'}
</button>
<button onClick={() => { onDelete(); setOpen(false); }} style={{display:'block',width:'100%',textAlign:'left',background:'none',border:'none',color:'#ef4444',padding:'8px 12px',borderRadius:7,cursor:'pointer',fontSize:13}}>
Löschen
</button>
</div>
)}
</div>
);
};
/* ══════════════════════════════════════════════════════════════════
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 (
<div className={`bv-user-row ${u.id === selectedId ? 'active' : ''}`} onClick={() => handleSelect(u.id)}>
{u.avatar_url ? (
<img src={u.avatar_url} alt={init} style={{width:36, height:36, borderRadius:10, objectFit:'cover', flexShrink:0}} onError={e => { e.target.style.display='none'; }} />
) : (
<div className="bv-ur-av" style={{background: avatarBg, width:36, height:36, borderRadius:10, display:'flex', alignItems:'center', justifyContent:'center', fontSize:13, fontWeight:700, color:'#fff', flexShrink:0}}>
{init}
</div>
)}
<div className={`bv-presence ${p}`} />
<div className="bv-ur-body">
<div className="bv-ur-name">{u.first_name} {u.last_name}</div>
<div className="bv-ur-email">{u.email}</div>
</div>
<span className={`bv-role-chip ${u.role_name}`} style={{background: rcolor.bg, color: rcolor.color, borderColor: rcolor.border}}>
{rcolor.label}
</span>
</div>
);
};
if (loading) return <div style={{display:'flex',alignItems:'center',justifyContent:'center',height:'60vh',color:'var(--text-muted)'}}>Lade Benutzerdaten</div>;
return (
<div style={{display:'flex',flexDirection:'column',height:'calc(100vh - 56px)',overflow:'hidden'}}>
<div className="bv-workspace">
{/* ── User List ─────────────────────────────────────── */}
<div className="bv-list-panel">
<div className="bv-list-head">
<div className="bv-list-title-row">
<div className="bv-list-title">Benutzer</div>
<div className="bv-list-count">{filtered.length} von {users.length}</div>
</div>
<div className="bv-list-sub">Verwalte Identitäten, Rollen &amp; Zugriffe</div>
<div className="bv-list-search">
<SearchIcon />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Name oder E-Mail suchen…" />
</div>
<div className="bv-filter-row">
{[
['all','Alle'],['admin','Admin'],['staff','Support'],
['hr','HR/Buchhaltung'],['production','Produktion'],['user','Benutzer'],
].map(([k, l]) => (
<button key={k} className={`bv-filter-pill ${roleFilter === k ? 'active' : ''}`} onClick={() => setRoleFilter(k)}>{l}</button>
))}
</div>
</div>
<div className="bv-list-scroll">
{favorites.length > 0 && <>
<div className="bv-section-label">Favoriten</div>
{favorites.map(u => <UserRow key={u.id} u={u} />)}
</>}
{others.length > 0 && <>
<div className="bv-section-label">Alle Benutzer</div>
{others.map(u => <UserRow key={u.id} u={u} />)}
</>}
{filtered.length === 0 && <div style={{padding:'40px 20px',textAlign:'center',color:'var(--text-muted)',fontSize:13}}>Keine Benutzer gefunden</div>}
</div>
<div className="bv-list-actions">
<button className="btn btn-primary" style={{flex:1,fontSize:12}} onClick={() => navigate('/users')}>
<PlusIcon /> Benutzer
</button>
<button className="btn btn-outline" style={{fontSize:12}} onClick={handleExportCsv} title="CSV exportieren">
<DownloadIcon />
</button>
</div>
</div>
{/* ── Detail Panel ──────────────────────────────────── */}
<div className="bv-detail-panel">
{!selectedUser ? (
<div className="bv-detail-empty">Benutzer auswählen</div>
) : (
<div className="bv-detail-inner">
{/* Header */}
<div className="bv-detail-head">
<div className="bv-dh-avatar" style={{background: selectedUser.avatar_url ? 'transparent' : (ROLE_AV_COLORS[selectedUser.role_name] || '#475569'), padding: 0, overflow: 'hidden'}}>
{selectedUser.avatar_url ? (
<img src={selectedUser.avatar_url} alt={initials} style={{width:'100%',height:'100%',objectFit:'cover',display:'block'}} onError={e => { e.target.style.display='none'; e.target.closest('.bv-dh-avatar').style.background = ROLE_AV_COLORS[selectedUser.role_name] || '#475569'; }} />
) : (
initials
)}
<div className={`bv-dh-presence ${presence}`} />
</div>
<div className="bv-dh-body">
<div className="bv-dh-name-row">
<h1 className="bv-dh-name">{selectedUser.first_name} {selectedUser.last_name}</h1>
<span className="bv-dh-handle">@{selectedUser.username}</span>
</div>
<div className="bv-dh-email"><MailIcon />{selectedUser.email}</div>
<div className="bv-dh-badges">
<span className="bv-dh-badge" style={{background: rc.bg, color: rc.color, borderColor: rc.border}}>
<ShieldIcon />{rc.label}
</span>
{selectedUser.department && (
<span className="bv-dh-badge dept"><BriefIcon />{selectedUser.department}</span>
)}
<span className="bv-dh-badge active">
<span className="bv-pulse" />
{selectedUser.is_active ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
</div>
<div className="bv-dh-actions">
<button className="btn" onClick={() => setShowEdit(true)}><EditIcon /> Bearbeiten</button>
{selectedUser.azure_id && (
<button className="btn" onClick={handleResetEntra} title="Entra Passwort-Reset"><LockIcon /> Passwort reset</button>
)}
{isSuperAdmin() && (
<MoreDropdown user={selectedUser} onDeactivate={handleDeactivate} onDelete={handleDelete} />
)}
</div>
</div>
{/* Tabs */}
<div className="bv-tabs-bar">
{TABS.map(t => (
<button key={t.id} className={`bv-tab ${tab === t.id ? 'active' : ''}`} onClick={() => setTab(t.id)}>
{t.label}
{t.count != null && t.count > 0 && <span className="bv-tab-count">{t.count}</span>}
</button>
))}
</div>
{/* Tab Content */}
{tab === 'overview' && <OverviewTab user={selectedUser} full={full} auditLogs={auditLogs} />}
{tab === 'assets' && <AssetsTab user={selectedUser} full={full} />}
{tab === 'licenses' && <LicensesTab user={selectedUser} />}
{tab === 'entra' && <EntraTab user={selectedUser} />}
{tab === 'fido' && <FidoTab user={selectedUser} />}
{tab === 'lifecycle' && <LifecycleTab user={selectedUser} full={full} />}
</div>
)}
</div>
</div>
{/* Edit Modal */}
{showEdit && selectedUser && (
<EditModal
user={selectedUser}
allUsers={users}
onClose={() => setShowEdit(false)}
onSaved={(updated) => setUsers(p => p.map(u => u.id === updated.id ? { ...u, ...updated } : u))}
/>
)}
</div>
);
};
export default UserManagementPage;