2026-06-01 20:49:07 +02:00
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' ;
2026-06-02 11:57:44 +02:00
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' ) ; } }
/ >
) ;
}
2026-06-01 20:49:07 +02:00
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 ) } >
2026-06-02 11:57:44 +02:00
{ 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 >
) }
2026-06-01 20:49:07 +02:00
< 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" >
2026-06-02 11:57:44 +02:00
< 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
) }
2026-06-01 20:49:07 +02:00
< 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 ;