import React, { useEffect, useMemo, useState } from 'react'; import { Avatar, Badge, Button, Card, EmptyState, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls'; import { Icon, type IconName } from '../ui/Icon'; import { useAuth } from '../../lib/auth'; import { directoryApi, usersApi, type AdUser } from '../../lib/api'; import { useLoader } from '../../lib/hooks'; import { relativeTime } from '../../lib/format'; import { useToast } from '../Toast'; import { ROLE_LABELS, ROLE_TONE, type DirectoryUser, type Role } from '../../types'; type ImportRole = Exclude; const ZONES: { role: ImportRole; hint: string; icon: IconName; tone: string }[] = [ { role: 'user', hint: 'Bucht Zeiten auf zugewiesene Projekte', icon: 'user', tone: 'border-ink-300 bg-ink-50 text-ink-700' }, // Gleiche Rechte wie Mitarbeiter; die Rolle trennt nur, wer zum Haus gehoert. { role: 'external', hint: 'Wie Mitarbeiter, gilt als externe Kraft', icon: 'globe', tone: 'border-amber-200 bg-amber-50 text-amber-700' }, { role: 'lead', hint: 'Sieht und verwaltet sein Team', icon: 'team', tone: 'border-sky-200 bg-sky-50 text-sky-700' }, { role: 'management', hint: 'Sieht alles, verwaltet SSO', icon: 'shield', tone: 'border-violet-200 bg-violet-50 text-violet-700' }, ]; const DRAG_TYPE = 'application/x-pulse-ad-users'; /** * SSO-Verwaltung: links alle zulaessigen AD-Konten, rechts die Rollen. Ziehen * auf eine Rolle uebernimmt das Konto nach Pulse bzw. aendert seine Rolle. * Fuer Touch und Tastatur gibt es dasselbe ueber Auswahl + Knopf. */ export const SsoDirectory: React.FC = () => { const { user } = useAuth(); const { addToast } = useToast(); const [refresh, setRefresh] = useState(0); const { data, error, loading, reload } = useLoader(() => directoryApi.list(refresh > 0), [refresh]); const [managers, setManagers] = useState([]); const [search, setSearch] = useState(''); const [filter, setFilter] = useState<'open' | 'imported' | 'all'>('open'); const [selected, setSelected] = useState>(new Set()); const [managerId, setManagerId] = useState(''); const [dragOver, setDragOver] = useState(null); const [busy, setBusy] = useState(null); const loadManagers = () => usersApi .directory() .then((list) => setManagers(list.filter((u) => u.is_active && (u.role === 'lead' || u.role === 'management')))) .catch(() => undefined); useEffect(() => { loadManagers(); }, []); const users = data?.users ?? []; const visible = useMemo(() => { const q = search.trim().toLowerCase(); return users.filter((u) => { if (filter === 'open' && u.pulse_user_id) return false; if (filter === 'imported' && !u.pulse_user_id) return false; return !q || `${u.name} ${u.email} ${u.job_title ?? ''} ${u.department ?? ''}`.toLowerCase().includes(q); }); }, [users, search, filter]); const byRole = (role: ImportRole) => users.filter((u) => u.pulse_user_id && u.pulse_role === role); const importedCount = users.filter((u) => u.pulse_user_id).length; const selectable = visible.filter((u) => u.pulse_role !== 'admin'); const allSelected = selectable.length > 0 && selectable.every((u) => selected.has(u.id)); const toggle = (id: string) => setSelected((cur) => { const next = new Set(cur); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const assign = async (ids: string[], role: ImportRole) => { const list = ids.filter((id) => users.find((u) => u.id === id)?.pulse_role !== 'admin'); if (!list.length) return; setBusy(role); try { const r = await directoryApi.import(list, role, managerId || null); const parts = []; if (r.created.length) parts.push(`${r.created.length} übernommen`); if (r.linked.length) parts.push(`${r.linked.length} aktualisiert`); if (r.skipped.length) parts.push(`${r.skipped.length} nicht mehr im AD`); addToast(`${ROLE_LABELS[role]}: ${parts.join(', ') || 'keine Änderung'}.`, 'success'); setSelected(new Set()); await reload(); if (role !== 'user' && role !== 'external') loadManagers(); } catch (err) { addToast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error'); } finally { setBusy(null); } }; const sync = async () => { setBusy('sync'); try { const r = await directoryApi.sync(); addToast(`Abgleich: ${r.updated} aktualisiert, ${r.deactivated} deaktiviert (${r.eligible} zulässige von ${r.total} AD-Konten).`, 'success'); setRefresh((n) => n + 1); } catch (err) { addToast(err instanceof Error ? err.message : 'Abgleich fehlgeschlagen.', 'error'); } finally { setBusy(null); } }; const onDragStart = (event: React.DragEvent, u: AdUser) => { // Gezogene Zeile gehoert zur Auswahl? Dann die ganze Auswahl mitnehmen. const ids = selected.has(u.id) ? [...selected] : [u.id]; event.dataTransfer.setData(DRAG_TYPE, JSON.stringify(ids)); event.dataTransfer.setData('text/plain', ids.length > 1 ? `${ids.length} Benutzer` : u.name); event.dataTransfer.effectAllowed = 'copyMove'; }; const onDrop = (event: React.DragEvent, role: ImportRole) => { event.preventDefault(); setDragOver(null); try { const ids = JSON.parse(event.dataTransfer.getData(DRAG_TYPE) || '[]'); if (Array.isArray(ids) && ids.length) assign(ids, role); } catch { /* fremdes Drag-Objekt */ } }; if (error && !data) return ; if (!data) return ; if (!data.configured) { return ( ); } return (

{users.length} Mitarbeiterkonten im AD · {importedCount} in Pulse

Nur vorname.nachname@{data.domains.join(' / @')} – ohne Admin-Konten, freigegebene Postfächer und Gäste {data.fetched_at && ` · Stand ${relativeTime(data.fetched_at)}`}

{/* Active Directory */}

Active Directory

{ setFilter(v); setSelected(new Set()); }} options={[ { value: 'open', label: 'Nicht übernommen' }, { value: 'imported', label: 'In Pulse' }, { value: 'all', label: 'Alle' }, ]} />
setSearch(e.target.value)} />
    {visible.length === 0 &&
  • Keine Konten für diesen Filter.
  • } {visible.map((u, i) => { const isAdmin = u.pulse_role === 'admin'; const checked = selected.has(u.id); return (
  • onDragStart(e, u)} className={`animate-rise flex items-center gap-3 px-4 py-2.5 transition ${isAdmin ? 'opacity-60' : 'cursor-grab active:cursor-grabbing'} ${ checked ? 'bg-brand-50' : 'hover:bg-ink-50' }`} style={{ '--i': Math.min(i, 12) } as React.CSSProperties} > toggle(u.id)} className="h-4 w-4 shrink-0 accent-[var(--color-brand-500)]" aria-label={`${u.name} auswählen`} />

    {u.name}

    {u.email} {(u.job_title || u.department) && ` · ${[u.job_title, u.department].filter(Boolean).join(', ')}`}

    {u.pulse_role && {ROLE_LABELS[u.pulse_role]}} {u.pulse_active === false && inaktiv} {!u.pulse_user_id && u.local_match_id && ( lokal vorhanden )}
    {!isAdmin && }
  • ); })}
{selected.size > 0 && (
{selected.size} ausgewählt – übernehmen als {ZONES.map((z) => ( ))}
)}
{/* Rollen */}
{ZONES.map((zone) => { const members = byRole(zone.role); const over = dragOver === zone.role; return (
{ if (!e.dataTransfer.types.includes(DRAG_TYPE)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; if (dragOver !== zone.role) setDragOver(zone.role); }} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOver(null); }} onDrop={(e) => onDrop(e, zone.role)} className={`rounded-2xl border-2 border-dashed p-4 transition-all duration-200 ${ over ? 'scale-[1.01] border-brand-400 bg-brand-50 shadow-lift' : 'border-ink-200 bg-surface' }`} >

{ROLE_LABELS[zone.role]} ({members.length})

{over ? 'Loslassen zum Übernehmen' : zone.hint}

{busy === zone.role && }
{members.length > 0 && (
{members.map((m) => ( onDragStart(e, m)} title="Auf eine andere Rolle ziehen, um sie zu ändern" className={`flex cursor-grab items-center gap-1.5 rounded-full border border-ink-200 bg-surface py-0.5 pl-0.5 pr-2.5 text-xs font-bold text-ink-700 active:cursor-grabbing ${ m.pulse_active === false ? 'opacity-50' : '' }`} > {m.name} ))}
)}
); })}

Anmelden per Microsoft können sich nur übernommene Konten. Rollen werden ausschließlich hier bzw. unter Team vergeben – AD-Gruppen spielen keine Rolle. Hierarchie, Wochenstunden und Deaktivieren unter Team.

); };