// ============================================================= // Intune App-Manager — Frontend // Workflow: Connect → Select Targets → Pick Apps → Apply // ============================================================= const State = { connected: false, account: null, mode: 'dept', // 'dept' | 'rpa' | 'user' targets: [], selectedTargetIds: new Set(), apps: [], appFilter: { search: '', preset: 'all', category: '', onlyMember: false, hideNative: false, modFrom: '', modTo: '' }, session: [], membershipCache: new Map(), loadedModes: new Set(), appsLoaded: false, sideTab: 'session', // groupId → { members:Array|null, selected:Set, loading:bool, error:string|null, filter:string } expandedGroups: new Map(), // mode → { targets, selectedIds:Set, expandedGroups:Map } — verhindert Verlust beim Tab-Wechsel modeCache: new Map(), // appId → { details, loading, error } appDetails: new Map(), expandedApps: new Set(), }; // ============================================================= // API wrapper // ============================================================= async function api(path, options = {}) { const opts = { method: options.method || 'GET', headers: { 'Content-Type': 'application/json' }, body: options.body ? JSON.stringify(options.body) : undefined, }; // Optionaler Timeout (z.B. fuer lange Upload-Operationen). fetch hat sonst // keinen Default-Timeout — lauft auf localhost ewig, was hier ok ist. let timer = null; if (options.timeoutMs) { const ctrl = new AbortController(); opts.signal = ctrl.signal; timer = setTimeout(() => ctrl.abort(), options.timeoutMs); } let res; try { res = await fetch(path, opts); } finally { if (timer) clearTimeout(timer); } let data; try { data = await res.json(); } catch { data = {}; } if (!res.ok) { const msg = data.error || `HTTP ${res.status}`; throw new Error(msg); } return data; } // ============================================================= // Toasts // ============================================================= function toast(msg, type = 'info', title = null) { const ico = { ok: '✓', warn: '!', err: '×', info: 'i' }[type] || 'i'; const t = document.createElement('div'); t.className = `toast ${type}`; t.innerHTML = `
${ico}
${title ? `
${escapeHtml(title)}
` : ''}
${escapeHtml(msg)}
`; document.getElementById('toasts').appendChild(t); setTimeout(() => { t.style.animation = 'slide-in 200ms reverse'; setTimeout(() => t.remove(), 220); }, type === 'err' ? 6000 : 3500); } function escapeHtml(s) { if (s == null) return ''; return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } // Markdown -> HTML. Nutzt marked (gebundelt in /vendor/marked.min.js). // breaks: true -> einzelne Newlines werden zu
, intuitiver fuer User // gfm: true -> GitHub-Flavored Markdown (Tabellen, Strikethrough, Autolinks) // Wenn marked aus irgendeinem Grund nicht geladen ist, faellt der Helper // auf escapeHtml zurueck — kein Crash, nur kein Markdown. let markedConfigured = false; function renderMarkdown(s) { if (s == null) return ''; const text = String(s); if (typeof window.marked === 'undefined') return escapeHtml(text); if (!markedConfigured) { window.marked.setOptions({ breaks: true, gfm: true }); markedConfigured = true; } try { return window.marked.parse(text); } catch { return escapeHtml(text); } } // ============================================================= // Loading overlay // ============================================================= let loadingDepth = 0; function setLoading(text) { loadingDepth++; document.getElementById('loadingText').textContent = text || 'Bitte warten...'; document.getElementById('loading').classList.remove('hidden'); } function clearLoading() { loadingDepth = Math.max(0, loadingDepth - 1); if (loadingDepth === 0) document.getElementById('loading').classList.add('hidden'); } // ============================================================= // Workflow stepper // ============================================================= // Stepper wurde entfernt — Funktion bleibt als No-Op, damit die ~12 // bestehenden Aufrufer keine Errors werfen. Kann irgendwann mit Sed // gemeinsam mit den Aufrufstellen rausgesnipt werden. function updateStepper() { /* no-op */ } // ============================================================= // Connection // ============================================================= function showOnboarding(show) { document.getElementById('onboarding').classList.toggle('hidden', !show); document.getElementById('layout').classList.toggle('hidden', show); if (show) { // Reset state-View nur, wenn gerade keine Anmeldung lief if (!connectInFlight) showOnboardingState('idle'); } else { stopDevicePolling(); } } async function refreshStatus() { try { const s = await api('/api/status'); State.connected = s.connected; State.account = s.account; renderConnection(); } catch (e) { toast('Status konnte nicht geladen werden: ' + e.message, 'err'); } } // Bootstrap-Icons "person-circle" — Avatar mit eingebautem Kreis-Outline. // 20px matches die visuelle Groesse der anderen Header-Icons (cog, logout). const PERSON_ICON = ``; function renderConnection() { const pill = document.getElementById('connStatus'); const text = pill.querySelector('.conn-text'); const btnD = document.getElementById('btnDisconnect'); // Avatar ist der Person-Icon — Identifikation des aktiven Accounts laeuft // ueber den Tooltip (title-Attribut, hover). text.innerHTML = PERSON_ICON; if (State.connected) { pill.className = 'conn-pill conn-on'; pill.title = State.account || 'Verbunden'; btnD.classList.remove('hidden'); showOnboarding(false); } else { pill.className = 'conn-pill conn-off'; pill.title = 'Nicht verbunden'; btnD.classList.add('hidden'); showOnboarding(true); } updateStepper(); } // ---------------------------------------------------------------- // Device-Code-Login: Anmeldung direkt auf der Seite, ohne Popup- // Fenster, ohne WAM. Nutzer kann jeden Account angeben. // ---------------------------------------------------------------- let connectInFlight = false; let devicePollTimer = null; function showOnboardingState(view) { // view: 'idle' | 'requesting' | 'awaiting' | 'error' document.querySelectorAll('[data-onboarding-view]').forEach(el => { el.classList.toggle('hidden', el.dataset.onboardingView !== view); }); } function stopDevicePolling() { if (devicePollTimer) { clearInterval(devicePollTimer); devicePollTimer = null; } } document.getElementById('btnConnect').addEventListener('click', async () => { if (connectInFlight) return; connectInFlight = true; showOnboardingState('awaiting'); // Sekundenzaehler im UI const startedAt = Date.now(); const waitText = document.querySelector('[data-onboarding-view="awaiting"] .onboarding-text'); const baseText = waitText ? waitText.textContent : ''; const tickTimer = setInterval(() => { if (waitText) { const sec = Math.floor((Date.now() - startedAt) / 1000); waitText.textContent = `${baseText} (Wartet seit ${sec}s)`; } }, 1000); // 90s Timeout — Connect-MgGraph blockt typisch 5-30s; 90s ist sicher const ctrl = new AbortController(); const timeoutId = setTimeout(() => ctrl.abort(), 90000); try { const resp = await fetch('/api/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ctrl.signal, }); clearTimeout(timeoutId); let s = {}; try { s = await resp.json(); } catch {} if (!resp.ok) throw new Error(s.error || `HTTP ${resp.status}`); if (!s.connected) throw new Error('Backend meldet nicht connected'); State.connected = true; State.account = s.account; renderConnection(); toast('Verbunden als ' + s.account, 'ok', 'Erfolgreich'); autoLoadAfterConnect(); } catch (e) { clearTimeout(timeoutId); let msg = e.message; if (e.name === 'AbortError') { msg = 'Anmeldung dauert über 90s. Möglicherweise ist das Login-Fenster im Hintergrund — prüfe die Taskleiste. Sonst PowerShell-Konsole prüfen.'; } document.getElementById('dcError').textContent = msg; showOnboardingState('error'); toast(msg, 'err', 'Verbindung fehlgeschlagen'); } finally { clearInterval(tickTimer); if (waitText) waitText.textContent = baseText; connectInFlight = false; } }); document.getElementById('btnCancelConnect')?.addEventListener('click', async () => { stopDevicePolling(); try { await api('/api/connect/cancel', { method: 'POST' }); } catch {} showOnboardingState('idle'); connectInFlight = false; }); document.getElementById('btnRetryConnect')?.addEventListener('click', () => { showOnboardingState('idle'); connectInFlight = false; }); document.getElementById('btnDisconnect').addEventListener('click', async () => { setLoading('Trenne...'); stopDevicePolling(); connectInFlight = false; try { await api('/api/disconnect', { method: 'POST' }); Object.assign(State, { connected: false, account: null, targets: [], apps: [], session: [], appsLoaded: false, }); State.selectedTargetIds.clear(); State.membershipCache.clear(); State.loadedModes.clear(); State.appDetails.clear(); State.expandedApps.clear(); State.modeCache.clear(); renderConnection(); renderTargets(); renderPinnedTargets(); renderApps(); renderSession(); renderExisting(); toast('Verbindung getrennt', 'info'); } finally { clearLoading(); } }); async function autoLoadAfterConnect() { // Load default mode targets + apps in parallel const tasks = []; if (!State.loadedModes.has(State.mode)) tasks.push(loadTargets({ silent: true })); if (!State.appsLoaded) tasks.push(loadApps(false, { silent: true })); await Promise.allSettled(tasks); } // ============================================================= // Mode tabs (segmented control) // ============================================================= document.querySelectorAll('.seg').forEach(seg => { seg.addEventListener('click', () => { if (seg.classList.contains('active')) return; const oldMode = State.mode; const newMode = seg.dataset.mode; // Aktuellen Stand fuer alten Mode sichern State.modeCache.set(oldMode, { targets: State.targets.slice(), selectedIds: new Set(State.selectedTargetIds), expandedGroups: State.expandedGroups, }); document.querySelectorAll('.seg').forEach(s => s.classList.remove('active')); seg.classList.add('active'); State.mode = newMode; // Stand fuer neuen Mode wiederherstellen — falls da const cached = State.modeCache.get(newMode); if (cached) { State.targets = cached.targets; State.selectedTargetIds = new Set(cached.selectedIds); State.expandedGroups = cached.expandedGroups; } else { State.targets = []; State.selectedTargetIds = new Set(); State.expandedGroups = new Map(); } // Membership-Cache leeren da Targets sich aendern → andere Mitgliedschafts-Resultate State.membershipCache.clear(); const search = document.getElementById('targetSearch'); const hint = document.getElementById('userSearchHint'); if (newMode === 'user') { search.placeholder = 'Tippe Name oder UPN...'; hint.classList.remove('hidden'); // Suchwert bei User-Mode immer leer starten search.value = ''; search.focus(); try { search.setSelectionRange(0, 0); } catch {} } else { search.placeholder = 'Filtern...'; hint.classList.add('hidden'); search.value = ''; } renderTargets(); onTargetSelectionChange(); // Auto-Load nur wenn fuer diesen Mode noch nichts geladen wurde UND es kein User-Mode ist if (State.connected && newMode !== 'user' && !State.loadedModes.has(newMode) && State.targets.length === 0) { loadTargets({ silent: false }); } }); }); // ============================================================= // Targets // ============================================================= document.getElementById('btnReloadTargets').addEventListener('click', () => { if (State.mode === 'user') { const term = document.getElementById('targetSearch').value; if (!term || term.length < 2) { document.getElementById('targetSearch').focus(); toast('Tippe mind. 2 Zeichen für Benutzersuche', 'warn'); return; } runUserSearch(term); } else { State.loadedModes.delete(State.mode); loadTargets({ silent: false }); } }); document.getElementById('targetSearch').addEventListener('input', e => { if (State.mode === 'user') { debouncedUserSearch(e.target.value); } else { renderTargets(); } }); // Enter key triggers explicit user search document.getElementById('targetSearch').addEventListener('keydown', e => { if (e.key === 'Enter' && State.mode === 'user') { const term = e.target.value; if (term && term.length >= 2) runUserSearch(term); } }); document.getElementById('btnTargetsSelectAll').addEventListener('click', () => { getFilteredTargets().forEach(t => State.selectedTargetIds.add(t.Id)); renderTargets(); onTargetSelectionChange(); }); document.getElementById('btnTargetsClear').addEventListener('click', () => { State.selectedTargetIds.clear(); renderTargets(); onTargetSelectionChange(); }); let userSearchTimer = null; let userSearchAbort = null; let userSearchCache = { query: '', results: [] }; function debouncedUserSearch(term) { clearTimeout(userSearchTimer); const t = (term || '').trim(); if (t.length < 2) { State.targets = []; userSearchCache = { query: '', results: [] }; renderTargets(); return; } // Optimistic: wenn neue Anfrage Verfeinerung der vorigen ist // → sofort lokal filtern, kein Server-Request, NULL Latenz const lower = t.toLowerCase(); const cachedLower = userSearchCache.query.toLowerCase(); if (cachedLower && lower.startsWith(cachedLower) && userSearchCache.results.length > 0) { State.targets = userSearchCache.results.filter(u => (u.DisplayName || '').toLowerCase().includes(lower) || (u.UserPrincipalName || '').toLowerCase().includes(lower) || (u.Mail || '').toLowerCase().includes(lower)); renderTargets(); return; } userSearchTimer = setTimeout(() => runUserSearch(t), 220); } async function runUserSearch(term) { if (!State.connected) { toast('Bitte zuerst verbinden', 'warn'); return; } // Vorherige Anfrage abbrechen — sonst stapeln sich die Requests beim Tippen if (userSearchAbort) { try { userSearchAbort.abort(); } catch {} } userSearchAbort = new AbortController(); const signal = userSearchAbort.signal; document.getElementById('targetList').innerHTML = `
`; try { const resp = await fetch('/api/users?q=' + encodeURIComponent(term), { headers: { 'Content-Type': 'application/json' }, signal, }); let data = {}; try { data = await resp.json(); } catch {} if (signal.aborted) return; if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); State.targets = data.items || []; userSearchCache = { query: term, results: State.targets.slice() }; // Selektion auf das neue Result-Set einschraenken — sonst bleibt der alte // User im selectedTargetIds-Set haengen und verfaelscht alle Membership- // Anfragen (status wird Partial/None weil der alte User nicht mehr matcht). if (State.selectedTargetIds.size > 0) { const visibleIds = new Set(State.targets.map(t => t.Id)); const before = State.selectedTargetIds.size; for (const id of [...State.selectedTargetIds]) { if (!visibleIds.has(id)) State.selectedTargetIds.delete(id); } if (State.selectedTargetIds.size !== before) { State.membershipCache.clear(); } } renderTargets(); onTargetSelectionChange(); if (State.targets.length === 0) toast(`Keine Benutzer für "${term}" gefunden`, 'warn'); } catch (e) { if (e.name === 'AbortError') return; // neue Suche laeuft schon toast(e.message, 'err', 'Benutzersuche fehlgeschlagen'); renderTargets(); } } let loadTargetsInFlight = null; // welcher Mode laeuft gerade async function loadTargets(opts = {}) { if (!State.connected) { toast('Bitte zuerst verbinden', 'warn'); return; } if (State.mode === 'user') { const term = document.getElementById('targetSearch').value; if (!term || term.length < 2) { document.getElementById('targetSearch').focus(); toast('Tippe mind. 2 Zeichen, um Benutzer zu suchen', 'warn'); return; } return runUserSearch(term); } const requestedMode = State.mode; if (loadTargetsInFlight === requestedMode) return; // gleiche Anfrage laeuft bereits loadTargetsInFlight = requestedMode; if (!opts.silent) setLoading('Lade ' + (requestedMode === 'rpa' ? 'RPA-Gruppen' : 'Abteilungen') + '...'); document.getElementById('targetList').innerHTML = `
`; try { const url = requestedMode === 'rpa' ? '/api/groups/rpa' : '/api/groups'; const res = await api(url); // User koennte zwischenzeitlich den Tab gewechselt haben — Resultat verwerfen if (State.mode !== requestedMode) return; State.targets = res.items || []; State.loadedModes.add(requestedMode); State.selectedTargetIds.clear(); State.membershipCache.clear(); // Cache fuer diesen Mode aktualisieren State.modeCache.set(requestedMode, { targets: State.targets.slice(), selectedIds: new Set(), expandedGroups: new Map(), }); renderTargets(); onTargetSelectionChange(); if (!opts.silent) toast(`${res.count} ${requestedMode === 'rpa' ? 'RPA-Gruppen' : 'Abteilungen'} geladen`, 'ok'); } catch (e) { if (State.mode === requestedMode) { toast(e.message, 'err', 'Laden fehlgeschlagen'); renderTargets(); } } finally { if (loadTargetsInFlight === requestedMode) loadTargetsInFlight = null; if (!opts.silent) clearLoading(); } } function getFilteredTargets() { const q = document.getElementById('targetSearch').value.toLowerCase().trim(); if (!q || State.mode === 'user') return State.targets; return State.targets.filter(t => (t.DisplayName || '').toLowerCase().includes(q) || (t.UserPrincipalName || '').toLowerCase().includes(q) ); } function renderTargets() { const list = document.getElementById('targetList'); const cnt = document.getElementById('targetCount'); cnt.textContent = State.targets.length; const filtered = getFilteredTargets(); if (!State.connected) { list.innerHTML = emptyState({ title: 'Nicht verbunden', text: 'Verbinde dich zuerst mit Microsoft Graph.', }); return; } if (filtered.length === 0) { if (State.mode === 'user') { const term = document.getElementById('targetSearch').value; list.innerHTML = emptyState({ title: term && term.length >= 2 ? 'Keine Treffer' : 'Benutzer suchen', text: term && term.length >= 2 ? `Keine Benutzer für "${escapeHtml(term)}" gefunden.` : 'Tippe mind. 2 Zeichen — Name, UPN oder E-Mail.', }); } else { list.innerHTML = emptyState({ title: State.targets.length === 0 ? 'Wird geladen...' : 'Keine Treffer', text: State.targets.length === 0 ? 'Wenn nichts kommt, klicke auf das Refresh-Icon oben rechts.' : 'Filter zurücksetzen oder neu laden.', }); } return; } const isGroupMode = State.mode === 'dept' || State.mode === 'rpa'; list.innerHTML = filtered.map(t => { const checked = State.selectedTargetIds.has(t.Id) ? 'checked' : ''; const meta = t.UserPrincipalName ? `
${escapeHtml(t.UserPrincipalName)}${t.Department ? ' · ' + escapeHtml(t.Department) : ''}
` : ''; const isSel = State.selectedTargetIds.has(t.Id) ? 'selected' : ''; const isEmpty = isGroupMode && t.HasMembers === false ? 'empty' : ''; const expanded = isGroupMode && State.expandedGroups.has(t.Id); const expandBtn = isGroupMode ? `` : ''; const expandedHtml = expanded ? renderExpandedMembers(t.Id, t.DisplayName) : ''; // Externe Direkt-Links als dezente Icons rechts neben dem Namen. // User → Entra-User. Gruppe → Entra-Gruppe + Mitglieder-Tab. const linkIcons = t.UserPrincipalName ? `${entraUserLink(t.Id, t.DisplayName)}${teamsChatLink(t.UserPrincipalName, t.DisplayName)}` : `${entraGroupLink(t.Id, t.DisplayName)}${membersTabLink(t.Id, t.DisplayName)}`; return `
${expandedHtml}
`; }).join(''); list.querySelectorAll('.target-row').forEach(row => { row.addEventListener('change', e => { const id = row.dataset.id; if (e.target.checked) State.selectedTargetIds.add(id); else State.selectedTargetIds.delete(id); row.classList.toggle('selected', e.target.checked); onTargetSelectionChange(); }); }); list.querySelectorAll('.row-expand').forEach(btn => { btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); toggleGroupExpand(btn.dataset.groupId, btn.dataset.groupName); }); }); attachExpandedHandlers(list); } function onTargetSelectionChange() { State.membershipCache.clear(); renderPinnedTargets(); renderApps(); renderExisting(); updateMembershipForVisibleApps(); renderSession(); updateStepper(); } function collectAllSelectedTargets() { // Liefert [{ id, name, mode, meta }] ueber alle Modi hinweg. // Aktueller Mode kommt aus State.targets/State.selectedTargetIds, // andere Modi aus State.modeCache. const all = []; const seen = new Set(); const push = (mode, targets, selectedIds) => { const byId = new Map(targets.map(t => [t.Id, t])); selectedIds.forEach(id => { if (seen.has(id)) return; seen.add(id); const t = byId.get(id); const name = t ? (t.DisplayName || id) : id; const meta = t && t.UserPrincipalName ? t.UserPrincipalName : ''; all.push({ id, name, mode, meta, hasMembers: t ? t.HasMembers : undefined }); }); }; push(State.mode, State.targets, State.selectedTargetIds); for (const m of ['dept','rpa','user']) { if (m === State.mode) continue; const c = State.modeCache.get(m); if (c && c.selectedIds && c.selectedIds.size > 0) push(m, c.targets || [], c.selectedIds); } return all; } function renderPinnedTargets() { const wrap = document.getElementById('pinnedTargets'); if (!wrap) return; const items = collectAllSelectedTargets(); if (items.length === 0) { wrap.classList.add('hidden'); wrap.innerHTML = ''; return; } wrap.classList.remove('hidden'); const modeLabels = { dept: 'Abt', rpa: 'RPA', user: 'User' }; const chips = items.map(it => { const isEmpty = it.hasMembers === false ? 'pin-empty' : ''; const title = `${it.name}${it.meta ? ' · ' + it.meta : ''} (${modeLabels[it.mode] || it.mode}) — Klick: zur Zeile springen, X: entfernen`; return ` ${escapeHtml(modeLabels[it.mode] || it.mode)} ${escapeHtml(it.name)} `; }).join(''); wrap.innerHTML = `
Aktuelle Auswahl ${items.length}
${chips}
`; wrap.querySelectorAll('.pin-remove').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); removePinned(btn.dataset.id, btn.dataset.mode); }); }); wrap.querySelectorAll('.pin-chip').forEach(chip => { chip.addEventListener('click', e => { if (e.target.closest('.pin-remove')) return; jumpToPinned(chip.dataset.id, chip.dataset.mode); }); }); wrap.querySelector('.pin-clear-all').addEventListener('click', clearAllPinned); } function removePinned(id, mode) { if (mode === State.mode) { State.selectedTargetIds.delete(id); } else { const c = State.modeCache.get(mode); if (c && c.selectedIds) c.selectedIds.delete(id); } renderTargets(); onTargetSelectionChange(); } function clearAllPinned() { State.selectedTargetIds.clear(); for (const m of ['dept','rpa','user']) { const c = State.modeCache.get(m); if (c && c.selectedIds) c.selectedIds.clear(); } renderTargets(); onTargetSelectionChange(); } function jumpToPinned(id, mode) { if (mode !== State.mode) { const seg = document.querySelector(`.seg[data-mode="${mode}"]`); if (seg) seg.click(); // Nach Mode-Wechsel kurz warten, bis renderTargets durchgelaufen ist setTimeout(() => focusTargetRow(id), 50); } else { focusTargetRow(id); } } function focusTargetRow(id) { const row = document.querySelector(`#targetList .target-row[data-id="${CSS.escape(id)}"]`); if (!row) return; row.scrollIntoView({ behavior: 'smooth', block: 'center' }); row.classList.add('flash'); setTimeout(() => row.classList.remove('flash'), 1200); } // ============================================================= // Apps // ============================================================= document.getElementById('btnReloadApps').addEventListener('click', () => loadApps(true)); document.getElementById('appSearch').addEventListener('input', e => { State.appFilter.search = e.target.value.toLowerCase().trim(); renderApps(); }); document.getElementById('appFilter').addEventListener('change', e => { State.appFilter.preset = e.target.value; renderApps(); }); document.getElementById('appCategoryFilter').addEventListener('change', e => { State.appFilter.category = e.target.value; renderApps(); }); document.getElementById('chkOnlyMember').addEventListener('change', e => { State.appFilter.onlyMember = e.target.checked; renderApps(); }); document.getElementById('chkHideNative').addEventListener('change', e => { State.appFilter.hideNative = e.target.checked; renderApps(); }); // Date-Range Filter (Zuletzt geaendert) — flatpickr ersetzt den nativen // date-input Popup. flatpickr garantiert konsistentes Look-and-Feel und // vermeidet den Chromium-Quirk mit schwarzem Initial-Render des Popups. (function setupDateModFilter() { const inFrom = document.getElementById('dateModFrom'); const inTo = document.getElementById('dateModTo'); const btnX = document.getElementById('dateModClear'); if (!inFrom || !inTo || !btnX) return; // Native type="date" auf type="text" umstellen — sonst klatscht der // Browser sein Picker-UI ueber das von flatpickr drueber. inFrom.type = 'text'; inTo.type = 'text'; inFrom.placeholder = 'tt.mm.jjjj'; inTo.placeholder = 'tt.mm.jjjj'; const syncClearVisibility = () => { btnX.hidden = !(State.appFilter.modFrom || State.appFilter.modTo); }; const commonOpts = { dateFormat: 'Y-m-d', // intern speichern wir ISO (matched Backend-Format) altInput: true, // zweites Input fuer die Anzeige in DE-Format altFormat: 'd.m.Y', // angezeigt: 18.05.2026 allowInput: true, locale: { weekdays: { shorthand: ['So','Mo','Di','Mi','Do','Fr','Sa'], longhand: ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'] }, months: { shorthand: ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'], longhand: ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'], }, firstDayOfWeek: 1, rangeSeparator: ' bis ', }, }; const fpFrom = window.flatpickr(inFrom, Object.assign({}, commonOpts, { onChange: (sel, str) => { State.appFilter.modFrom = str || ''; // "Bis" darf nicht vor "Von" liegen if (fpTo && sel[0]) fpTo.set('minDate', sel[0]); syncClearVisibility(); renderApps(); }, })); const fpTo = window.flatpickr(inTo, Object.assign({}, commonOpts, { onChange: (sel, str) => { State.appFilter.modTo = str || ''; if (fpFrom && sel[0]) fpFrom.set('maxDate', sel[0]); syncClearVisibility(); renderApps(); }, })); btnX.addEventListener('click', () => { fpFrom.clear(); fpTo.clear(); fpFrom.set('maxDate', null); fpTo.set('minDate', null); State.appFilter.modFrom = ''; State.appFilter.modTo = ''; syncClearVisibility(); renderApps(); }); })(); async function loadApps(force, opts = {}) { if (!State.connected) { toast('Bitte zuerst verbinden', 'warn'); return; } if (!opts.silent) setLoading(force ? 'Lade Apps neu (kann Sekunden dauern)...' : 'Lade Apps aus Intune...'); document.getElementById('appList').innerHTML = `
`; try { const url = force ? '/api/apps?refresh=true' : '/api/apps'; const res = await api(url); State.apps = res.items || []; State.appsLoaded = true; if (force) { State.appDetails.clear(); State.expandedApps.clear(); } rebuildCategoryFilterOptions(); renderApps(); updateMembershipForVisibleApps(); if (!opts.silent) toast(`${res.count} Apps ${res.cached ? 'aus Cache' : 'geladen'}`, 'ok'); // Kategorien NACH dem App-Laden im Hintergrund holen (separater $batch im // Backend) — blockiert das App-Laden nicht. Fuellt danach den Kategorie-Filter. loadAppCategoriesInBackground(); } catch (e) { toast(e.message, 'err', 'Apps laden fehlgeschlagen'); renderApps(); } finally { if (!opts.silent) clearLoading(); } } async function loadAppCategoriesInBackground() { // Holt die appId->Kategorien-Map separat (Backend nutzt $batch) und merged // sie in die geladene App-Liste, dann Filter neu aufbauen. Fehler werden // still verschluckt — die App-Liste funktioniert auch ohne Kategorien. try { const res = await api('/api/apps/categories', { timeoutMs: 300000 }); const map = res && res.map ? res.map : {}; let any = false; for (const a of State.apps) { if (Object.prototype.hasOwnProperty.call(map, a.AppId)) { a.Categories = map[a.AppId] || []; any = true; } } if (any) { rebuildCategoryFilterOptions(); // Nur neu rendern wenn ein Kategorie-Filter aktiv ist (sonst unnoetig) if (State.appFilter.category) renderApps(); } } catch (e) { // bewusst still — Kategorien sind optional fuer den Filter console.warn('Kategorien-Hintergrundladen fehlgeschlagen:', e.message); } } function getFilteredApps() { let list = State.apps.slice(); const f = State.appFilter; if (f.search) list = list.filter(a => (a.AppName || '').toLowerCase().includes(f.search)); switch (f.preset) { case 'none': list = list.filter(a => a.AvailableCount === 0 && a.RequiredCount === 0); break; case 'available': list = list.filter(a => a.AvailableCount > 0 && a.RequiredCount === 0); break; case 'required': list = list.filter(a => a.RequiredCount > 0 && a.AvailableCount === 0); break; case 'multipleAvail':list = list.filter(a => a.AvailableCount > 1); break; case 'multipleReq': list = list.filter(a => a.RequiredCount > 1); break; case 'allUsers': list = list.filter(a => a.AvailableGroups.some(g => g.GroupId === 'ALL_USERS') || a.RequiredGroups.some(g => g.GroupId === 'ALL_USERS')); break; case 'allDevices': list = list.filter(a => a.AvailableGroups.some(g => g.GroupId === 'ALL_DEVICES') || a.RequiredGroups.some(g => g.GroupId === 'ALL_DEVICES')); break; case 'srcIntune': list = list.filter(a => a.Source === 'Intune'); break; default: // Generisch "srcVendor:" — vendor.displayName aus Settings-Map ziehen if (typeof f.preset === 'string' && f.preset.startsWith('srcVendor:')) { const vid = f.preset.substring('srcVendor:'.length); const v = (SettingsState.vendorsById || {})[vid]; if (v) list = list.filter(a => a.Source === v.displayName); } break; } // Kategorie-Filter: leerer Wert = alle. Spezialwert '__none__' = Apps ohne // zugewiesene Kategorie. Sonst exakter Match auf einen Kategorie-Namen. if (f.category) { if (f.category === '__none__') { list = list.filter(a => !Array.isArray(a.Categories) || a.Categories.length === 0); } else { list = list.filter(a => Array.isArray(a.Categories) && a.Categories.includes(f.category)); } } if (f.hideNative) { list = list.filter(a => !a.AvailableGroups.some(g => g.IsNative) && !a.RequiredGroups.some(g => g.IsNative)); } if (f.onlyMember) { list = list.filter(a => { const all = [...a.AvailableGroups, ...a.RequiredGroups]; return all.some(g => { const m = getCachedMembership(g.GroupId); return m && (m.status === 'Full' || m.status === 'Partial'); }); }); } // Zeitraum-Filter auf LastModifiedDateTime — von/bis inklusive if (f.modFrom || f.modTo) { const fromMs = f.modFrom ? Date.parse(f.modFrom + 'T00:00:00') : null; // "Bis" inklusiv: bis Ende des Tages (23:59:59.999) const toMs = f.modTo ? Date.parse(f.modTo + 'T23:59:59.999') : null; list = list.filter(a => { if (!a.LastModifiedDateTime) return false; const t = Date.parse(a.LastModifiedDateTime); if (isNaN(t)) return false; if (fromMs !== null && t < fromMs) return false; if (toMs !== null && t > toMs) return false; return true; }); } return list; } function renderApps() { const list = document.getElementById('appList'); const cnt = document.getElementById('appCount'); const total = State.apps.length; const filtered = getFilteredApps(); cnt.textContent = total === filtered.length ? total : `${filtered.length}/${total}`; if (!State.connected) { list.innerHTML = emptyState({ title: 'Nicht verbunden', text: 'Verbinde dich zuerst mit Microsoft Graph.', }); return; } if (State.apps.length === 0) { list.innerHTML = emptyState({ title: 'Apps werden geladen...', text: 'Falls nicht, klicke auf das Refresh-Icon oben rechts.', }); return; } if (filtered.length === 0) { list.innerHTML = emptyState({ title: 'Keine Treffer', text: 'Filter oder Suche zurücksetzen.', }); return; } list.innerHTML = filtered.map(app => renderAppRow(app)).join(''); list.querySelectorAll('.app-row-wrap').forEach(wrap => attachAppRowEvents(wrap)); } function renderAppRow(app) { const sessionPills = State.session.filter(s => s.appId === app.AppId); // Aggregierter Member-Status fuer den Indikator vor App-Name let isFullMember = false, isPartialMember = false; if (State.selectedTargetIds.size > 0) { for (const g of [...app.AvailableGroups, ...app.RequiredGroups]) { if (g.IsNative) continue; const m = getCachedMembership(g.GroupId); if (!m) continue; if (m.status === 'Full') isFullMember = true; if (m.status === 'Partial') isPartialMember = true; } } const statusDot = isFullMember ? `✓` : (isPartialMember ? `◐` : ''); const queuedCount = sessionPills.length; const queuedBadge = queuedCount > 0 ? `${queuedCount}` : ''; // Sammel-Button pro Typ. Klick: // - 1 zuweisbare Gruppe → direkt zu den geplanten Zuweisungen hinzufuegen // - mehrere → Popover mit Auswahl // - nur native → disabled const renderTypeButton = (groups, type) => { if (groups.length === 0) return ''; const assignable = groups.filter(g => !g.IsNative); const queuedForType = sessionPills.filter(s => s.type === type).length; let memberCls = ''; if (State.selectedTargetIds.size > 0) { let full = false, partial = false; for (const g of assignable) { const m = getCachedMembership(g.GroupId); if (!m) continue; if (m.status === 'Full') full = true; if (m.status === 'Partial') partial = true; } if (full) memberCls = 'member-full'; else if (partial) memberCls = 'member-partial'; } const isNativeOnly = assignable.length === 0; const hasQueued = queuedForType > 0; const cls = ['type-btn', `type-${type.toLowerCase()}`, isNativeOnly ? 'native-only' : '', hasQueued ? 'has-queued' : '', memberCls].filter(Boolean).join(' '); const countSuffix = assignable.length > 1 ? `${assignable.length}` : ''; const queuedSuffix = hasQueued ? `${queuedForType}` : ''; let title; if (isNativeOnly) { title = `Nur native Zuweisung (${groups.map(g => g.GroupName).join(', ')}) — nicht änderbar`; } else if (groups.length === 1) { title = `${type}: ${assignable[0].GroupName}\nKlick: zu geplanten Zuweisungen hinzufügen`; } else { title = `${type} (${groups.length} Gruppen) — Klick zum Auswählen:\n • ` + assignable.map(g => g.GroupName).join('\n • '); } return ``; }; const availBtn = renderTypeButton(app.AvailableGroups, 'Available'); const reqBtn = renderTypeButton(app.RequiredGroups, 'Required'); const createBtn = ``; const hasNoAssignments = app.AvailableGroups.length === 0 && app.RequiredGroups.length === 0; // Direkt-Link in das Intune Admin Center fuer diese App (dezentes Icon, NICHT der Name selbst) const intuneIcon = intuneAppLink(app.AppId, app.AppName); // Source-Badge: pro Vendor (aus Settings) ODER Intune-Default. const vendor = (SettingsState.vendorsByDisplayName || {})[app.Source]; const srcLogo = vendor ? `/assets/${encodeURIComponent(vendor.logoFile || 'application.png')}` : '/assets/intune.png'; const srcUrl = vendor ? (vendor.portalUrl || 'https://intune.microsoft.com/') : 'https://intune.microsoft.com/'; const srcTitle = vendor ? `Bereitgestellt durch ${vendor.displayName} — Portal öffnen` : 'Native Intune-App — Intune öffnen'; const srcClass = vendor ? `src-${escapeHtml(vendor.id)}` : 'src-intune'; // Fallback: wenn das Vendor-Logo nicht da ist, zeigen wir den ersten Buchstaben des Vendor-Namens. const srcFallback = vendor ? `this.replaceWith(Object.assign(document.createElement('span'), { className: 'app-source app-source-letter ${srcClass}', textContent: '${escapeHtml(vendor.displayName[0] || '?')}' }))` : ''; const srcBadge = `${srcTitle}`; // Vendor-managed Apps koennen NUR im jeweiligen Portal geloescht/umbenannt // werden. Wir zeigen ein passives Lock-Icon mit Erklaerung statt Trash/Pencil. const deleteLocked = !!(vendor && vendor.block && vendor.block.delete); const renameLocked = !!(vendor && vendor.block && vendor.block.rename); const deleteBtn = deleteLocked ? `${LOCK_ICON}` : ``; const expanded = State.expandedApps.has(app.AppId); const detailHtml = expanded ? renderAppDetailPanel(app.AppId) : ''; return `
${statusDot}
${escapeHtml(app.AppName)} ${queuedBadge} ${renameLocked ? `${LOCK_ICON}` : ``} ${intuneIcon}
${app.Publisher ? `
${escapeHtml(app.Publisher)}
` : ''}
${srcBadge}
${escapeHtml(app.AppType)}
${app.Version ? `${escapeHtml(app.Version)}` : '—'}
${availBtn || '—'}
${reqBtn || '—'}
${createBtn}
${deleteBtn}
${detailHtml}
`; } function renderAssignmentsSection(app) { // Zwei Listen: Available + Required. Native (All Users / All Devices) werden // mitgezeigt aber als nicht-loeschbar markiert. const renderGroupList = (groups, type) => { if (!groups || groups.length === 0) { return `
Keine ${type === 'available' ? 'Available' : 'Required'}-Zuweisungen.
`; } return ``; }; const avail = renderGroupList(app.AvailableGroups, 'available'); const req = renderGroupList(app.RequiredGroups, 'required'); return `
Zuweisungen
Available${app.AvailableGroups.length}
${avail}
Required${app.RequiredGroups.length}
${req}
`; } function renderAppDetailPanel(appId) { const entry = State.appDetails.get(appId); if (!entry || entry.loading) { return `
Lade Details aus Intune...
`; } if (entry.error) { return `
Konnte Details nicht laden: ${escapeHtml(entry.error)}
`; } const d = entry.details || {}; const row = (label, value, opts = {}) => { if (value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) return ''; let v = value; if (Array.isArray(v)) { // Defensiv gegen Backend-Bugs: wenn Items Objekte sind, displayName/name // extrahieren statt "[object Object]" zu rendern. v = v.map(item => { if (item === null || item === undefined) return ''; if (typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') return String(item); if (typeof item === 'object') return item.displayName || item.DisplayName || item.name || item.Name || item.id || ''; return String(item); }).filter(s => s !== '').join(', '); if (!v) return ''; } if (typeof v === 'boolean') v = v ? 'Ja' : 'Nein'; // Letzte Verteidigung: wenn v immer noch ein Objekt ist (single value). if (v && typeof v === 'object') { v = v.displayName || v.DisplayName || v.name || v.Name || v.id || JSON.stringify(v); } const cls = opts.mono ? 'app-detail-val mono' : 'app-detail-val'; const copy = opts.copy ? `` : ''; return `
${escapeHtml(label)}
${escapeHtml(String(v))}${copy}
`; }; const fmtDate = (s) => { if (!s) return null; // Defensiv: new Date() wirft KEINE Exception, gibt aber Invalid Date // zurueck — also explizit pruefen. Auch Legacy "/Date(ms)/" akzeptieren. let d = new Date(s); if (isNaN(d.getTime())) { const m = String(s).match(/\/Date\((-?\d+)/); if (m) d = new Date(Number(m[1])); } if (isNaN(d.getTime())) return null; return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }); }; const sections = []; // Zuweisungen: zuerst — direkt sichtbar nach dem Aufklappen const app = State.apps.find(a => a.AppId === appId); if (app) { sections.push(renderAssignmentsSection(app)); } // Installationen — direkt nach Zuweisungen, damit die operative Kennzahl // sichtbar ist ohne scrollen zu muessen. if (d.InstallSummary) { sections.push(renderInstallStatsSection(d.InstallSummary)); } // Allgemein const general = [ row('Hersteller', d.Publisher), row('Entwickler', d.Developer), row('Owner', d.Owner), row('Version', d.DisplayVersion || d.Version || d.ProductVersion), row('Typ', d.Type), row('Architekturen', d.Architectures), row('Min. OS', d.MinimumOS), row('Kategorien', (Array.isArray(d.Categories) && d.Categories.length > 0) ? d.Categories : 'Default'), row('Install-Kontext', d.InstallExperience), row('Featured', d.IsFeatured), ].filter(Boolean).join(''); if (general) sections.push(`
Allgemein
${general}
`); // Beschreibung if (d.Description || d.Notes) { let desc = ''; if (d.Description) desc += `
${renderMarkdown(d.Description)}
`; if (d.Notes) desc += `
Notizen: ${renderMarkdown(d.Notes)}
`; sections.push(`
Beschreibung
${desc}
`); } // Install / Uninstall const installRows = [ row('Datei', d.FileName, { mono: true }), row('Setup-Pfad', d.SetupFilePath, { mono: true }), row('Install-Befehl', d.InstallCommandLine, { mono: true, copy: true }), row('Uninstall-Befehl', d.UninstallCommandLine, { mono: true, copy: true }), row('Command-Line', d.CommandLine, { mono: true, copy: true }), ].filter(Boolean).join(''); if (installRows) sections.push(`
Installation
${installRows}
`); // MSI if (d.Msi) { const msiRows = [ row('Product Code', d.Msi.ProductCode, { mono: true, copy: true }), row('Upgrade Code', d.Msi.UpgradeCode, { mono: true, copy: true }), row('Product Version', d.Msi.ProductVersion), row('Publisher (MSI)', d.Msi.Publisher), row('Package-Type', d.Msi.PackageType), row('Reboot nötig', d.Msi.RequiresReboot), ].filter(Boolean).join(''); if (msiRows) sections.push(`
MSI
${msiRows}
`); } // Detection-Rules — wide weil Registry-Pfade lang werden koennen if (d.DetectionRules && d.DetectionRules.length) { sections.push(`
Detection
`); } // Return-Codes if (d.ReturnCodes && d.ReturnCodes.length) { const rows = d.ReturnCodes.map(rc => `
  • ${escapeHtml(String(rc.Code))} · ${escapeHtml(rc.Type)}
  • `).join(''); sections.push(`
    Return-Codes
    `); } // Meta const metaRows = [ row('Erstellt', fmtDate(d.CreatedDateTime)), row('Geändert', fmtDate(d.LastModifiedDateTime)), d.InformationUrl ? `
    Info-URL
    ` : '', d.PrivacyInformationUrl ? `
    Privacy
    ` : '', ].filter(Boolean).join(''); if (metaRows) sections.push(`
    Meta
    ${metaRows}
    `); // Dependencies — Voraussetzungen dieser App if (d.Dependencies && d.Dependencies.length) { sections.push(renderRelationshipFlow({ title: 'Abhängigkeiten', subtitle: 'Apps, die installiert sein müssen, bevor diese App installiert werden kann.', centerApp: { AppId: appId, AppName: d.DisplayName || 'Diese App' }, related: d.Dependencies, direction: 'incoming', // Pfeile von dep -> diese App kindLabelFn: (r) => r.DependencyType === 'autoInstall' ? 'autoInstall' : 'detect', explanationFn: (center, r) => { const c = `${escapeHtml(center.AppName)}`; const t = `${escapeHtml(r.TargetDisplayName || r.TargetId)}`; if (r.DependencyType === 'autoInstall') { return `Wenn ${c} einem Gerät zugewiesen wird, installiert Intune zuvor automatisch ${t} mit.`; } return `${c} erwartet, dass ${t} bereits installiert ist. Intune installiert es nicht automatisch — manuelle Bereitstellung nötig (Modus „detect“).`; }, })); } // Supersedence — was diese App ersetzt/aktualisiert if (d.Supersedence && d.Supersedence.length) { sections.push(renderRelationshipFlow({ title: 'Supersedence', subtitle: 'Ältere Apps, die durch diese App ersetzt oder aktualisiert werden.', centerApp: { AppId: appId, AppName: d.DisplayName || 'Diese App' }, related: d.Supersedence, direction: 'outgoing', // Pfeile diese App -> ersetzte App kindLabelFn: (r) => r.SupersedenceType === 'replace' ? 'replace' : 'update', explanationFn: (center, r) => { const c = `${escapeHtml(center.AppName)}`; const t = `${escapeHtml(r.TargetDisplayName || r.TargetId)}`; if (r.SupersedenceType === 'replace') { return `Bei der Installation deinstalliert ${c} eine vorhandene Version von ${t} und ersetzt sie vollständig (Modus „replace“).`; } return `${c} aktualisiert eine vorhandene Installation von ${t} (Modus „update“) — Benutzerdaten und Konfiguration bleiben erhalten.`; }, })); } // Setup-Datei aktualisieren — Phase 1: nur win32LobApp (.intunewin), // und nur fuer NICHT vendor-managed Apps (PMPC/Robopack ausgeschlossen). if (app && app.AppTypeRaw === '#microsoft.graph.win32LobApp' && !(SettingsState.vendorsByDisplayName || {})[app.Source]) { sections.push(`
    Setup-Datei
    Lädt eine neue .intunewin hoch und aktiviert sie als neue Version. Betrifft alle zugewiesenen Geräte beim nächsten Sync.
    `); } if (sections.length === 0) { return `
    Keine zusätzlichen Details verfügbar.
    `; } return `
    ${sections.join('')}
    `; } // ============================================================= // App-Detail: Installations-Statistik + Relationship-Flow-Viz // ============================================================= function renderInstallStatsSection(s) { const pct = (ok, fail) => { const total = ok + fail; if (total === 0) return null; return Math.round((ok / total) * 100); }; const stat = (label, value, kind) => `
    ${value}
    ${escapeHtml(label)}
    `; // Stacked-Balken: Segmente in Reihenfolge ok / err / pend / not / na const stackedBar = (ok, fail, pend, not, na) => { const total = ok + fail + pend + not + na; if (total === 0) return `
    `; const w = (v) => `${Math.max(0, Math.round((v / total) * 1000) / 10)}%`; return `
    ${ok ? `
    ` : ''} ${fail ? `
    ` : ''} ${pend ? `
    ` : ''} ${not ? `
    ` : ''} ${na ? `
    ` : ''}
    `; }; const dev = { ok: s.InstalledDeviceCount || 0, fail: s.FailedDeviceCount || 0, pend: s.PendingInstallDeviceCount || 0, not: s.NotInstalledDeviceCount || 0, na: s.NotApplicableDeviceCount || 0, }; const usr = { ok: s.InstalledUserCount || 0, fail: s.FailedUserCount || 0, pend: s.PendingInstallUserCount || 0, not: s.NotInstalledUserCount || 0, na: s.NotApplicableUserCount || 0, }; const devPct = pct(dev.ok, dev.fail); const usrPct = pct(usr.ok, usr.fail); const axis = (title, d, ratePct) => `
    ${escapeHtml(title)} ${ratePct !== null ? `${ratePct}% erfolgreich` : ''}
    ${stackedBar(d.ok, d.fail, d.pend, d.not, d.na)}
    Installiert Fehler Ausstehend Nicht inst. N/A
    ${stat('Installiert', d.ok, 'ok')} ${stat('Fehler', d.fail, 'err')} ${stat('Ausstehend', d.pend, 'pend')} ${stat('Nicht inst.', d.not, 'not')} ${stat('N/A', d.na, 'na')}
    `; return `
    Installationsübersicht
    ${axis('Geräte', dev, devPct)} ${axis('Benutzer', usr, usrPct)}
    `; } // Mini-SVG-Flow: zentraler App-Knoten + verbundene Target-Knoten. // direction: 'incoming' = Pfeile target->center (Dependencies) // 'outgoing' = Pfeile center->target (Supersedence) // Verbindungen sind ORTHOGONAL (gerade Linien mit rechtwinkligen Knicken), // keine Bezier-Kurven. Pro Relation wird zusaetzlich ein Klartext-Satz // generiert (explanationFn), der direkt unter dem Diagramm steht. function renderRelationshipFlow(opts) { const { title, subtitle, centerApp, related, direction, kindLabelFn, explanationFn } = opts; if (!related || related.length === 0) return ''; const flowId = 'flow-' + Math.random().toString(36).slice(2, 9); // Layout-Geometrie — etwas grosszuegiger als vorher fuer "premium" Wirkung const NODE_W = 230; const NODE_H = 60; const ROW_H = 84; // Vertikaler Abstand zwischen Target-Nodes const COL_GAP = 170; // Horizontaler Platz fuer die Pfeil-Linie const PAD = 16; const count = related.length; // Hoehe so dass Center und Target-Stack vertikal gleich hoch sind const stackH = (count - 1) * ROW_H + NODE_H; const svgH = Math.max(stackH, NODE_H) + PAD * 2; const svgW = NODE_W * 2 + COL_GAP + PAD * 2; // Center vertikal mittig const centerY = (svgH - NODE_H) / 2; // Center ist je nach Richtung links oder rechts const centerOnLeft = (direction === 'outgoing'); const centerX = centerOnLeft ? PAD : (svgW - PAD - NODE_W); const targetX = centerOnLeft ? (svgW - PAD - NODE_W) : PAD; // Target-Y so verteilen dass Stack vertikal zentriert ist const stackTop = (svgH - stackH) / 2; const targetY = (i) => stackTop + i * ROW_H; const knownApp = (id) => State.apps.find(a => a.AppId === id); // Node-Renderer als HTML-Foreign-Object (Text-Wrapping einfacher als in SVG) const nodeHtml = (a, isCenter) => { const known = !!knownApp(a.AppId || a.TargetId); const id = a.AppId || a.TargetId || ''; const name = escapeHtml(a.AppName || a.TargetDisplayName || id); const meta = isCenter ? '' : [a.TargetPublisher, a.TargetDisplayVersion].filter(Boolean).map(escapeHtml).join(' · '); const cls = `flow-node ${isCenter ? 'flow-node-center' : 'flow-node-target'} ${known && !isCenter ? 'flow-node-known' : ''}`; return `
    ${name}
    ${meta ? `
    ${meta}
    ` : ''}
    `; }; // Orthogonale Pfade: horizontal → vertikal → horizontal (Elbow). // Wenn from-Y == to-Y degeneriert das automatisch zu einer geraden Linie. const arrows = related.map((r, i) => { const ty = targetY(i); const cyMid = centerY + NODE_H / 2; const tyMid = ty + NODE_H / 2; const fromX = centerOnLeft ? (centerX + NODE_W) : (targetX + NODE_W); const fromY = centerOnLeft ? cyMid : tyMid; const toX = centerOnLeft ? targetX : centerX; const toY = centerOnLeft ? tyMid : cyMid; const midX = (fromX + toX) / 2; const d = `M ${fromX} ${fromY} L ${midX} ${fromY} L ${midX} ${toY} L ${toX} ${toY}`; const labelX = midX; const labelY = (fromY + toY) / 2 - 6; const kind = kindLabelFn ? kindLabelFn(r) : ''; return ` ${kind ? `${escapeHtml(kind)}` : ''}`; }).join(''); // Target-Nodes (HTML overlay via foreignObject) const targetNodesSvg = related.map((r, i) => { const y = targetY(i); return `
    ${nodeHtml(r, false)}
    `; }).join(''); // Klartext-Erklaerungen pro Relation const explanations = explanationFn ? related.map(r => explanationFn(centerApp, r)).filter(Boolean) : []; const explanationHtml = explanations.length ? `
    Was bedeutet das?
      ${explanations.map(e => `
    • ${e}
    • `).join('')}
    ` : ''; return `
    ${escapeHtml(title)}
    ${escapeHtml(subtitle)}
    ${arrows}
    ${nodeHtml(centerApp, true)}
    ${targetNodesSvg}
    ${explanationHtml}
    `; } // Klick auf einen Flow-Node: zur Ziel-App scrollen + expanden function attachFlowNodeHandlers(wrap) { wrap.querySelectorAll('[data-jump-app]').forEach(el => { el.addEventListener('click', () => jumpToAppById(el.dataset.jumpApp)); el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); jumpToAppById(el.dataset.jumpApp); } }); }); } async function jumpToAppById(appId) { if (!appId) return; // Suchfeld leeren damit die App garantiert sichtbar ist const search = document.getElementById('appSearch'); if (search && search.value) { search.value = ''; State.appFilter.search = ''; } // WICHTIG: Wenn die App noch nicht expanded ist, muessen wir den DETAILS- // FETCH triggern. Frueher haben wir nur expandedApps.add() gemacht — ohne // Fetch — und der Spinner drehte ewig. Wenn der User dann die Zeile manuell // klickt, sieht toggleAppExpand "schon offen" und KLAPPT WIEDER ZU. const alreadyOpen = State.expandedApps.has(appId); const wasCached = State.appDetails.has(appId); if (!alreadyOpen) { State.expandedApps.add(appId); } renderApps(); // Fetch nur ausloesen wenn keine validen Daten im Cache liegen const cached = State.appDetails.get(appId); if (!cached || (!cached.details && !cached.loading)) { State.appDetails.set(appId, { details: null, loading: true, error: null }); renderApps(); try { const res = await api(`/api/apps/${encodeURIComponent(appId)}/details`); State.appDetails.set(appId, { details: res, loading: false, error: null }); } catch (e) { State.appDetails.set(appId, { details: null, loading: false, error: e.message }); } if (State.expandedApps.has(appId)) renderApps(); } // Im naechsten Frame: in den View scrollen requestAnimationFrame(() => { const wrap = document.querySelector(`.app-row-wrap[data-app-id="${CSS.escape(appId)}"]`); if (wrap) { wrap.scrollIntoView({ behavior: 'smooth', block: 'center' }); wrap.classList.add('app-row-wrap--flash'); setTimeout(() => wrap.classList.remove('app-row-wrap--flash'), 1200); } else { toast('Ziel-App nicht in der aktuell geladenen Liste', 'warn'); } }); } async function toggleAppExpand(appId) { if (State.expandedApps.has(appId)) { State.expandedApps.delete(appId); renderApps(); return; } State.expandedApps.add(appId); // Bereits geladene oder fertig im Cache → kein Reload const cached = State.appDetails.get(appId); if (cached && !cached.loading && !cached.error && cached.details) { renderApps(); return; } State.appDetails.set(appId, { details: null, loading: true, error: null }); renderApps(); try { // 45s Timeout — falls Backend haengt, bricht der Spinner ab und der User // bekommt eine Fehlermeldung statt ewig zu warten. const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 45000); const opts = { method: 'GET', headers: { 'Content-Type': 'application/json' }, signal: ctrl.signal }; const r = await fetch(`/api/apps/${encodeURIComponent(appId)}/details`, opts); clearTimeout(timer); let res = {}; try { res = await r.json(); } catch {} if (!r.ok) throw new Error(res.error || `HTTP ${r.status}`); State.appDetails.set(appId, { details: res, loading: false, error: null }); } catch (e) { const msg = e.name === 'AbortError' ? 'Timeout (45s) — Graph-API antwortet nicht' : e.message; State.appDetails.set(appId, { details: null, loading: false, error: msg }); } if (State.expandedApps.has(appId)) renderApps(); } function attachAppRowEvents(wrap) { wrap.querySelectorAll('.type-btn[data-type]').forEach(btn => { btn.addEventListener('click', e => { if (btn.disabled) return; e.stopPropagation(); onTypeButtonClick(btn); }); }); wrap.querySelectorAll('[data-create-group]').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); openCreateGroupModal(btn.dataset.appId, btn.dataset.appName); }); }); wrap.querySelectorAll('[data-delete-app]').forEach(btn => { btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); deleteAppWithConfirm(btn.dataset.appId, btn.dataset.appName); }); }); wrap.querySelectorAll('[data-rename-app]').forEach(btn => { btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); openRenameAppModal(btn.dataset.appId, btn.dataset.appName); }); }); wrap.querySelectorAll('[data-update-content]').forEach(btn => { btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); openUpdateContentModal(btn.dataset.appId, btn.dataset.appName, btn.dataset.appType, btn.dataset.appVersion); }); }); // Im Detail-Panel (Zuweisungen-Sektion) sitzen die Delete-Assignment-Buttons // und externe Link-Icons — hier auch verkabeln, damit Klicks weder zur // Session togglen noch das Detail-Panel zuklappen. wrap.querySelectorAll('[data-remove-assign]').forEach(btn => { btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); removeAssignmentWithConfirm({ appId: btn.dataset.appId, appName: btn.dataset.appName, groupId: btn.dataset.groupId, groupName: btn.dataset.groupName, type: btn.dataset.type, }); }); }); wrap.querySelectorAll('.asg-actions, .asg-row .entra-link').forEach(el => { el.addEventListener('click', e => e.stopPropagation()); }); // Flow-Nodes (Dependencies / Supersedence) — Klicks fuehren zur Target-App attachFlowNodeHandlers(wrap); // Klicks innerhalb der Flow-Container sollen nicht den Row-Toggle ausloesen wrap.querySelectorAll('.flow-container').forEach(el => { el.addEventListener('click', e => e.stopPropagation()); }); // Klick auf Zeile = Detail-Toggle. Klicks auf interne Buttons/Links sind oben // bereits mit stopPropagation versehen oder triggern via kein Bubble-Toggle. const rowEl = wrap.querySelector('.app-row'); if (rowEl) { rowEl.addEventListener('click', e => { // Direkte Links (App-Name in Intune oeffnen, Source-Badge) nicht abfangen if (e.target.closest('a')) return; // Buttons innerhalb der Zeile (Type-Btn, Create-Btn) haben ihre eigenen Handler if (e.target.closest('button')) return; const id = rowEl.dataset.appId; if (id) toggleAppExpand(id); }); } // Copy-Buttons im Detail-Panel wrap.querySelectorAll('.app-detail-copy').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const txt = btn.dataset.copy || ''; navigator.clipboard.writeText(txt).then( () => { btn.classList.add('copied'); setTimeout(() => btn.classList.remove('copied'), 900); toast('In Zwischenablage kopiert', 'ok'); }, () => toast('Kopieren fehlgeschlagen', 'err') ); }); }); } function onTypeButtonClick(btnEl) { const appId = btnEl.dataset.appId; const type = btnEl.dataset.type; const app = State.apps.find(a => a.AppId === appId); if (!app) return; const groups = (type === 'Available' ? app.AvailableGroups : app.RequiredGroups).filter(g => !g.IsNative); if (groups.length === 0) return; if (groups.length === 1) { const g = groups[0]; toggleSession({ appId, groupId: g.GroupId, groupName: g.GroupName, type }); return; } // Mehrere Gruppen → Popover mit Auswahl showGroupPicker(btnEl, app, groups, type); } let pickerEl = null; function closeGroupPicker() { if (pickerEl) { pickerEl.remove(); pickerEl = null; } document.removeEventListener('click', onPickerOutsideClick, true); document.removeEventListener('keydown', onPickerEscape); } function onPickerOutsideClick(e) { if (pickerEl && !pickerEl.contains(e.target)) closeGroupPicker(); } function onPickerEscape(e) { if (e.key === 'Escape') closeGroupPicker(); } function showGroupPicker(anchorEl, app, groups, type) { closeGroupPicker(); const queuedSet = new Set( State.session.filter(s => s.appId === app.AppId && s.type === type).map(s => s.groupId) ); pickerEl = document.createElement('div'); pickerEl.className = `group-picker picker-${type.toLowerCase()}`; pickerEl.innerHTML = `
    ${escapeHtml(type)} — Gruppe wählen
    ${groups.map(g => { const queued = queuedSet.has(g.GroupId); const memb = getCachedMembership(g.GroupId); let mLabel = ''; if (memb) { if (memb.status === 'Full') mLabel = `Mitglied`; else if (memb.status === 'Partial') mLabel = `${memb.matched}/${memb.total}`; } const sideActions = ` ${entraGroupLink(g.GroupId, g.GroupName)} ${membersTabLink(g.GroupId, g.GroupName)} `; return `
    ${sideActions}
    `; }).join('')}
    `; document.body.appendChild(pickerEl); // Position relativ zum Button const r = anchorEl.getBoundingClientRect(); const pw = pickerEl.offsetWidth; const ph = pickerEl.offsetHeight; let left = r.left; let top = r.bottom + 4; if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8; if (top + ph > window.innerHeight - 8) top = r.top - ph - 4; pickerEl.style.left = left + 'px'; pickerEl.style.top = top + 'px'; pickerEl.querySelectorAll('.picker-item').forEach(item => { item.addEventListener('click', e => { e.stopPropagation(); toggleSession({ appId: app.AppId, groupId: item.dataset.groupId, groupName: item.dataset.groupName, type, }); closeGroupPicker(); }); }); // Side-Actions (Entra-Link, Members-Tab) duerfen den Picker nicht schliessen // bzw. die Gruppe nicht zur Session togglen. Links lassen wir natuerlich oeffnen. pickerEl.querySelectorAll('[data-stop]').forEach(box => { box.addEventListener('click', e => e.stopPropagation()); }); // Zuweisung entfernen pickerEl.querySelectorAll('[data-remove-assign]').forEach(btn => { btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); const ctx = { appId: btn.dataset.appId, appName: btn.dataset.appName, groupId: btn.dataset.groupId, groupName: btn.dataset.groupName, type: btn.dataset.type, }; closeGroupPicker(); removeAssignmentWithConfirm(ctx); }); }); // Click outside / Escape schliessen setTimeout(() => { document.addEventListener('click', onPickerOutsideClick, true); document.addEventListener('keydown', onPickerEscape); }, 0); } // ============================================================= // Membership // ============================================================= function getCachedMembership(groupId) { if (State.selectedTargetIds.size === 0) return null; return State.membershipCache.get(membershipKey(groupId)); } function membershipKey(groupId) { const ids = [...State.selectedTargetIds].sort().join(','); return groupId + '|' + ids; } let membershipAbort = null; function abortMembershipFetch() { if (membershipAbort) { try { membershipAbort.abort(); } catch {} membershipAbort = null; } } async function updateMembershipForVisibleApps() { if (State.selectedTargetIds.size === 0) return; const visible = getFilteredApps(); const groupIds = new Set(); visible.forEach(a => { a.AvailableGroups.concat(a.RequiredGroups).forEach(g => { if (!g.IsNative && !State.membershipCache.has(membershipKey(g.GroupId))) { groupIds.add(g.GroupId); } }); }); if (groupIds.size === 0) return; // Vorherige Bulk-Anfrage abbrechen wenn neue Ziele/Apps abortMembershipFetch(); membershipAbort = new AbortController(); const targets = [...State.selectedTargetIds]; const groupIdsArr = [...groupIds]; try { const resp = await fetch('/api/membership/bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targets, groupIds: groupIdsArr }), signal: membershipAbort.signal, }); if (!resp.ok) return; const data = await resp.json(); if (!data.memberships) return; for (const [gid, info] of Object.entries(data.memberships)) { State.membershipCache.set(membershipKey(gid), info); } renderApps(); renderExisting(); } catch (e) { if (e.name === 'AbortError') return; // Stille — Mitgliedschaft ist Hintergrund-Info } } // ============================================================= // Session (Cart) // ============================================================= document.getElementById('btnSessionClear').addEventListener('click', () => { if (State.session.length === 0) return; State.session = []; renderSession(); renderApps(); updateStepper(); }); document.getElementById('btnApply').addEventListener('click', applyAssignments); function toggleSession({ appId, groupId, groupName, type }) { // Eine Session entspricht GENAU EINEM Empfaenger (Gruppe ODER User). // Toggle: existieren bereits Sessions fuer diese App+Intune-Group+Type // mit den aktuell gewaehlten Empfaengern, werden diese entfernt. // Andernfalls wird pro Empfaenger eine eigene Session angelegt. const allSel = getAllSelected(); const matching = State.session.filter(s => s.appId === appId && s.groupId === groupId && s.type === type); if (matching.length > 0 && allSel.length === 0) { // Keine Auswahl → toggle = entfernen aller Eintraege fuer diese App-Group-Combo State.session = State.session.filter(s => !matching.includes(s)); renderSession(); renderApps(); updateStepper(); return; } if (allSel.length === 0) { toast('Wähle erst Abteilungen oder Benutzer aus', 'warn'); return; } // Pruefen, ob fuer alle aktuellen Empfaenger schon Sessions existieren // → dann komplett entfernen (Toggle aus). const matchingByTarget = new Map(matching.map(s => [s.scope?.[0]?.id, s])); const allCovered = allSel.every(t => matchingByTarget.has(t.Id)); if (allCovered && matching.length === allSel.length) { State.session = State.session.filter(s => !matching.includes(s)); renderSession(); renderApps(); updateStepper(); return; } // Sonst: fuer jeden Empfaenger, der noch keine Session hat, eine neue anlegen const app = State.apps.find(a => a.AppId === appId); for (const t of allSel) { if (matchingByTarget.has(t.Id)) continue; State.session.push({ id: crypto.randomUUID(), appId, appName: app ? app.AppName : '?', appType: app ? app.AppType : '', groupId, groupName, type, scope: [{ id: t.Id, name: t.DisplayName, mode: t._mode }], }); } renderSession(); renderApps(); updateStepper(); } // Helfer: gesammelte Auswahl ueber ALLE Modi (current + cached) function collectSelectedByMode(mode) { if (State.mode === mode) { return State.targets.filter(t => State.selectedTargetIds.has(t.Id)); } const cached = State.modeCache.get(mode); if (!cached) return []; return cached.targets.filter(t => cached.selectedIds.has(t.Id)); } function getAllSelected() { const out = [ ...collectSelectedByMode('dept').map(t => ({ ...t, _mode: 'dept' })), ...collectSelectedByMode('rpa').map(t => ({ ...t, _mode: 'rpa' })), ...collectSelectedByMode('user').map(t => ({ ...t, _mode: 'user' })), ]; // Auch in aufgeklappten Gruppen markierte User mit aufnehmen — der User // erwartet, dass das Anhaken sofort wirkt, ohne erst "Benutzer uebernehmen" // klicken zu muessen. Dedupliziert ueber Id, damit ein bereits committed // User nicht doppelt erscheint. const seen = new Set(out.map(t => t.Id)); const collectExpand = (expandedMap) => { if (!expandedMap) return; for (const entry of expandedMap.values()) { if (!entry || !entry.selected || !entry.members) continue; const byId = new Map(entry.members.map(m => [m.Id, m])); for (const id of entry.selected) { if (seen.has(id)) continue; const m = byId.get(id); if (!m) continue; seen.add(id); out.push({ ...m, _mode: 'user' }); } } }; collectExpand(State.expandedGroups); for (const m of ['dept','rpa','user']) { if (m === State.mode) continue; const c = State.modeCache.get(m); if (c) collectExpand(c.expandedGroups); } return out; } function renderSession() { const list = document.getElementById('sessionList'); const cnt = document.getElementById('sessionCount'); const btnApply = document.getElementById('btnApply'); const btnApplyLabel = document.getElementById('btnApplyLabel'); const btnClear = document.getElementById('btnSessionClear'); cnt.textContent = State.session.length; // Aktuelle Auswahl (kann von Session-Scopes abweichen!) // Auch "noch nicht uebernommene" Einzelmitglieder aus aufgeklappten Gruppen // mitzaehlen — der User erwartet sofortiges Feedback beim Anhaken. const currentDept = collectSelectedByMode('dept').length + collectSelectedByMode('rpa').length; const userIds = new Set(collectSelectedByMode('user').map(t => t.Id)); const collectExpandSelected = (expandedMap) => { if (!expandedMap) return; for (const entry of expandedMap.values()) { if (entry && entry.selected) { for (const id of entry.selected) userIds.add(id); } } }; collectExpandSelected(State.expandedGroups); for (const m of ['dept','rpa','user']) { if (m === State.mode) continue; const c = State.modeCache.get(m); if (c) collectExpandSelected(c.expandedGroups); } const currentUser = userIds.size; // Tatsaechliche Vorgaenge aus den eingefrorenen Per-Item-Scopes let groupOps = 0, userOps = 0; for (const s of State.session) { if (!s.scope) continue; for (const sc of s.scope) { if (sc.mode === 'user') { userOps++; } else { groupOps++; } } } const total = groupOps + userOps; const sessions = State.session.length; document.getElementById('sumGroupsSel').textContent = currentDept; document.getElementById('sumUsersSel').textContent = currentUser; document.getElementById('sumGroupsOps').textContent = groupOps; document.getElementById('sumUsersOps').textContent = userOps; document.getElementById('sumTotal').textContent = total; btnApply.disabled = (sessions === 0 || total === 0); btnApplyLabel.textContent = total > 0 ? `${total} Vorgang${total === 1 ? '' : 'e'} ausführen` : 'Zuweisungen ausführen'; btnClear.hidden = sessions === 0; if (State.session.length === 0) { list.innerHTML = emptyState({ small: true, title: 'Keine geplanten Zuweisungen', text: 'Klicke auf "Available" oder "Required" bei einer App um eine Zuweisung hinzuzufügen.', }); return; } list.innerHTML = State.session.map(s => { const scope = s.scope || []; // Eine Session = genau ein Empfaenger. Falls aus Altdaten noch ein Multi- // Scope existiert, fallen wir auf eine kompakte Anzeige zurueck. const single = scope.length === 1 ? scope[0] : null; let scopeBlock = ''; if (single) { const isUser = single.mode === 'user'; const tagCls = isUser ? 'scope-user' : 'scope-dept'; const tagLbl = isUser ? '1 Benutzer' : '1 Gruppe'; scopeBlock = `
    ${tagLbl} ${escapeHtml(single.name)}
    `; } else if (scope.length > 1) { const deptN = scope.filter(x => x.mode !== 'user').length; const userN = scope.filter(x => x.mode === 'user').length; const tags = []; if (deptN > 0) tags.push(`${deptN} Gruppe${deptN === 1 ? '' : 'n'}`); if (userN > 0) tags.push(`${userN} Benutzer`); const tip = scope.map(x => x.name).join('\n'); scopeBlock = `
    ${tags.join('')}
    `; } return `
    ${escapeHtml(s.appName)}
    ${escapeHtml(s.groupName)}
    ${scopeBlock}
    ${s.type === 'Available' ? 'A' : 'R'}
    `; }).join(''); list.querySelectorAll('.session-remove').forEach(b => { b.addEventListener('click', () => { State.session = State.session.filter(x => x.id !== b.dataset.id); renderSession(); renderApps(); updateStepper(); }); }); } // ============================================================= // Existing assignments // ============================================================= function renderExisting() { const list = document.getElementById('existingList'); if (State.selectedTargetIds.size === 0 || State.apps.length === 0) { list.innerHTML = emptyState({ small: true, title: 'Keine Auswahl', text: 'Wähle Empfänger und lade Apps, um Mitgliedschaften zu sehen.', }); return; } const items = []; for (const app of State.apps) { const flags = []; for (const g of app.AvailableGroups) { if (g.IsNative) { flags.push({ cls: 'n', label: 'A·N' }); continue; } const m = getCachedMembership(g.GroupId); if (m && (m.status === 'Full' || m.status === 'Partial')) { flags.push({ cls: 'a', label: 'A' + (m.status === 'Partial' ? '·◐' : '·✓') }); } } for (const g of app.RequiredGroups) { if (g.IsNative) { flags.push({ cls: 'n', label: 'R·N' }); continue; } const m = getCachedMembership(g.GroupId); if (m && (m.status === 'Full' || m.status === 'Partial')) { flags.push({ cls: 'r', label: 'R' + (m.status === 'Partial' ? '·◐' : '·✓') }); } } if (flags.length > 0) items.push({ app, flags }); } if (items.length === 0) { list.innerHTML = emptyState({ small: true, title: 'Nichts zugewiesen', text: 'Keine bestehenden Zuweisungen für die Auswahl.', }); return; } list.innerHTML = items.map(({ app, flags }) => `
    ${escapeHtml(app.AppName)}
    ${flags.map(f => `${escapeHtml(f.label)}`).join('')}
    `).join(''); } // ============================================================= // Side tabs (Cart / Existing) // ============================================================= document.querySelectorAll('.side-tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.side-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); State.sideTab = tab.dataset.side; document.getElementById('sidePaneSession').classList.toggle('hidden', State.sideTab !== 'session'); document.getElementById('sidePaneExisting').classList.toggle('hidden', State.sideTab !== 'existing'); }); }); // ============================================================= // Apply // ============================================================= async function applyAssignments() { if (!State.connected) { toast('Bitte zuerst verbinden', 'warn'); return; } if (State.session.length === 0) { toast('Keine geplanten Zuweisungen', 'warn'); return; } // Flache Ops-Liste aus den Per-Item-Snapshots bauen const ops = []; let deptOps = 0, userOps = 0; for (const s of State.session) { if (!s.scope || s.scope.length === 0) continue; for (const sc of s.scope) { ops.push({ targetId: sc.id, targetName: sc.name, targetType: sc.mode, appId: s.appId, appName: s.appName, groupId: s.groupId, groupName: s.groupName, type: s.type, }); if (sc.mode === 'user') userOps++; else deptOps++; } } const total = ops.length; if (total === 0) { toast('Keine ausführbaren Vorgänge', 'warn'); return; } document.getElementById('confirmText').innerHTML = ` ${total} Vorgang${total === 1 ? '' : 'e'} wird ausgeführt:
      ${deptOps > 0 ? `
    • ${deptOps} auf Abteilungsgruppen
    • ` : ''} ${userOps > 0 ? `
    • ${userOps} auf Einzelbenutzer
    • ` : ''}
    `; openModal('modalConfirm'); // wait for confirm or cancel via promise const userConfirmed = await new Promise(resolve => { const ok = document.getElementById('btnConfirmApply'); const modal = document.getElementById('modalConfirm'); function cleanup(value) { ok.removeEventListener('click', onOk); modal.querySelectorAll('[data-close]').forEach(x => x.removeEventListener('click', onCancel)); resolve(value); } function onOk() { closeModal('modalConfirm'); cleanup(true); } function onCancel() { cleanup(false); } ok.addEventListener('click', onOk); modal.querySelectorAll('[data-close]').forEach(x => x.addEventListener('click', onCancel)); }); if (!userConfirmed) return; // Laufende Hintergrund-Anfragen abbrechen damit der Apply nicht hinten in der Queue hängt abortMembershipFetch(); setLoading(`Führe ${total} Vorgang${total === 1 ? '' : 'e'} aus...`); // 60s-Timeout — sonst hängt die UI ewig wenn das Backend stirbt const ctrl = new AbortController(); const timeoutId = setTimeout(() => ctrl.abort(), 60000); try { const resp = await fetch('/api/assignments/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ops }), signal: ctrl.signal, }); clearTimeout(timeoutId); let res = {}; try { res = await resp.json(); } catch {} if (!resp.ok) throw new Error(res.error || `HTTP ${resp.status}`); // Defensiv: falls Backend versehentlich Array (mit Pipeline-Junk + Response) liefert, // nimm das letzte Hashtable-Element if (Array.isArray(res)) { res = res.filter(x => x && typeof x === 'object').pop() || {}; } showResult(res); toast(`${res.success} OK · ${res.errors} Fehler · ${res.skipped} Skip`, res.errors > 0 ? 'warn' : 'ok'); State.session = []; State.membershipCache.clear(); renderSession(); renderApps(); renderExisting(); updateMembershipForVisibleApps(); updateStepper(); } catch (e) { clearTimeout(timeoutId); if (e.name === 'AbortError') { // Liveness-Check: lebt der Server noch? try { const ping = await fetch('/api/ping').then(r => r.json()); toast(`Apply-Timeout nach 60s. Server lebt (Ping ${ping.time}). Apply hängt im Backend — siehe PowerShell-Konsole.`, 'err', 'Timeout'); } catch { toast('Apply-Timeout nach 60s. Server reagiert nicht — PowerShell-Konsole prüfen oder Server neu starten.', 'err', 'Server tot?'); } } else { toast(e.message, 'err', 'Apply fehlgeschlagen'); } } finally { clearLoading(); } } function showResult(res) { const success = Number(res.success) || 0; const skipped = Number(res.skipped) || 0; const errors = Number(res.errors) || 0; const total = Number(res.total) || 0; const details = Array.isArray(res.details) ? res.details : []; document.getElementById('resOk').textContent = success; document.getElementById('resSkip').textContent = skipped; document.getElementById('resErr').textContent = errors; document.getElementById('resTotal').textContent = total; // Status-Banner const banner = document.getElementById('resBanner'); let bannerCls = 'res-banner-ok'; let bannerIco = '✓'; let bannerText = `${success} Vorgang${success === 1 ? '' : 'e'} erfolgreich abgeschlossen`; if (errors > 0) { bannerCls = 'res-banner-err'; bannerIco = '!'; bannerText = `${errors} von ${total} Vorgang${total === 1 ? '' : 'en'} fehlgeschlagen`; } else if (success === 0 && skipped > 0) { bannerCls = 'res-banner-warn'; bannerIco = '·'; bannerText = `${skipped} Vorgang${skipped === 1 ? '' : 'e'} übersprungen (nichts zu tun)`; } banner.className = 'res-banner ' + bannerCls; banner.innerHTML = `${bannerIco}${escapeHtml(bannerText)}`; // Details-Liste const det = document.getElementById('resDetails'); if (details.length === 0) { det.innerHTML = ''; } else { const rows = details.map(d => { const status = String(d.status || ''); const cls = status === 'Success' || status === 'AlreadyMember' ? 'ok' : status === 'Skipped' ? 'warn' : status === 'Error' ? 'err' : ''; const sym = cls === 'ok' ? '✓' : cls === 'warn' ? '·' : '✕'; return `
    ${sym}
    ${escapeHtml(d.target || '')} → ${escapeHtml(d.app || '')}
    ${escapeHtml(d.group || '')} ${escapeHtml(d.type || '')}
    ${d.message ? `
    ${escapeHtml(d.message)}
    ` : ''}
    `; }).join(''); det.innerHTML = `
    Details (${details.length})
    ${rows}
    `; } // Report-Link als prominenter Button const link = document.getElementById('resReportLink'); if (res.reportUrl) { link.innerHTML = `
    HTML-Report öffnen `; } else { link.innerHTML = `
    ⚠ Kein Report-URL in der Response — siehe Debug unten
    `; } // Debug-Sektion mit der RAW Response — damit wir sehen was tatsaechlich ankommt const dbg = document.getElementById('resDebug'); if (dbg) { const raw = JSON.stringify(res, null, 2); dbg.innerHTML = `
    Raw Output${res.build ? ' (Backend-Build: ' + escapeHtml(res.build) + ')' : ' (kein build-Stamp — Backend wurde nicht neu gestartet!)'}
    ${escapeHtml(raw)}
    `; } console.log('[APPLY] Response:', res); openModal('modalResult'); } // ============================================================= // Modals // ============================================================= function openModal(id) { document.getElementById(id).classList.remove('hidden'); } function closeModal(id) { document.getElementById(id).classList.add('hidden'); } document.querySelectorAll('[data-close]').forEach(el => { el.addEventListener('click', () => { const modal = el.closest('.modal'); if (modal) modal.classList.add('hidden'); }); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') { document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden')); } }); // ============================================================= // Create Required Group Modal // ============================================================= let createGroupContext = null; let cgCheckTimer = null; // Naming-Schemas aus Settings holen. Fallback auf Defaults wenn Settings noch // nicht geladen sind (z.B. waehrend des allerersten Init-Renders). function getNamingFor(intent) { const s = SettingsState && SettingsState.current; const block = intent === 'available' ? (s && s.availableGroupNaming) : (s && s.requiredGroupNaming); const defPrefix = 'intune-win-app-'; const defSuffix = intent === 'available' ? '-available' : '-required'; return { prefix: (block && block.prefix) || defPrefix, suffix: (block && block.suffix) || defSuffix, }; } function appSlug(appName) { return appName.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '').toLowerCase(); } function suggestedGroupName(appName, intent) { const { prefix, suffix } = getNamingFor(intent); return `${prefix}${appSlug(appName)}${suffix}`; } function getCurrentCgMode() { const el = document.querySelector('input[name="cgMode"]:checked'); return el ? el.value : 'req-group'; } // Modus -> { intent, isGroup, target } — single source of truth function cgModeMeta(mode) { switch (mode) { case 'req-group': return { intent: 'required', isGroup: true, target: null }; case 'req-allusers': return { intent: 'required', isGroup: false, target: 'ALL_USERS' }; case 'req-alldevices': return { intent: 'required', isGroup: false, target: 'ALL_DEVICES' }; case 'avail-group': return { intent: 'available', isGroup: true, target: null }; case 'avail-allusers': return { intent: 'available', isGroup: false, target: 'ALL_USERS' }; case 'avail-alldevices':return { intent: 'available', isGroup: false, target: 'ALL_DEVICES' }; } return { intent: 'required', isGroup: true, target: null }; } function openCreateGroupModal(appId, appName) { createGroupContext = { appId, appName }; document.getElementById('cgAppName').value = appName; // Bereits zugewiesene Native-Targets aus dem App-Cache ermitteln. // ALL_USERS und ALL_DEVICES koennen je Intent nur EINMAL existieren — // wenn schon da, Option deaktivieren statt einen 409 vom Backend zu kassieren. const app = State.apps.find(a => a.AppId === appId); const has = (arr, gid) => !!(app && arr && arr.some(g => g.GroupId === gid)); const occupied = { 'req-allusers': has(app && app.RequiredGroups, 'ALL_USERS'), 'req-alldevices': has(app && app.RequiredGroups, 'ALL_DEVICES'), 'avail-allusers': has(app && app.AvailableGroups, 'ALL_USERS'), 'avail-alldevices': has(app && app.AvailableGroups, 'ALL_DEVICES'), }; document.querySelectorAll('input[name="cgMode"]').forEach(radio => { const isOccupied = !!occupied[radio.value]; radio.disabled = isOccupied; radio.checked = false; // Reset jeder Auswahl, Default unten gesetzt const card = radio.closest('.cg-mode'); if (!card) return; card.classList.toggle('cg-mode--disabled', isOccupied); const desc = card.querySelector('.cg-mode-desc'); if (desc) { // Original-Description am ersten Aufruf merken, sonst wuerde wiederholtes // Oeffnen den Text dauerhaft auf "bereits zugewiesen" pinnen. if (!desc.dataset.origDesc) desc.dataset.origDesc = desc.textContent; desc.textContent = isOccupied ? 'Bereits zugewiesen — keine zweite Zuweisung möglich.' : desc.dataset.origDesc; } }); // Default-Modus: erste verfuegbare Option (in der Reihenfolge: req-group ist // immer da, fallback auf naechstes). Garantiert konsistente Vorauswahl. const order = ['req-group','req-allusers','req-alldevices','avail-group','avail-allusers','avail-alldevices']; for (const val of order) { if (!occupied[val]) { const r = document.querySelector(`input[name="cgMode"][value="${val}"]`); if (r) { r.checked = true; break; } } } document.getElementById('cgAssignToApp').checked = true; applyCgModeUi(); openModal('modalCreateGroup'); } // Zeigt/versteckt das Namens-Input + Vorschlag je nach Modus. // All-Users/All-Devices brauchen weder Name noch Validierung. function applyCgModeUi() { const mode = getCurrentCgMode(); const meta = cgModeMeta(mode); const nameGroup = document.getElementById('cgGroupNameGroup'); const assignWrap = document.getElementById('cgAssignToAppWrap'); const createBtn = document.getElementById('cgCreate'); const intentLabel = meta.intent === 'available' ? 'Available' : 'Required'; if (meta.isGroup) { nameGroup.hidden = false; assignWrap.hidden = false; document.getElementById('cgGroupName').value = suggestedGroupName(createGroupContext.appName, meta.intent); document.querySelector('#cgAssignToAppWrap span').textContent = `Gruppe direkt der App als ${intentLabel} zuweisen`; checkGroupName(); createBtn.textContent = `${intentLabel}-Gruppe erstellen`; } else { nameGroup.hidden = true; assignWrap.hidden = true; createBtn.disabled = false; const tgtLbl = meta.target === 'ALL_USERS' ? 'All Users' : 'All Devices'; createBtn.textContent = `Als ${intentLabel} für ${tgtLbl} zuweisen`; } } document.querySelectorAll('input[name="cgMode"]').forEach(r => { r.addEventListener('change', applyCgModeUi); }); document.getElementById('cgGroupName').addEventListener('input', () => { clearTimeout(cgCheckTimer); cgCheckTimer = setTimeout(checkGroupName, 400); }); async function checkGroupName() { const name = document.getElementById('cgGroupName').value.trim(); const status = document.getElementById('cgStatus'); const btn = document.getElementById('cgCreate'); if (!name) { status.textContent = 'Name darf nicht leer sein'; status.className = 'form-hint err'; btn.disabled = true; return; } status.textContent = 'Prüfe...'; status.className = 'form-hint'; btn.disabled = true; try { const res = await api('/api/groups/check', { method: 'POST', body: { displayName: name } }); if (res.exists) { status.textContent = '✕ Gruppe existiert bereits'; status.className = 'form-hint err'; } else { status.textContent = '✓ Name ist verfügbar'; status.className = 'form-hint ok'; btn.disabled = false; } } catch (e) { status.textContent = 'Prüfung fehlgeschlagen: ' + e.message; status.className = 'form-hint err'; } } document.getElementById('cgCreate').addEventListener('click', async () => { if (!createGroupContext) return; const meta = cgModeMeta(getCurrentCgMode()); if (meta.isGroup) { // Gruppe erstellen + zuweisen const { prefix, suffix } = getNamingFor(meta.intent); const raw = document.getElementById('cgGroupName').value.trim(); // Praefix/Suffix entfernen damit der Backend-Slug-Builder die Saettigung steuert const customName = raw .replace(new RegExp('^' + prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), '') .replace(new RegExp(suffix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'), ''); const assignToApp = document.getElementById('cgAssignToApp').checked; setLoading(`Erstelle ${meta.intent === 'available' ? 'Available' : 'Required'}-Gruppe...`); try { const res = await api('/api/groups', { method: 'POST', body: { appId: createGroupContext.appId, appName: createGroupContext.appName, customName, assignToApp, intent: meta.intent, } }); toast(`Gruppe erstellt: ${res.displayName}` + (res.assigned ? ' (zugewiesen)' : ''), 'ok'); closeModal('modalCreateGroup'); if (assignToApp) await loadApps(true); } catch (e) { toast(e.message, 'err', 'Erstellen fehlgeschlagen'); } finally { clearLoading(); } return; } // Native Zuweisung (All Users / All Devices) — Intent kann required ODER available sein const intentLabel = meta.intent === 'available' ? 'Available' : 'Required'; const targetLabel = meta.target === 'ALL_USERS' ? 'All Users' : 'All Devices'; setLoading(`Weise als ${intentLabel} für ${targetLabel} zu...`); try { await api(`/api/apps/${encodeURIComponent(createGroupContext.appId)}/assignments`, { method: 'POST', body: { intent: meta.intent, target: meta.target }, }); toast(`${intentLabel}-Zuweisung (${targetLabel}) hinzugefügt`, 'ok'); closeModal('modalCreateGroup'); await loadApps(true); } catch (e) { toast(e.message, 'err', 'Zuweisung fehlgeschlagen'); } finally { clearLoading(); } }); // ============================================================= // App umbenennen // ============================================================= let renameAppContext = null; function openRenameAppModal(appId, appName) { renameAppContext = { appId, appName }; document.getElementById('raOldName').textContent = appName; const input = document.getElementById('raNewName'); input.value = appName; document.getElementById('raError').textContent = ''; openModal('modalRenameApp'); // Im naechsten Frame fokussieren + komplett markieren, damit der User // sofort lostippen oder gezielt aendern kann. requestAnimationFrame(() => { input.focus(); input.select(); }); } async function submitRenameApp() { if (!renameAppContext) return; const input = document.getElementById('raNewName'); const errEl = document.getElementById('raError'); const newName = input.value.trim(); errEl.textContent = ''; if (newName.length < 1) { errEl.textContent = 'Name darf nicht leer sein.'; return; } if (newName.length > 256) { errEl.textContent = 'Name zu lang (max. 256 Zeichen).'; return; } if (newName === renameAppContext.appName) { closeModal('modalRenameApp'); return; } setLoading(`Benenne um zu "${newName}"...`); try { const res = await api(`/api/apps/${encodeURIComponent(renameAppContext.appId)}`, { method: 'PATCH', body: { displayName: newName }, }); // Cache lokal nachziehen const app = State.apps.find(a => a.AppId === renameAppContext.appId); if (app) app.AppName = res.appName || newName; closeModal('modalRenameApp'); renderApps(); toast(`Umbenannt: "${renameAppContext.appName}" → "${newName}"`, 'ok'); } catch (e) { errEl.textContent = e.message; } finally { clearLoading(); } } // --- Setup-Datei aktualisieren --- let updateContentContext = null; function openUpdateContentModal(appId, appName, appType, appVersion) { updateContentContext = { appId, appName, appType }; document.getElementById('ucAppName').textContent = appName; document.getElementById('ucFilePath').value = ''; document.getElementById('ucVersion').value = appVersion || ''; document.getElementById('ucConfirm').checked = false; document.getElementById('ucError').textContent = ''; const prog = document.getElementById('ucProgress'); prog.classList.add('hidden'); prog.innerHTML = ''; document.getElementById('ucSubmit').disabled = true; openModal('modalUpdateContent'); requestAnimationFrame(() => document.getElementById('ucFilePath').focus()); } async function submitUpdateContent() { if (!updateContentContext) return; const filePath = document.getElementById('ucFilePath').value.trim(); const version = document.getElementById('ucVersion').value.trim(); const errEl = document.getElementById('ucError'); const prog = document.getElementById('ucProgress'); const submit = document.getElementById('ucSubmit'); errEl.textContent = ''; if (!filePath) { errEl.textContent = 'Bitte einen Dateipfad angeben.'; return; } if (!document.getElementById('ucConfirm').checked) { errEl.textContent = 'Bitte die Bestätigung anhaken.'; return; } submit.disabled = true; prog.classList.remove('hidden'); prog.innerHTML = '
    Lade hoch und aktiviere — das kann je nach Dateigröße dauern…'; try { const body = { filePath }; if (version) body.displayVersion = version; const res = await api(`/api/apps/${encodeURIComponent(updateContentContext.appId)}/content`, { method: 'POST', body, timeoutMs: 1800000, // 30 Min — grosse Pakete + Commit-Polling }); closeModal('modalUpdateContent'); // App-Liste neu laden (Backend hat den Cache geleert) await loadApps(true, { silent: true }); State.appDetails.delete(updateContentContext.appId); renderApps(); toast(`Setup-Datei aktualisiert: "${updateContentContext.appName}" → Version ${res.contentVersion}`, 'ok'); } catch (e) { errEl.textContent = e.message; prog.classList.add('hidden'); submit.disabled = false; } } // ============================================================= // Destruktive Aktionen: App loeschen / Zuweisung entfernen // ============================================================= async function deleteAppWithConfirm(appId, appName) { if (!appId) return; const msg = `App aus Intune LÖSCHEN?\n\n ${appName}\n\nDas entfernt die App und alle Zuweisungen aus Intune. Die Aktion kann nicht rückgängig gemacht werden.`; if (!window.confirm(msg)) return; setLoading(`Lösche "${appName}"...`); try { await api(`/api/apps/${encodeURIComponent(appId)}`, { method: 'DELETE' }); State.apps = State.apps.filter(a => a.AppId !== appId); State.appDetails.delete(appId); State.expandedApps.delete(appId); State.session = State.session.filter(s => s.appId !== appId); State.membershipCache.clear(); renderApps(); renderSession(); renderExisting(); updateStepper(); toast(`App "${appName}" gelöscht`, 'ok'); } catch (e) { toast(e.message, 'err', 'Löschen fehlgeschlagen'); } finally { clearLoading(); } } async function removeAssignmentWithConfirm(ctx) { const { appId, appName, groupId, groupName, type } = ctx; if (!appId || !groupId) return; if (groupId === 'ALL_USERS' || groupId === 'ALL_DEVICES') { toast('Native Zuweisungen (All Users / All Devices) können hier nicht entfernt werden.', 'warn'); return; } const typeLbl = type === 'required' ? 'Required' : 'Available'; const msg = `Zuweisung entfernen?\n\nApp: ${appName}\nGruppe: ${groupName}\nTyp: ${typeLbl}\n\nDie Gruppe ist danach der App nicht mehr zugewiesen. Mitgliedschaften der Gruppe bleiben unverändert.`; if (!window.confirm(msg)) return; setLoading(`Entferne Zuweisung "${groupName}"...`); try { const url = `/api/apps/${encodeURIComponent(appId)}/assignments/${encodeURIComponent(groupId)}?type=${encodeURIComponent(type)}`; await api(url, { method: 'DELETE' }); // Lokal nachziehen — Backend tut das ebenfalls im Cache const app = State.apps.find(a => a.AppId === appId); if (app) { if (type === 'available') app.AvailableGroups = app.AvailableGroups.filter(g => g.GroupId !== groupId); if (type === 'required') app.RequiredGroups = app.RequiredGroups.filter(g => g.GroupId !== groupId); app.AvailableCount = app.AvailableGroups.length; app.RequiredCount = app.RequiredGroups.length; } // Geplante Zuweisungen auf die gleiche Gruppe ggf. ebenfalls aufraeumen — sie // wuerden ja im naechsten Apply gegen eine nicht mehr zugewiesene Gruppe laufen. State.session = State.session.filter(s => !(s.appId === appId && s.groupId === groupId && (s.type || '').toLowerCase() === type)); State.membershipCache.clear(); renderApps(); renderSession(); renderExisting(); updateStepper(); toast(`Zuweisung "${groupName}" entfernt`, 'ok'); } catch (e) { toast(e.message, 'err', 'Entfernen fehlgeschlagen'); } finally { clearLoading(); } } // ============================================================= // Externe Direkt-Links (Entra, Intune, Members-Tab) // ============================================================= const ENTRA_ICON = ``; const MEMBERS_ICON = ``; const TRASH_ICON = ``; const LOCK_ICON = ``; const PENCIL_ICON = ``; // Microsoft Teams Icon — original aus Bootstrap Icons (microsoft-teams) const TEAMS_ICON = ``; function entraUserLink(id, name) { const url = `https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/${encodeURIComponent(id)}`; return `${ENTRA_ICON}`; } function entraGroupLink(id, name) { // Group-Overview-Blade im Intune-Portal. Wichtig: schliessendes "/menuId/" // ist Teil des Deep-Links und darf NICHT weggelassen werden. const url = `https://intune.microsoft.com/#view/Microsoft_AAD_IAM/GroupDetailsMenuBlade/~/Overview/groupId/${encodeURIComponent(id)}/menuId/`; return `${ENTRA_ICON}`; } function intuneAppLink(id, name) { const url = `https://intune.microsoft.com/#view/Microsoft_Intune_Apps/SettingsMenu/~/2/appId/${encodeURIComponent(id)}`; return `${ENTRA_ICON}`; } function membersTabLink(id, name) { // Oeffnet die Members-Blade der Gruppe direkt im Intune-Portal (neuer Tab). // Auch hier ist das "/menuId/" am Ende Pflicht. const url = `https://intune.microsoft.com/#view/Microsoft_AAD_IAM/GroupDetailsMenuBlade/~/Members/groupId/${encodeURIComponent(id)}/menuId/`; return `${MEMBERS_ICON}`; } function teamsChatLink(upn, name) { // Microsoft-Teams Deep-Link: oeffnet IMMER den installierten Desktop-Client // ueber den msteams:-Protocol-Handler. Web-Variante (https://) wird hier // bewusst nicht verwendet — der User hat den Desktop-Client als gewuenschten // Zielort definiert. if (!upn) return ''; const url = `msteams:/l/chat/0/0?users=${encodeURIComponent(upn)}`; return `${TEAMS_ICON}`; } // ============================================================= // Inline Mitglieder-Browser (Gruppe aufklappen → User auswaehlen) // ============================================================= async function toggleGroupExpand(groupId, groupName) { if (State.expandedGroups.has(groupId)) { State.expandedGroups.delete(groupId); renderTargets(); return; } // Anlegen + Loading State.expandedGroups.set(groupId, { members: null, selected: new Set(), loading: true, error: null, filter: '', name: groupName, }); renderTargets(); try { const res = await api(`/api/groups/${encodeURIComponent(groupId)}/members`); const entry = State.expandedGroups.get(groupId); if (!entry) return; // user has collapsed in the meantime entry.members = (res.items || []) .filter(m => m.UserPrincipalName) .sort((a, b) => (a.DisplayName || '').localeCompare(b.DisplayName || '', 'de', { sensitivity: 'base' })); entry.loading = false; } catch (e) { const entry = State.expandedGroups.get(groupId); if (!entry) return; entry.loading = false; entry.error = e.message; } renderTargets(); } function renderExpandedMembers(groupId, groupName) { const entry = State.expandedGroups.get(groupId); if (!entry) return ''; if (entry.loading) { return `
    Lade Mitglieder...
    `; } if (entry.error) { return `
    Fehler: ${escapeHtml(entry.error)}
    `; } const all = entry.members || []; const q = entry.filter.toLowerCase(); const items = q ? all.filter(m => (m.DisplayName || '').toLowerCase().includes(q) || (m.UserPrincipalName || '').toLowerCase().includes(q)) : all; const selectedCount = entry.selected.size; const filterInput = all.length > 6 ? `` : ''; const memberRows = items.length === 0 ? `
    ${all.length === 0 ? 'Keine direkten Benutzer in dieser Gruppe.' : 'Keine Treffer.'}
    ` : items.map(m => { const isSel = entry.selected.has(m.Id) ? 'selected' : ''; const checked = entry.selected.has(m.Id) ? 'checked' : ''; return ``; }).join(''); return `
    ${filterInput}
    ${all.length} Mitglieder${selectedCount ? ` · ${selectedCount} ausgewählt` : ''}
    ·
    ${memberRows}
    `; } function attachExpandedHandlers(list) { // Filter-Input list.querySelectorAll('.expand-filter').forEach(inp => { inp.addEventListener('input', e => { const gid = inp.dataset.groupId; const entry = State.expandedGroups.get(gid); if (!entry) return; entry.filter = e.target.value; // Nur die expand-list neu rendern, nicht alles — sonst verliert der Input den Focus const wrapper = inp.closest('.target-row-wrap'); if (wrapper) { const oldList = wrapper.querySelector('.expand-list'); const oldCount = wrapper.querySelector('.expand-count'); const fresh = document.createElement('div'); fresh.innerHTML = renderExpandedMembers(gid, entry.name); const newList = fresh.querySelector('.expand-list'); const newCount = fresh.querySelector('.expand-count'); if (oldList && newList) oldList.replaceWith(newList); if (oldCount && newCount) oldCount.replaceWith(newCount); attachMemberHandlers(wrapper); } }); }); attachMemberHandlers(list); // Action-Buttons (Alle / Leeren / Übernehmen) list.querySelectorAll('[data-expand-action]').forEach(btn => { btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); const gid = btn.dataset.groupId; const entry = State.expandedGroups.get(gid); if (!entry) return; const action = btn.dataset.expandAction; if (action === 'all') { const q = entry.filter.toLowerCase(); const visible = q ? (entry.members || []).filter(m => (m.DisplayName || '').toLowerCase().includes(q) || (m.UserPrincipalName || '').toLowerCase().includes(q)) : (entry.members || []); visible.forEach(m => entry.selected.add(m.Id)); renderTargets(); renderSession(); renderPinnedTargets(); } else if (action === 'clear') { entry.selected.clear(); renderTargets(); renderSession(); renderPinnedTargets(); } else if (action === 'apply') { applyExpandedSelection(gid); } }); }); } function attachMemberHandlers(scope) { scope.querySelectorAll('.ex-member-row').forEach(row => { row.addEventListener('change', e => { const gid = row.dataset.groupId; const mid = row.dataset.memberId; const entry = State.expandedGroups.get(gid); if (!entry) return; if (e.target.checked) entry.selected.add(mid); else entry.selected.delete(mid); row.classList.toggle('selected', e.target.checked); // Counter & Apply-Button updaten ohne Komplettrender const wrapper = row.closest('.target-row-wrap'); if (wrapper) { const cnt = wrapper.querySelector('.expand-count'); if (cnt) cnt.innerHTML = `${(entry.members || []).length} Mitglieder${entry.selected.size ? ` · ${entry.selected.size} ausgewählt` : ''}`; const btn = wrapper.querySelector('[data-expand-action="apply"]'); if (btn) { btn.disabled = entry.selected.size === 0; btn.textContent = entry.selected.size > 0 ? `${entry.selected.size} Benutzer übernehmen` : 'Benutzer übernehmen'; } } // Rechte Spalte (Empfaenger-Zaehler) und Pin-Leiste aktualisieren renderSession(); renderPinnedTargets(); }); }); } function applyExpandedSelection(groupId) { const entry = State.expandedGroups.get(groupId); if (!entry || !entry.members) return; const picked = entry.members.filter(m => entry.selected.has(m.Id)); if (picked.length === 0) { toast('Keine Mitglieder ausgewählt', 'warn'); return; } // 1) Aktuellen Modus (dept oder rpa) in den Cache schreiben — sonst geht // die Abteilungs-Auswahl + die geladene Liste verloren. const oldMode = State.mode; if (oldMode !== 'user') { State.modeCache.set(oldMode, { targets: State.targets.slice(), selectedIds: new Set(State.selectedTargetIds), expandedGroups: new Map(), // expand wird gleich geschlossen }); } // 2) In User-Modus wechseln. Bestehende User-Auswahl MERGEN, nicht ueberschreiben. State.mode = 'user'; document.querySelectorAll('.seg').forEach(s => s.classList.toggle('active', s.dataset.mode === 'user')); const cachedUser = State.modeCache.get('user'); if (cachedUser && cachedUser.targets && cachedUser.targets.length > 0) { const existingIds = new Set(cachedUser.targets.map(t => t.Id)); const newOnes = picked.filter(p => !existingIds.has(p.Id)); State.targets = [...cachedUser.targets, ...newOnes]; State.selectedTargetIds = new Set([ ...cachedUser.selectedIds, ...picked.map(p => p.Id), ]); } else { State.targets = picked.slice(); State.selectedTargetIds = new Set(picked.map(m => m.Id)); } State.membershipCache.clear(); State.expandedGroups.clear(); const search = document.getElementById('targetSearch'); search.placeholder = 'Tippe Name oder UPN...'; document.getElementById('userSearchHint').classList.remove('hidden'); search.value = ''; renderTargets(); onTargetSelectionChange(); toast(`${picked.length} Benutzer aus "${entry.name}" übernommen — Abteilungs-Auswahl bleibt erhalten`, 'ok'); } // (Alte Modal-Logik — unbenutzt, aber harmlos:) let mmContext = { groupId: null, groupName: null, members: [], selectedIds: new Set() }; async function openMembersModal(groupId, groupName) { mmContext = { groupId, groupName, members: [], selectedIds: new Set() }; document.getElementById('mmTitle').textContent = `Mitglieder · ${groupName}`; document.getElementById('mmSearch').value = ''; document.getElementById('mmInfo').textContent = '0 ausgewählt'; document.getElementById('mmList').innerHTML = `
    `; openModal('modalMembers'); try { const res = await api(`/api/groups/${encodeURIComponent(groupId)}/members`); mmContext.members = (res.items || []).filter(m => m.UserPrincipalName); // nur User, keine geschachtelten Gruppen renderMembersList(); } catch (e) { document.getElementById('mmList').innerHTML = emptyState({ title: 'Fehler beim Laden', text: e.message, }); } } function renderMembersList() { const q = (document.getElementById('mmSearch').value || '').toLowerCase().trim(); const items = q ? mmContext.members.filter(m => (m.DisplayName || '').toLowerCase().includes(q) || (m.UserPrincipalName || '').toLowerCase().includes(q)) : mmContext.members; const list = document.getElementById('mmList'); if (mmContext.members.length === 0) { list.innerHTML = emptyState({ small: true, title: 'Keine Mitglieder', text: 'Diese Gruppe hat keine direkten Benutzer.' }); return; } if (items.length === 0) { list.innerHTML = emptyState({ small: true, title: 'Keine Treffer', text: 'Filter zurücksetzen.' }); return; } list.innerHTML = items.map(m => { const checked = mmContext.selectedIds.has(m.Id) ? 'checked' : ''; const sel = mmContext.selectedIds.has(m.Id) ? 'selected' : ''; return ``; }).join(''); list.querySelectorAll('.member-row').forEach(row => { row.addEventListener('change', e => { const id = row.dataset.id; if (e.target.checked) mmContext.selectedIds.add(id); else mmContext.selectedIds.delete(id); row.classList.toggle('selected', e.target.checked); document.getElementById('mmInfo').textContent = mmContext.selectedIds.size === 0 ? 'Keine ausgewählt' : `${mmContext.selectedIds.size} ausgewählt`; }); }); } document.getElementById('mmSearch')?.addEventListener('input', renderMembersList); document.getElementById('mmSelectAll')?.addEventListener('click', () => { const q = (document.getElementById('mmSearch').value || '').toLowerCase().trim(); const items = q ? mmContext.members.filter(m => (m.DisplayName || '').toLowerCase().includes(q) || (m.UserPrincipalName || '').toLowerCase().includes(q)) : mmContext.members; items.forEach(m => mmContext.selectedIds.add(m.Id)); renderMembersList(); document.getElementById('mmInfo').textContent = `${mmContext.selectedIds.size} ausgewählt`; }); document.getElementById('mmClear')?.addEventListener('click', () => { mmContext.selectedIds.clear(); renderMembersList(); document.getElementById('mmInfo').textContent = 'Keine ausgewählt'; }); document.getElementById('mmApply')?.addEventListener('click', () => { if (mmContext.selectedIds.size === 0) { toast('Keine Mitglieder ausgewählt', 'warn'); return; } // In den User-Modus wechseln und die ausgewählten Mitglieder als Targets übernehmen State.mode = 'user'; document.querySelectorAll('.seg').forEach(s => s.classList.toggle('active', s.dataset.mode === 'user')); const picked = mmContext.members.filter(m => mmContext.selectedIds.has(m.Id)); State.targets = picked; State.selectedTargetIds = new Set(picked.map(m => m.Id)); State.membershipCache.clear(); const search = document.getElementById('targetSearch'); search.placeholder = 'Tippe Name oder UPN...'; document.getElementById('userSearchHint').classList.remove('hidden'); search.value = ''; renderTargets(); onTargetSelectionChange(); closeModal('modalMembers'); toast(`${picked.length} Benutzer aus "${mmContext.groupName}" übernommen`, 'ok'); }); // ============================================================= // Helpers // ============================================================= function emptyState({ title, text, small = false, ico = null }) { return `
    ${ico ? `
    ${ico}
    ` : ''} ${title ? `
    ${escapeHtml(title)}
    ` : ''} ${text ? `
    ${escapeHtml(text)}
    ` : ''}
    `; } // ============================================================= // Hilfe-Modal: laedt www/help.md und rendert es mit marked. // TOC wird aus den H2-Headings auto-generiert (mit Anker-IDs). // ============================================================= let helpLoaded = false; async function openHelpModal() { openModal('modalHelp'); // Version-Badge im Footer setzen (immer aktuell, auch wenn helpLoaded == true) try { const v = await api('/api/version'); const el = document.getElementById('helpVersion'); if (el) el.textContent = `Version ${v.version} · Build ${v.build}`; } catch {} if (helpLoaded) return; const contentEl = document.getElementById('helpContent'); try { const res = await fetch('/help.md', { cache: 'no-store' }); if (!res.ok) throw new Error('HTTP ' + res.status); const md = await res.text(); // marked rendert das ganze Dokument const html = renderMarkdown(md); contentEl.innerHTML = html; // H2 mit IDs versehen und TOC bauen const tocEl = document.querySelector('#helpToc ul'); tocEl.innerHTML = ''; const slug = (txt) => txt.toLowerCase().replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-'); const seen = new Set(); const headings = []; const links = new Map(); // headingId -> element contentEl.querySelectorAll('h2').forEach(h => { let id = slug(h.textContent); let n = 2; while (seen.has(id)) { id = slug(h.textContent) + '-' + (n++); } seen.add(id); h.id = id; headings.push(h); const li = document.createElement('li'); const a = document.createElement('a'); a.href = '#' + id; a.textContent = h.textContent; a.addEventListener('click', e => { e.preventDefault(); h.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); li.appendChild(a); tocEl.appendChild(li); links.set(id, a); }); // Aktiver TOC-Eintrag via Scroll-Position erkennen. // IntersectionObserver mit Top-Marker (kleiner Slot oben im Viewport) — // sobald ein H2 darin auftaucht, ist es der "aktive" Abschnitt. const setActive = (id) => { links.forEach((a, key) => a.classList.toggle('active', key === id)); }; // Default: erstes Heading aktiv if (headings.length > 0) setActive(headings[0].id); // Beobachtung: scroll-Position im help-content-Container const onScroll = () => { const containerTop = contentEl.getBoundingClientRect().top; let currentId = headings[0]?.id; // Nimm das letzte Heading dessen oben-Kante schon ueber dem 1/4-Mark // des Containers ist — wirkt natuerlicher als "schwellt rein". const threshold = containerTop + contentEl.clientHeight * 0.25; for (const h of headings) { if (h.getBoundingClientRect().top <= threshold) currentId = h.id; else break; } if (currentId) setActive(currentId); }; contentEl.addEventListener('scroll', onScroll, { passive: true }); onScroll(); helpLoaded = true; } catch (e) { contentEl.innerHTML = `
    Hilfe konnte nicht geladen werden: ${escapeHtml(e.message)}
    `; } } document.getElementById('btnHelp')?.addEventListener('click', openHelpModal); // ============================================================= // Branding / Theme: Logo + benutzerdefinierte Farben // ============================================================= // Default-Hex pro Konzept — wird bei "Reset" pro Farbe verwendet. // Diese Werte sind die VOM USER vorgegebenen Defaults (kein willkuerliches // Bootstrap-Blau mehr). Spiegelt Get-DefaultSettings im Backend. const ThemeDefaults = { brand: '#27a078', accent: '#3b82f6', required: '#cb2a7a', success: '#10b981', warning: '#f59e0b', error: '#ef4444', rowSelected: '#71e5c4', detailPanel: '#f7f7f7', }; // Welche Konzept-Farbe auf welche CSS-Variablen abgebildet wird. Die soft/ // strong/bg/border/shadow-Werte werden zur Laufzeit aus dem Hex per rgba() // abgeleitet (Alpha-Konstanten siehe applyThemeColors). const ThemeMap = { brand: { main: '--brand-primary' }, accent: { main: '--brand-accent', soft: '--accent-soft', strong: '--accent-strong', bg: '--accent-bg' }, required: { main: '--pink', soft: '--pink-soft' }, success: { main: '--success', soft: '--success-soft', strong: '--success-strong', bg: '--success-bg', border: '--success-border', shadow: '--success-shadow' }, warning: { main: '--warning', soft: '--warning-soft', strong: '--warning-strong', bg: '--warning-bg', border: '--warning-border' }, error: { main: '--error', soft: '--error-soft', strong: '--error-strong', bg: '--error-bg', border: '--error-border' }, }; function hexToRgb(hex) { if (!hex) return null; let h = hex.replace(/^#/, ''); if (h.length === 3) h = h.split('').map(c => c + c).join(''); if (!/^[0-9a-f]{6}$/i.test(h)) return null; const n = parseInt(h, 16); return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; } function rgbaStr(hex, alpha) { const rgb = hexToRgb(hex); if (!rgb) return null; return `rgba(${rgb.r},${rgb.g},${rgb.b},${alpha})`; } // WCAG-Relative-Luminance — basis fuer Kontrast-Entscheidung function relLuminance(rgb) { const ch = (v) => { const s = v / 255; return s < 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }; return 0.2126 * ch(rgb.r) + 0.7152 * ch(rgb.g) + 0.0722 * ch(rgb.b); } // WCAG Contrast Ratio zwischen zwei Farben function contrastRatio(rgb1, rgb2) { const l1 = relLuminance(rgb1); const l2 = relLuminance(rgb2); const hi = Math.max(l1, l2); const lo = Math.min(l1, l2); return (hi + 0.05) / (lo + 0.05); } // Liefert eine gut lesbare Vordergrund-Palette fuer den gegebenen Hintergrund. // Pickt zwischen weiss/schwarz basierend auf WCAG-Contrast (nicht nur Luminanz- // Schwellwert — der wuerde bei mittel-hellen Farben wie Pink danebenliegen). // Liefert {primary, secondary, muted} jeweils mit absteigender Saettigung // gegen den Hintergrund. function readableForeground(bgRgb) { if (!bgRgb) return { primary: '#111111', secondary: '#374151', muted: '#6b7280' }; const WHITE = { r: 255, g: 255, b: 255 }; const BLACK = { r: 17, g: 17, b: 17 }; const crWhite = contrastRatio(bgRgb, WHITE); const crBlack = contrastRatio(bgRgb, BLACK); if (crWhite >= crBlack) { // Weisser Text ist kontrastreicher -> bg ist eher dunkel return { primary: '#ffffff', secondary: '#e7eaf0', muted: '#c9d0db' }; } // Schwarzer Text ist kontrastreicher -> bg ist eher hell return { primary: '#111111', secondary: '#374151', muted: '#475569' }; } function applyThemeColors(colors) { if (!colors) return; const root = document.documentElement.style; for (const key of Object.keys(ThemeMap)) { const hex = colors[key]; if (!hex || !hexToRgb(hex)) continue; const vars = ThemeMap[key]; root.setProperty(vars.main, hex); if (vars.soft) root.setProperty(vars.soft, rgbaStr(hex, 0.12)); if (vars.strong) root.setProperty(vars.strong, rgbaStr(hex, 0.20)); if (vars.border) root.setProperty(vars.border, rgbaStr(hex, 0.30)); if (vars.bg) root.setProperty(vars.bg, rgbaStr(hex, 0.06)); if (vars.shadow) root.setProperty(vars.shadow, rgbaStr(hex, 0.35)); } // Background der aufgeklappten App: picked color wird 1:1 als solid // Hintergrund gesetzt (keine Transparenz). Text und Icons werden per // WCAG-Kontrast automatisch in Schwarz oder Weiss gewaehlt. // Innere Surfaces (Chips, File-Paths, Asg-Boxen) werden als SOLID-Shade // der Picked Color berechnet (heller bei dunkler Tint, dunkler bei heller). const rgbToHex = (rgb) => '#' + ['r','g','b'].map(c => Math.max(0, Math.min(255, Math.round(rgb[c]))).toString(16).padStart(2,'0')).join(''); const shade = (rgb, delta) => ({ r: Math.max(0, Math.min(255, rgb.r + Math.round(delta * 255))), g: Math.max(0, Math.min(255, rgb.g + Math.round(delta * 255))), b: Math.max(0, Math.min(255, rgb.b + Math.round(delta * 255))), }); const surfaceShades = (rgb) => { const isDark = relLuminance(rgb) < 0.5; const d = isDark ? 0.06 : -0.04; // Dark bg -> heller machen, Light bg -> dunkler return { surface: rgbToHex(shade(rgb, d)), surfaceStrong: rgbToHex(shade(rgb, d * 2)), border: rgbToHex(shade(rgb, d * 3)), borderSoft: rgbToHex(shade(rgb, d * 1.5)), }; }; if (colors.rowSelected && hexToRgb(colors.rowSelected)) { const rgb = hexToRgb(colors.rowSelected); // Picked color wird 1:1 als Background gesetzt — keine Transparenz, kein rgba. root.setProperty('--row-selected-border', colors.rowSelected); root.setProperty('--row-selected-bg', colors.rowSelected); root.setProperty('--row-selected-bg-hover', colors.rowSelected); const fg = readableForeground(rgb); root.setProperty('--row-selected-text', fg.primary); root.setProperty('--row-selected-text-muted', fg.secondary); root.setProperty('--row-selected-icon', fg.primary); root.setProperty('--row-selected-icon-hover', fg.primary); const so = surfaceShades(rgb); root.setProperty('--row-selected-surface', so.surface); root.setProperty('--row-selected-surface-strong', so.surfaceStrong); } if (colors.detailPanel && hexToRgb(colors.detailPanel)) { const rgb = hexToRgb(colors.detailPanel); root.setProperty('--app-expanded-bg', colors.detailPanel); const fg = readableForeground(rgb); root.setProperty('--detail-text', fg.primary); root.setProperty('--detail-text-muted', fg.secondary); root.setProperty('--detail-text-faint', fg.muted); const so = surfaceShades(rgb); root.setProperty('--detail-surface', so.surface); root.setProperty('--detail-surface-strong', so.surfaceStrong); root.setProperty('--detail-border', so.border); root.setProperty('--detail-border-soft', so.borderSoft); } } // Default-Logo das immer im Projekt-Root liegt — wird verwendet, wenn der // User kein eigenes Logo hochgeladen hat (oder seines wieder entfernt). const DEFAULT_LOGO_FILE = 'application.png'; function applyBranding(branding, cacheBust) { const logoEl = document.getElementById('brandLogo'); if (!logoEl) return; const file = (branding && branding.logoFile) || DEFAULT_LOGO_FILE; // cacheBust-Fallback-Kette: explizit uebergeben -> branding.logoCacheTag -> 1 // (Default-Logo ist statisch, einmaliges Laden reicht; uploaded Logos // bekommen vom Backend einen mtime-Tag.) const tag = cacheBust || (branding && branding.logoCacheTag) || '1'; const url = `/assets/${encodeURIComponent(file)}?v=${encodeURIComponent(tag)}`; logoEl.innerHTML = `Logo`; logoEl.classList.add('has-custom-logo'); } // ============================================================= // Einstellungen (Tenant/Client-ID, Gruppen-Praefixe, Naming, ...) // ============================================================= // Fallback-Vendor-Definitionen falls SettingsState aus irgendeinem Grund // (Settings-Load-Race, kaputte settings.json) leer ist. Damit zeigen PMPC- // und Robopack-Apps IMMER Source-Badge + Lock-Icons, unabhaengig von Backend. const VENDOR_FALLBACKS = [ { id: 'patchmypc', displayName: 'PatchMyPC', portalUrl: 'https://portal.patchmypc.com/', logoFile: 'pmpc.png', block: { delete: true, rename: true } }, { id: 'robopack', displayName: 'Robopack', portalUrl: 'https://app.robopack.com/', logoFile: 'robopack.png', block: { delete: true, rename: true } }, ]; const SettingsState = { current: null, // zuletzt geladene Settings vom Server filePath: null, // Vendor-Indizes — werden bei jedem Settings-Load via rebuildVendorIndexes() // neu aufgebaut, damit Render + Filter ohne wiederholten Lookup laufen. // Initial mit Fallbacks befuellt, damit der erste Render schon korrekt rendert. vendors: VENDOR_FALLBACKS.slice(), vendorsById: Object.fromEntries(VENDOR_FALLBACKS.map(v => [v.id, v])), vendorsByDisplayName: Object.fromEntries(VENDOR_FALLBACKS.map(v => [v.displayName, v])), }; function rebuildVendorIndexes(settings) { let list = (settings && Array.isArray(settings.vendors)) ? settings.vendors : []; // Fehlende Standard-Vendoren immer mit reinmischen — verhindert dass eine // unvollstaendige settings.json PMPC/Robopack aus dem UI verschwinden laesst. for (const fb of VENDOR_FALLBACKS) { if (!list.some(v => v && v.id === fb.id)) list = list.concat([fb]); } SettingsState.vendors = list; SettingsState.vendorsById = {}; SettingsState.vendorsByDisplayName = {}; for (const v of list) { if (v && v.id) SettingsState.vendorsById[v.id] = v; if (v && v.displayName) SettingsState.vendorsByDisplayName[v.displayName] = v; } rebuildVendorFilterOptions(); } function rebuildVendorFilterOptions() { // Filter-Select hat statische Optionen fuer "Alle/Intune"; Vendor-Optionen // werden hier nachgereicht. Vorhandene srcVendor:-Options vorher entsorgen. const sel = document.getElementById('appFilter'); if (!sel) return; // Bestehende dynamische Eintraege entfernen Array.from(sel.querySelectorAll('option[data-vendor-option]')).forEach(o => o.remove()); // "Nur Intune" als Anker — Vendor-Eintraege werden direkt danach eingefuegt const intuneOpt = Array.from(sel.options).find(o => o.value === 'srcIntune'); const insertAfter = intuneOpt || sel.options[sel.options.length - 1]; for (const v of SettingsState.vendors) { if (!v || !v.id || !v.displayName) continue; const opt = document.createElement('option'); opt.value = `srcVendor:${v.id}`; opt.textContent = `Nur ${v.displayName}-Apps`; opt.dataset.vendorOption = '1'; insertAfter.insertAdjacentElement('afterend', opt); } } function rebuildCategoryFilterOptions() { // Baut die Optionen des Kategorie-Filters aus den tatsaechlich in den // geladenen Apps vorkommenden Kategorien. Auswahl bleibt erhalten falls // die Kategorie noch existiert. const sel = document.getElementById('appCategoryFilter'); if (!sel) return; const prev = sel.value; // Distinkte Kategorien sammeln (alphabetisch, de-DE) const set = new Set(); let anyWithout = false; for (const a of State.apps) { if (Array.isArray(a.Categories) && a.Categories.length > 0) { for (const c of a.Categories) { if (c) set.add(String(c)); } } else { anyWithout = true; } } const cats = Array.from(set).sort((x, y) => x.localeCompare(y, 'de-DE')); // Neu aufbauen: "Alle Kategorien" fix, dann optional "Ohne Kategorie", dann die Namen sel.innerHTML = ''; const optAll = document.createElement('option'); optAll.value = ''; optAll.textContent = 'Alle Kategorien'; sel.appendChild(optAll); if (anyWithout) { const optNone = document.createElement('option'); optNone.value = '__none__'; optNone.textContent = 'Ohne Kategorie (Default)'; sel.appendChild(optNone); } for (const c of cats) { const opt = document.createElement('option'); opt.value = c; opt.textContent = c; sel.appendChild(opt); } // Vorherige Auswahl wiederherstellen wenn noch gueltig if (prev && (prev === '__none__' || cats.includes(prev))) { sel.value = prev; } else { sel.value = ''; State.appFilter.category = ''; } } function isSetupIncomplete(s) { if (!s) return true; const c = s.connection || {}; const d = s.departments || {}; const tenant = (c.tenantId || '').trim(); const client = (c.clientId || '').trim(); // Praefixe: 'prefixes'-Array bevorzugt, sonst alter Single-String 'prefix'. // Setup ist komplett, wenn mind. ein nicht-leerer Praefix (>= 2 Zeichen) existiert. let hasPrefix = false; if (Array.isArray(d.prefixes)) { for (const p of d.prefixes) { if (p && String(p).trim().length >= 2) { hasPrefix = true; break; } } } if (!hasPrefix && d.prefix && String(d.prefix).trim().length >= 2) { hasPrefix = true; } return !tenant || !client || !hasPrefix; } function applySetupNeededUI(needed) { // Setup-Banner im Modal + Hinweis im Onboarding-Panel + Connect-Button disablen const banner = document.getElementById('setupBanner'); if (banner) banner.classList.toggle('hidden', !needed); const hint = document.getElementById('onboardingSetupHint'); if (hint) hint.classList.toggle('hidden', !needed); const btn = document.getElementById('btnConnect'); if (btn) { btn.disabled = !!needed; btn.title = needed ? 'Bitte zuerst in Einstellungen konfigurieren.' : ''; } } async function openSettingsModal() { // Modal sofort oeffnen, Inhalt nachladen document.getElementById('setFilePath').textContent = SettingsState.filePath || 'wird geladen…'; // Test-Result-Box bei jedem Oeffnen leeren — stale Ergebnisse vermeiden. const trBox = document.getElementById('settingsTestResult'); if (trBox) trBox.innerHTML = ''; openModal('modalSettings'); try { const res = await api('/api/settings'); SettingsState.current = res.settings; SettingsState.filePath = res.path; rebuildVendorIndexes(res.settings); fillSettingsForm(res.settings); document.getElementById('setFilePath').textContent = res.path || '(unbekannt)'; const banner = document.getElementById('setupBanner'); if (banner) banner.classList.toggle('hidden', !isSetupIncomplete(res.settings)); } catch (e) { toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'err'); } } async function onSettingsTest() { const box = document.getElementById('settingsTestResult'); const btn = document.getElementById('btnSettingsTest'); if (!box) return; // Aktuellen Form-State testen (nicht den gespeicherten). const payload = readSettingsForm(); box.innerHTML = '
    Pruefe Werte gegen Microsoft Graph…
    '; if (btn) btn.disabled = true; try { const res = await api('/api/settings/test', { method: 'POST', body: payload }); box.innerHTML = renderSettingsTestResult(res); } catch (e) { box.innerHTML = `
    Test fehlgeschlagen: ${escapeHtml(e.message || String(e))}
    `; } finally { if (btn) btn.disabled = false; } } function renderSettingsTestResult(res) { const lines = []; const ok = 'OK'; const fail = 'FAIL'; const conn = res.connection || {}; if (conn.ok) { lines.push(`
    ${ok} Verbindung: ${escapeHtml(conn.account || '')} (Tenant ${escapeHtml(conn.tenantId || '')})
    `); } else { const why = conn.reason === 'not_connected' ? 'Bitte zuerst per Connect-Button verbinden.' : (conn.message || 'unbekannter Grund'); lines.push(`
    ${fail} Verbindung: ${escapeHtml(why)}
    `); return lines.join(''); } const d = res.departments || {}; if (d.reason === 'not_configured') { lines.push(`
    ${fail} Abteilungs-Praefix(e) nicht gesetzt.
    `); } else if (Array.isArray(d.perPrefix) && d.perPrefix.length > 0) { // Neue Form: eine Zeile pro Praefix lines.push(`
    ${d.ok ? ok : fail} Abteilungs-Gruppen — Total: ${d.totalCount || 0}
    `); for (const pp of d.perPrefix) { if (pp.ok) { const sample = pp.sample && pp.sample.length ? ` (z.B. ${pp.sample.map(escapeHtml).join(', ')})` : ''; const icon = pp.count > 0 ? ok : '0'; const cls = pp.count > 0 ? '' : 'set-test-warn'; lines.push(`
    ${icon} ${escapeHtml(pp.prefix)}: ${pp.count} Treffer${sample}
    `); } else { lines.push(`
    ${fail} ${escapeHtml(pp.prefix)}: ${escapeHtml(pp.error || '')}
    `); } } } else if (d.ok) { // Backward-compat: alte Single-Prefix-Response const sample = d.sample && d.sample.length ? ` (z.B. ${d.sample.map(escapeHtml).join(', ')})` : ''; lines.push(`
    ${ok} Abteilungs-Gruppen: ${d.count} Treffer fuer ${escapeHtml(d.prefix)}${sample}
    `); } else { lines.push(`
    ${fail} Abteilungs-Gruppen-Lookup fehlgeschlagen: ${escapeHtml(d.error || '')}
    `); } const r = res.rpa || {}; if (r.ok) { lines.push(`
    ${ok} RPA-Gruppen: ${(r.found || []).length} von ${r.expected} gefunden
    `); } else if (r.reason === 'not_configured') { lines.push(`
    ${fail} Keine RPA-Gruppen konfiguriert (optional — der RPA-Tab bleibt dann leer).
    `); } else { const miss = (r.missing || []).map(escapeHtml).join(', '); lines.push(`
    ${fail} RPA-Gruppen: ${(r.found || []).length} von ${r.expected} gefunden. Fehlt: ${miss}
    `); } const u = res.userSearch || {}; if (u.ok) { lines.push(`
    ${ok} Benutzer-Suche: ok (${(u.fields || []).join(', ')})
    `); } else if (u.reason === 'no_fields') { lines.push(`
    ${fail} Keine User-Such-Felder gewaehlt.
    `); } else { lines.push(`
    ${fail} Benutzer-Suche fehlgeschlagen: ${escapeHtml(u.error || '')}
    `); } const ven = res.vendors || {}; if (Array.isArray(ven.counts) && ven.counts.length > 0) { for (const c of ven.counts) { const icon = c.count > 0 ? ok : '0'; const cls = c.count > 0 ? '' : 'set-test-warn'; lines.push(`
    ${icon} Vendor ${escapeHtml(c.displayName)}: ${c.count} Apps (Regel: ${escapeHtml(c.detection)})
    `); } if (ven.hint) { lines.push(`
    Hinweis: ${escapeHtml(ven.hint)}
    `); } } return lines.join(''); } function fillSettingsForm(s) { const c = s.connection || {}; document.getElementById('setTenantId').value = c.tenantId || ''; document.getElementById('setClientId').value = c.clientId || ''; document.getElementById('setScopes').value = (c.scopes || []).join('\n'); // Backward-compat: lese 'prefixes' (Array) bevorzugt, falle sonst auf // alten Single-String 'prefix' zurueck. Beide werden in die Textarea // gemerged (gleiche Logik wie Get-DepartmentPrefixes im Backend). (function () { const d = s.departments || {}; const list = []; if (Array.isArray(d.prefixes)) { for (const p of d.prefixes) if (p) list.push(String(p)); } if (d.prefix) list.push(String(d.prefix)); const seen = {}; const out = []; for (const raw of list) { const t = raw.trim(); if (!t) continue; const k = t.toLowerCase(); if (seen[k]) continue; seen[k] = true; out.push(t); } document.getElementById('setDeptPrefixes').value = out.join('\n'); })(); document.getElementById('setRpaGroups').value = ((s.rpa && s.rpa.groupNames) || []).join('\n'); const fields = (s.userSearch && s.userSearch.fields) || []; document.getElementById('setUsfDisplayName').checked = fields.includes('displayName'); document.getElementById('setUsfUpn').checked = fields.includes('userPrincipalName'); document.getElementById('setUsfMail').checked = fields.includes('mail'); document.getElementById('setUsfDepartment').checked = fields.includes('department'); const r = s.requiredGroupNaming || {}; document.getElementById('setReqPrefix').value = r.prefix || ''; document.getElementById('setReqSuffix').value = r.suffix || ''; updateReqPreview(); const av = s.availableGroupNaming || {}; document.getElementById('setAvailPrefix').value = av.prefix || ''; document.getElementById('setAvailSuffix').value = av.suffix || ''; updateAvailPreview(); // Branding-Logo Preview fillLogoPreview(s.branding); // Farben const cols = (s.theme && s.theme.colors) || {}; for (const key of Object.keys(ThemeDefaults)) { const hex = cols[key] || ThemeDefaults[key]; setColorInputs(key, hex); } } function setColorInputs(key, hex) { const cap = key[0].toUpperCase() + key.slice(1); const picker = document.getElementById(`setCol${cap}`); const text = document.getElementById(`setCol${cap}Hex`); const swatch = document.querySelector(`.color-swatch[data-swatch="${key}"]`); if (picker) picker.value = hex; if (text) text.value = hex; if (swatch) swatch.style.background = hex; } function fillLogoPreview(branding) { const userFile = branding && branding.logoFile; const file = userFile || DEFAULT_LOGO_FILE; const preview = document.getElementById('logoPreview'); const removeBtn = document.getElementById('btnLogoRemove'); if (!preview) return; const tag = (branding && branding.logoCacheTag) || '1'; const url = `/assets/${encodeURIComponent(file)}?v=${encodeURIComponent(tag)}`; preview.innerHTML = `Logo`; // "Entfernen"-Button nur wenn ein eigenes Logo aktiv ist (Default-Logo // application.png kann nicht entfernt werden — ist die Firmen-Vorgabe). if (removeBtn) removeBtn.hidden = !userFile; } function readSettingsForm() { const splitLines = (txt) => (txt || '') .split(/\r?\n/).map(s => s.trim()).filter(Boolean); const fields = []; if (document.getElementById('setUsfDisplayName').checked) fields.push('displayName'); if (document.getElementById('setUsfUpn').checked) fields.push('userPrincipalName'); if (document.getElementById('setUsfMail').checked) fields.push('mail'); if (document.getElementById('setUsfDepartment').checked) fields.push('department'); return { connection: { tenantId: document.getElementById('setTenantId').value.trim(), clientId: document.getElementById('setClientId').value.trim(), scopes: splitLines(document.getElementById('setScopes').value), }, departments: { // Neuer Listen-Form: Praefixe aus Textarea. Alten Single-String 'prefix' // beim Save auf leer setzen, damit nicht beide Quellen divergieren — // das Backend liest ab jetzt nur noch aus 'prefixes'. prefixes: splitLines(document.getElementById('setDeptPrefixes').value), prefix: '', }, rpa: { groupNames: splitLines(document.getElementById('setRpaGroups').value), }, userSearch: { fields, }, requiredGroupNaming: { prefix: document.getElementById('setReqPrefix').value, suffix: document.getElementById('setReqSuffix').value, }, availableGroupNaming: { prefix: document.getElementById('setAvailPrefix').value, suffix: document.getElementById('setAvailSuffix').value, }, theme: { colors: { brand: document.getElementById('setColBrandHex').value.trim() || ThemeDefaults.brand, accent: document.getElementById('setColAccentHex').value.trim() || ThemeDefaults.accent, required: document.getElementById('setColRequiredHex').value.trim() || ThemeDefaults.required, success: document.getElementById('setColSuccessHex').value.trim() || ThemeDefaults.success, warning: document.getElementById('setColWarningHex').value.trim() || ThemeDefaults.warning, error: document.getElementById('setColErrorHex').value.trim() || ThemeDefaults.error, rowSelected: document.getElementById('setColRowSelectedHex').value.trim() || ThemeDefaults.rowSelected, detailPanel: document.getElementById('setColDetailPanelHex').value.trim() || ThemeDefaults.detailPanel, }, }, }; } function updateReqPreview() { const p = document.getElementById('setReqPrefix').value || ''; const s = document.getElementById('setReqSuffix').value || ''; const ex = (p + 'beispiel-app' + s).toLowerCase(); document.getElementById('setReqPreview').textContent = ex; } function updateAvailPreview() { const p = document.getElementById('setAvailPrefix').value || ''; const s = document.getElementById('setAvailSuffix').value || ''; const ex = (p + 'beispiel-app' + s).toLowerCase(); document.getElementById('setAvailPreview').textContent = ex; } async function saveSettings() { const payload = readSettingsForm(); setLoading('Speichere Einstellungen...'); try { const res = await api('/api/settings', { method: 'PUT', body: payload }); SettingsState.current = res.settings; // Theme + Branding sofort live anwenden (kein Reload noetig) if (res.settings) { applyThemeColors(res.settings.theme && res.settings.theme.colors); applyBranding(res.settings.branding); rebuildVendorIndexes(res.settings); applySetupNeededUI(isSetupIncomplete(res.settings)); } closeModal('modalSettings'); if (res.disconnected) { toast('Einstellungen gespeichert — Connection-Werte geändert, du wurdest abgemeldet.', 'warn', 'Disconnect'); State.connected = false; State.account = null; // Komplettes Frontend-State neu starten State.targets = []; State.selectedTargetIds = new Set(); State.apps = []; State.session = []; State.membershipCache.clear(); State.expandedApps.clear(); renderConnection(); renderTargets(); renderApps(); renderSession(); renderExisting(); updateStepper(); await refreshStatus(); } else { toast('Einstellungen gespeichert.', 'ok'); // Nur was sich wirklich geaendert hat neu laden — Logo/Farben/Naming // brechen keine Backend-Caches. const changed = res.changed || {}; if (changed.departments) { State.loadedModes.delete('dept'); if (State.modeCache.has('dept')) State.modeCache.delete('dept'); } if (changed.rpa) { State.loadedModes.delete('rpa'); if (State.modeCache.has('rpa')) State.modeCache.delete('rpa'); } if (State.connected && ((changed.departments && State.mode === 'dept') || (changed.rpa && State.mode === 'rpa'))) { await loadTargets(); } } } catch (e) { toast(e.message, 'err', 'Speichern fehlgeschlagen'); } finally { clearLoading(); } } async function resetSettings() { if (!window.confirm('Alle Einstellungen auf Standardwerte zurücksetzen?\n\nDu wirst dabei von Microsoft Graph abgemeldet.')) return; setLoading('Setze Einstellungen zurück...'); try { const res = await api('/api/settings/reset', { method: 'POST' }); SettingsState.current = res.settings; if (res.settings) { applyThemeColors(res.settings.theme && res.settings.theme.colors); applyBranding(res.settings.branding); rebuildVendorIndexes(res.settings); } fillSettingsForm(res.settings); applySetupNeededUI(isSetupIncomplete(res.settings)); State.connected = false; State.account = null; renderConnection(); await refreshStatus(); toast('Einstellungen zurückgesetzt.', 'ok'); } catch (e) { toast(e.message, 'err', 'Reset fehlgeschlagen'); } finally { clearLoading(); } } // Verkabelung document.getElementById('btnSettings')?.addEventListener('click', openSettingsModal); document.getElementById('btnSettingsSave')?.addEventListener('click', saveSettings); document.getElementById('btnSettingsReset')?.addEventListener('click', resetSettings); document.getElementById('btnSettingsTest')?.addEventListener('click', onSettingsTest); document.getElementById('onboardingOpenSettings')?.addEventListener('click', e => { e.preventDefault(); openSettingsModal(); }); document.getElementById('setReqPrefix')?.addEventListener('input', updateReqPreview); document.getElementById('setReqSuffix')?.addEventListener('input', updateReqPreview); document.getElementById('setAvailPrefix')?.addEventListener('input', updateAvailPreview); document.getElementById('setAvailSuffix')?.addEventListener('input', updateAvailPreview); document.getElementById('raSubmit')?.addEventListener('click', submitRenameApp); document.getElementById('raNewName')?.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); submitRenameApp(); } }); // Setup-Datei aktualisieren: Submit + Bestaetigungs-Checkbox steuert den Button document.getElementById('ucSubmit')?.addEventListener('click', submitUpdateContent); document.getElementById('ucConfirm')?.addEventListener('change', e => { const btn = document.getElementById('ucSubmit'); if (btn) btn.disabled = !e.target.checked; }); document.getElementById('ucBrowse')?.addEventListener('click', async () => { const btn = document.getElementById('ucBrowse'); const errEl = document.getElementById('ucError'); errEl.textContent = ''; btn.disabled = true; const prevLabel = btn.textContent; btn.textContent = 'Dialog offen…'; try { // Datei-Dialog auf dem Server-Rechner (Phase 1: .intunewin vorfiltern) const res = await api('/api/pickfile', { method: 'POST', body: { filter: 'Intune-Pakete (*.intunewin)|*.intunewin|Alle Dateien (*.*)|*.*', title: 'Setup-Datei auswählen' }, timeoutMs: 600000, }); if (res.path) document.getElementById('ucFilePath').value = res.path; } catch (e) { errEl.textContent = 'Datei-Dialog fehlgeschlagen: ' + e.message; } finally { btn.disabled = false; btn.textContent = prevLabel; } }); // --- Branding: Logo-Upload --- document.getElementById('btnLogoPick')?.addEventListener('click', () => { document.getElementById('logoFile').click(); }); document.getElementById('logoFile')?.addEventListener('change', async e => { const file = e.target.files && e.target.files[0]; e.target.value = ''; // gleiche Datei nochmal auswaehlbar machen if (!file) return; const maxBytes = 2 * 1024 * 1024; if (file.size > maxBytes) { toast(`Datei zu groß (${Math.round(file.size/1024)} KB, max. 2 MB)`, 'err'); return; } setLoading('Lade Logo hoch...'); try { const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = () => rej(new Error('Datei konnte nicht gelesen werden.')); r.readAsDataURL(file); }); const m = String(dataUrl).match(/^data:([^;]+);base64,(.+)$/); if (!m) throw new Error('Unerwartetes Datei-Format.'); const res = await api('/api/settings/logo', { method: 'POST', body: { name: file.name, mime: m[1], dataBase64: m[2] }, }); if (SettingsState.current) { if (!SettingsState.current.branding) SettingsState.current.branding = {}; SettingsState.current.branding.logoFile = res.logoFile; SettingsState.current.branding.logoCacheTag = res.cacheTag; } applyBranding({ logoFile: res.logoFile, logoCacheTag: res.cacheTag }); fillLogoPreview({ logoFile: res.logoFile, logoCacheTag: res.cacheTag }); toast(`Logo hochgeladen (${res.sizeKb} KB)`, 'ok'); } catch (e) { toast(e.message, 'err', 'Logo-Upload fehlgeschlagen'); } finally { clearLoading(); } }); document.getElementById('btnLogoRemove')?.addEventListener('click', async () => { if (!window.confirm('Eigenes Logo entfernen?')) return; setLoading('Entferne Logo...'); try { await api('/api/settings/logo', { method: 'DELETE' }); if (SettingsState.current && SettingsState.current.branding) { SettingsState.current.branding.logoFile = null; } applyBranding({ logoFile: null }); fillLogoPreview({ logoFile: null }); toast('Logo entfernt.', 'ok'); } catch (e) { toast(e.message, 'err', 'Entfernen fehlgeschlagen'); } finally { clearLoading(); } }); // --- Farben: Picker <-> Hex-Input synchronisieren + Live-Preview-Swatch --- function bindColorPair(key) { const cap = key[0].toUpperCase() + key.slice(1); const picker = document.getElementById(`setCol${cap}`); const text = document.getElementById(`setCol${cap}Hex`); const swatch = document.querySelector(`.color-swatch[data-swatch="${key}"]`); if (!picker || !text) return; const sync = (hex) => { if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return false; picker.value = hex.toLowerCase(); text.value = hex.toLowerCase(); if (swatch) swatch.style.background = hex; return true; }; picker.addEventListener('input', () => sync(picker.value)); text.addEventListener('input', () => { const v = text.value.trim(); if (v.length === 7 && sync(v)) { /* ok */ } else if (swatch) swatch.style.background = v || ThemeDefaults[key]; }); } ['brand','accent','required','success','warning','error','rowSelected','detailPanel'].forEach(bindColorPair); document.querySelectorAll('[data-color-reset]').forEach(btn => { btn.addEventListener('click', () => { const key = btn.dataset.colorReset; setColorInputs(key, ThemeDefaults[key]); }); }); // "Standardfarben wiederherstellen" — setzt ALLE Picker auf die Vorgabe und // wendet sie sofort auf die UI an (Live-Vorschau ohne Speichern). User klickt // dann selbst "Speichern" wenn er die Defaults persistieren will. document.getElementById('btnRestoreColors')?.addEventListener('click', () => { for (const key of Object.keys(ThemeDefaults)) { setColorInputs(key, ThemeDefaults[key]); } applyThemeColors(Object.assign({}, ThemeDefaults)); toast('Farben auf Standard zurückgesetzt — "Speichern" um zu persistieren.', 'ok'); }); // ============================================================= // Theme-Toggle // ============================================================= (function setupTheme() { const btn = document.getElementById('btnThemeToggle'); if (!btn) return; const sync = () => { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; btn.setAttribute('aria-checked', isDark ? 'true' : 'false'); btn.title = isDark ? 'Auf Light Mode wechseln' : 'Auf Dark Mode wechseln'; }; btn.addEventListener('click', () => { const cur = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; const next = cur === 'dark' ? 'light' : 'dark'; // Alle CSS-Transitions waehrend des Wechsels ausschalten — sonst animieren // hunderte Elemente gleichzeitig ihre background/color/border-Aenderungen // und alles ruckelt. document.documentElement.classList.add('theme-switching'); document.documentElement.setAttribute('data-theme', next); try { localStorage.setItem('iam-theme', next); } catch {} sync(); // Im naechsten Frame Transitions wieder aktivieren requestAnimationFrame(() => { requestAnimationFrame(() => { document.documentElement.classList.remove('theme-switching'); }); }); }); sync(); })(); // ============================================================= // Init // ============================================================= (async function init() { renderConnection(); renderTargets(); renderApps(); renderSession(); renderExisting(); // Branding + Theme so frueh wie moeglich anwenden, damit nichts mit den // Default-Farben kurz aufblitzt. Connection-Status danach. let setupNeeded = false; try { const res = await api('/api/settings'); SettingsState.current = res.settings; SettingsState.filePath = res.path; applyThemeColors(res.settings && res.settings.theme && res.settings.theme.colors); applyBranding(res.settings && res.settings.branding); rebuildVendorIndexes(res.settings); setupNeeded = isSetupIncomplete(res.settings); applySetupNeededUI(setupNeeded); } catch (e) { console.warn('Settings konnten beim Init nicht geladen werden:', e.message); } await refreshStatus(); if (State.connected) autoLoadAfterConnect(); // First-Run: wenn kritische Felder leer sind, Settings-Modal automatisch oeffnen. if (setupNeeded) { setTimeout(() => openSettingsModal(), 200); } })(); // ============================================================= // Gruppen-Export // ============================================================= const GexState = { searchResults: [], selectedGroup: null, members: null, memberFilter: '', }; function openGroupExportModal() { if (!State.connected) { toast('Bitte zuerst verbinden.', 'warn'); return; } GexState.searchResults = []; GexState.selectedGroup = null; GexState.members = null; GexState.memberFilter = ''; document.getElementById('gexSearch').value = ''; document.getElementById('gexMemberFilter').value = ''; document.getElementById('gexSearchResults').innerHTML = '
    Mindestens 2 Zeichen eingeben.
    '; document.getElementById('gexMembersArea').innerHTML = '
    Gruppe auswählen um Mitglieder zu laden.
    '; document.getElementById('gexMembersTitle').textContent = 'Mitglieder'; document.getElementById('gexStats').textContent = ''; document.getElementById('gexExportBtn').disabled = true; document.getElementById('modalGroupExport').classList.remove('hidden'); setTimeout(() => document.getElementById('gexSearch').focus(), 80); } document.getElementById('btnGroupExport')?.addEventListener('click', openGroupExportModal); // Suche mit Debounce let _gexSearchTimer = null; document.getElementById('gexSearch')?.addEventListener('input', e => { clearTimeout(_gexSearchTimer); const q = e.target.value.trim(); if (q.length < 2) { GexState.searchResults = []; document.getElementById('gexSearchResults').innerHTML = '
    Mindestens 2 Zeichen eingeben.
    '; return; } document.getElementById('gexSearchResults').innerHTML = '
    Suche…
    '; _gexSearchTimer = setTimeout(() => _gexDoSearch(q), 350); }); async function _gexDoSearch(q) { try { const data = await api(`/api/groups/search?q=${encodeURIComponent(q)}`); GexState.searchResults = data.items || []; _gexRenderResults(); } catch (e) { document.getElementById('gexSearchResults').innerHTML = `
    Suche fehlgeschlagen: ${escapeHtml(e.message)}
    `; } } function _gexRenderResults() { const el = document.getElementById('gexSearchResults'); if (!el) return; const items = GexState.searchResults; if (items.length === 0) { el.innerHTML = '
    Keine Gruppen gefunden.
    '; return; } el.innerHTML = items.map(g => `
    ${escapeHtml(g.DisplayName)}
    ${g.Description ? `
    ${escapeHtml(g.Description)}
    ` : ''}
    `).join(''); el.querySelectorAll('.gex-group-item').forEach(item => { item.addEventListener('click', () => _gexSelectGroup(item.dataset.id, item.dataset.name)); }); } async function _gexSelectGroup(id, name) { GexState.selectedGroup = { Id: id, DisplayName: name }; GexState.members = null; GexState.memberFilter = ''; document.getElementById('gexMemberFilter').value = ''; _gexRenderResults(); // aktualisiert selected-Highlight document.getElementById('gexMembersTitle').textContent = `Mitglieder — ${name}`; document.getElementById('gexStats').textContent = ''; document.getElementById('gexExportBtn').disabled = true; document.getElementById('gexMembersArea').innerHTML = '
    Mitglieder werden aufgelöst (inkl. verschachtelte Gruppen)…
    '; try { const data = await api(`/api/groups/${encodeURIComponent(id)}/members/export`); GexState.members = data; _gexRenderMembers(); document.getElementById('gexExportBtn').disabled = data.count === 0; let stats = `${data.count} Benutzer`; if (data.duplicates > 0) stats += ` · ${data.duplicates} Duplikate aus Verschachtelung entfernt`; stats += ` · ${data.resolvedMs}ms`; document.getElementById('gexStats').textContent = stats; } catch (e) { document.getElementById('gexMembersArea').innerHTML = `
    Fehler beim Laden: ${escapeHtml(e.message)}
    `; } } // Live-Filter für die Mitgliederliste document.getElementById('gexMemberFilter')?.addEventListener('input', e => { GexState.memberFilter = e.target.value.trim().toLowerCase(); _gexApplyFilter(); }); function _gexApplyFilter() { const q = GexState.memberFilter; document.querySelectorAll('#gexMembersArea .gex-user-item').forEach(row => { const text = row.dataset.search || ''; row.classList.toggle('hidden-filter', q !== '' && !text.includes(q)); }); // Pfad-Gruppen ohne sichtbare Zeilen ausblenden document.querySelectorAll('#gexMembersArea .gex-path-group').forEach(pg => { const visible = pg.querySelectorAll('.gex-user-item:not(.hidden-filter)').length > 0; pg.style.display = visible ? '' : 'none'; }); } function _gexRenderMembers() { const area = document.getElementById('gexMembersArea'); if (!area || !GexState.members) return; const m = GexState.members; if (m.count === 0) { area.innerHTML = '
    Keine Benutzer in dieser Gruppe gefunden.
    '; return; } // Nach SourcePath gruppieren const byPath = new Map(); for (const u of m.users) { const p = u.SourcePath || m.groupName; if (!byPath.has(p)) byPath.set(p, []); byPath.get(p).push(u); } let html = `
    ${m.count} Benutzer`; if (m.duplicates > 0) html += ` (${m.duplicates} Duplikate aus verschachtelten Gruppen zusammengeführt)`; html += '
    '; for (const [path, users] of byPath.entries()) { html += `
    ${escapeHtml(path)} ${users.length}
    `; for (const u of users) { const upn = u.UserPrincipalName || u.Mail || u.Id; const searchKey = `${(u.DisplayName || '').toLowerCase()} ${upn.toLowerCase()}`; html += `
    ${escapeHtml(u.DisplayName || '—')} ${escapeHtml(upn)}
    `; } html += '
    '; } area.innerHTML = html; if (GexState.memberFilter) _gexApplyFilter(); } document.getElementById('gexExportBtn')?.addEventListener('click', () => { if (!GexState.members?.users) return; const m = GexState.members; const rows = [['DisplayName', 'UserPrincipalName', 'Mail', 'SourcePath', 'GroupId', 'GroupName']]; for (const u of m.users) { rows.push([ u.DisplayName || '', u.UserPrincipalName || '', u.Mail || '', u.SourcePath || m.groupName, m.groupId, m.groupName, ]); } const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',') ).join('\r\n'); const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${m.groupName.replace(/[^a-zA-Z0-9_-]/g, '_')}-members.csv`; a.click(); URL.revokeObjectURL(url); toast(`${m.count} Benutzer exportiert.`, 'ok'); });