// ============================================================= // Intune Manager — Frontend // Workflow: Connect → Select Targets → Pick Apps → Apply // ============================================================= const State = { connected: false, readOnly: false, account: null, mode: 'dept', // 'dept' | 'rpa' | 'user' targets: [], selectedTargetIds: new Set(), apps: [], appFilter: { search: '', preset: 'all', platform: '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); } catch (e) { // "Failed to fetch" = der lokale Server hat nicht geantwortet (Prozess // beendet/abgestürzt oder PowerShell-Fenster geschlossen). Klartext geben. if (e && (e.name === 'AbortError')) throw e; throw new Error('Server nicht erreichbar — läuft das PowerShell-Fenster (Run.cmd) noch? Bitte die App neu starten und erneut versuchen.'); } 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; State.readOnly = !!s.readOnly; applyReadOnlyMode(); renderConnection(); } catch (e) { toast('Status konnte nicht geladen werden: ' + e.message, 'err'); } } // Topbar-Mandantenumschalter: nur im Multi-Tenant-Modus sichtbar. async function refreshTenantSwitcher() { const sel = document.getElementById('tenantSwitch'); if (!sel) return; try { const t = await api('/api/tenants'); if (!t.multiTenant || !(t.items || []).length) { sel.classList.add('hidden'); sel.innerHTML = ''; return; } sel.innerHTML = t.items.map(it => `` ).join(''); sel.classList.remove('hidden'); } catch { sel.classList.add('hidden'); } } document.getElementById('tenantSwitch')?.addEventListener('change', async e => { const id = e.target.value; setLoading('Wechsle Mandant…'); try { // Löst server-seitig Disconnect + Reconnect mit dem neuen Tenant aus. const s = await api('/api/tenants/switch', { method: 'POST', body: { id }, timeoutMs: 120000 }); State.connected = !!s.connected; State.account = s.account; State.readOnly = !!s.readOnly; applyReadOnlyMode(); renderConnection(); // Anderer Tenant = andere Daten: ALLE datentragenden Frontend-Caches // verwerfen, sonst ueberspringen die Load-Guards (appsLoaded/loadedModes) // das Neuladen und die UI zeigt weiter den vorherigen Tenant. if (typeof stopDevicePolling === 'function') stopDevicePolling(); resetClientState(); switchMainView('apps'); if (s.connected) { toast('Verbunden mit ' + (s.tenantLabel || s.account || 'Mandant'), 'ok', 'Mandant gewechselt'); autoLoadAfterConnect(); } else { toast('Umgestellt, aber nicht verbunden.', 'warn'); } } catch (err) { toast('Wechsel fehlgeschlagen: ' + err.message, 'err'); await refreshStatus(); } finally { clearLoading(); refreshTenantSwitcher(); } }); function applyReadOnlyMode() { document.body.classList.toggle('read-only', State.readOnly); // Falls ein Schreib-Tab aktiv ist beim Wechsel in Read-Only -> Export-Tab zeigen if (State.readOnly) { const activeTab = document.querySelector('.gex-tab.active'); if (activeTab && (activeTab.dataset.tab === 'add' || activeTab.dataset.tab === 'import')) { if (typeof switchGexTab === 'function') switchGexTab('export'); } } } // 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; State.readOnly = !!s.readOnly; applyReadOnlyMode(); renderConnection(); const roHint = State.readOnly ? ' (Read Only)' : ''; toast('Verbunden als ' + s.account + roHint, '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 }); resetClientState(); renderConnection(); renderTargets(); renderPinnedTargets(); renderApps(); renderSession(); renderExisting(); toast('Verbindung getrennt', 'info'); } finally { clearLoading(); } }); // Alle datentragenden Frontend-Caches verwerfen (Apps, Targets, Gruppen- // Mitglieder, Report, Policies, Devices). Beruehrt NICHT connected/account — // das setzt der jeweilige Aufrufer (Disconnect vs. Tenant-Wechsel) selbst. // Wird beim Tenant-Wechsel benoetigt, sonst zeigen die Load-Guards // (appsLoaded / loadedModes) noch die Daten des vorherigen Tenants. function resetClientState() { Object.assign(State, { targets: [], apps: [], session: [], appsLoaded: false }); State.selectedTargetIds.clear(); State.membershipCache.clear(); State.loadedModes.clear(); State.appDetails.clear(); State.expandedApps.clear(); State.modeCache.clear(); State.expandedGroups = new Map(); if (typeof ReportState !== 'undefined') ReportState.items = []; if (typeof PolState !== 'undefined') { PolState.items = []; PolState.selected?.clear?.(); } if (typeof DevState !== 'undefined') { DevState.items = []; DevState.filtered = []; DevState.selected = null; } } 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 if (newMode === 'dept') { // Gruppen-Modus: Live-Suche ueber ALLE Gruppen (min. 2 Zeichen), kein Praefix-Filter search.placeholder = 'Gruppe suchen...'; hint.classList.remove('hidden'); 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 fuer Listen-Modi (RPA). 'dept' (Gruppensuche) und 'user' // laden erst bei Sucheingabe. if (State.connected && newMode === 'rpa' && !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 if (State.mode === 'dept') { const term = document.getElementById('targetSearch').value; if (!term || term.length < 2) { document.getElementById('targetSearch').focus(); toast('Tippe mind. 2 Zeichen für die Gruppensuche', 'warn'); return; } runGroupSearch(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 if (State.mode === 'dept') { debouncedGroupSearch(e.target.value); } else { renderTargets(); } }); // Enter loest die Server-Suche sofort aus (Benutzer- und Gruppen-Modus) document.getElementById('targetSearch').addEventListener('keydown', e => { if (e.key !== 'Enter') return; const term = e.target.value; if (term && term.length >= 2) { if (State.mode === 'user') runUserSearch(term); else if (State.mode === 'dept') runGroupSearch(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(); } } // ── Gruppen-Live-Suche: durchsucht ALLE Gruppen (nicht nur Praefix-Gruppen) ── let groupSearchTimer = null; let groupSearchAbort = null; let groupSearchCache = { query: '', results: [] }; // Bereits ausgewaehlte Gruppen in ein neues Ergebnis mergen, damit die Auswahl // (und die Namen in der Pinned-Leiste) ueber mehrere Suchen hinweg bestehen bleibt. function mergeSelectedGroups(results) { const ids = new Set(results.map(r => r.Id)); const keep = State.targets.filter(t => State.selectedTargetIds.has(t.Id) && !ids.has(t.Id)); return keep.length ? results.concat(keep) : results; } function debouncedGroupSearch(term) { clearTimeout(groupSearchTimer); const t = (term || '').trim(); if (t.length < 2) { // Unter 2 Zeichen: nur die ausgewaehlten Gruppen stehen lassen (Auswahl behalten) State.targets = State.targets.filter(x => State.selectedTargetIds.has(x.Id)); groupSearchCache = { query: '', results: [] }; renderTargets(); return; } // Optimistisch lokal verfeinern, wenn die neue Anfrage die vorige praezisiert const lower = t.toLowerCase(); const cachedLower = groupSearchCache.query.toLowerCase(); if (cachedLower && lower.startsWith(cachedLower) && groupSearchCache.results.length > 0) { const filtered = groupSearchCache.results.filter(g => (g.DisplayName || '').toLowerCase().includes(lower)); State.targets = mergeSelectedGroups(filtered); renderTargets(); return; } groupSearchTimer = setTimeout(() => runGroupSearch(t), 220); } async function runGroupSearch(term) { if (!State.connected) { toast('Bitte zuerst verbinden', 'warn'); return; } if (groupSearchAbort) { try { groupSearchAbort.abort(); } catch {} } groupSearchAbort = new AbortController(); const signal = groupSearchAbort.signal; document.getElementById('targetList').innerHTML = `
`; try { const resp = await fetch('/api/groups/search?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}`); const results = data.items || []; groupSearchCache = { query: term, results: results.slice() }; State.targets = mergeSelectedGroups(results); renderTargets(); onTargetSelectionChange(); if (results.length === 0) toast(`Keine Gruppen für "${term}" gefunden`, 'warn'); } catch (e) { if (e.name === 'AbortError') return; // neue Suche laeuft schon toast(e.message, 'err', 'Gruppensuche 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); } // 'dept' (Gruppen) ist suchbasiert — kein Listen-Load. Bei vorhandener Eingabe // die Suche ausloesen, sonst nichts tun (auch beim Auto-Load nach Connect). if (State.mode === 'dept') { const term = document.getElementById('targetSearch').value; if (term && term.length >= 2) return runGroupSearch(term); return; } const requestedMode = State.mode; if (loadTargetsInFlight === requestedMode) return; // gleiche Anfrage laeuft bereits loadTargetsInFlight = requestedMode; if (!opts.silent) setLoading('Lade ' + (requestedMode === 'rpa' ? 'RPA-Gruppen' : 'Gruppen') + '...'); 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' : 'Gruppen'} 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(); // 'user' und 'dept' werden serverseitig gesucht -> State.targets ist bereits das Ergebnis. if (!q || State.mode === 'user' || State.mode === 'dept') 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' || State.mode === 'dept') { const term = document.getElementById('targetSearch').value; const isUser = State.mode === 'user'; const has2 = term && term.length >= 2; list.innerHTML = emptyState({ title: has2 ? 'Keine Treffer' : (isUser ? 'Benutzer suchen' : 'Gruppe suchen'), text: has2 ? `Keine ${isUser ? 'Benutzer' : 'Gruppen'} für "${escapeHtml(term)}" gefunden.` : (isUser ? 'Tippe mind. 2 Zeichen — Name, UPN oder E-Mail.' : 'Tippe mind. 2 Zeichen — durchsucht alle Gruppen im Tenant.'), }); } 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: 'Grp', 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('appPlatformFilter')?.addEventListener('change', e => { State.appFilter.platform = 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)); // Plattform-Filter ueber den rohen @odata.type: macOS-Typen enthalten "macos" // (z.B. macOSDmgApp, macOsVppApp), alles uebrige ist Windows (iOS/Android sind // bereits serverseitig ausgeschlossen). if (f.platform && f.platform !== 'all') { const isMac = a => (a.AppTypeRaw || '').toLowerCase().includes('macos'); list = f.platform === 'macos' ? list.filter(isMac) : list.filter(a => !isMac(a)); } 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 removableReq = app.RequiredGroups.filter(g => !g.IsNative); const removableAvail = app.AvailableGroups.filter(g => !g.IsNative); const mkRemoveBtn = (groups, type, label) => groups.length > 0 ? `` : ``; const removeReqBtn = mkRemoveBtn(removableReq, 'required', 'R'); const removeAvailBtn = mkRemoveBtn(removableAvail, 'available', 'A'); 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}
${removeReqBtn}
${removeAvailBtn}
${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 = ''; } // Plattform-Filter zuruecksetzen, sonst koennte die Ziel-App ausgeblendet sein if (State.appFilter.platform !== 'all') { State.appFilter.platform = 'all'; const pf = document.getElementById('appPlatformFilter'); if (pf) pf.value = 'all'; } // 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-remove-group]').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const app = State.apps.find(a => a.AppId === btn.dataset.appId); if (!app) return; const type = btn.dataset.removeType; // 'required' | 'available' const src = type === 'required' ? app.RequiredGroups : app.AvailableGroups; const groups = src.filter(g => !g.IsNative).map(g => ({ ...g, _type: type })); if (groups.length === 0) return; if (groups.length === 1) { removeAssignmentWithConfirm({ appId: app.AppId, appName: app.AppName, groupId: groups[0].GroupId, groupName: groups[0].GroupName, type, }); return; } showRemoveGroupPicker(btn, app, groups); }); }); 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); } function showRemoveGroupPicker(anchorEl, app, groups) { closeGroupPicker(); pickerEl = document.createElement('div'); pickerEl.className = 'group-picker picker-remove'; pickerEl.innerHTML = `
    Gruppe entfernen — ${escapeHtml(app.AppName)}
    ${groups.map(g => { const badge = g._type === 'required' ? `Req` : `Avail`; return `
    `; }).join('')}
    `; document.body.appendChild(pickerEl); 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-remove-item').forEach(item => { item.addEventListener('click', e => { e.stopPropagation(); const ctx = { appId: app.AppId, appName: app.AppName, groupId: item.dataset.groupId, groupName: item.dataset.groupName, type: item.dataset.type, }; closeGroupPicker(); removeAssignmentWithConfirm(ctx); }); }); 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 Gruppen 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 Gruppen
    • ` : ''} ${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). // Aktives Tenant-Profil aus einem Settings-Objekt (oder null im Single-Modus). function activeTenantProfileFromSettings(s) { const c = s && s.connection; if (!c || !c.multiTenant || !Array.isArray(c.tenants) || !c.tenants.length) return null; return c.tenants.find(t => t.id === c.activeTenantId) || c.tenants[0]; } function getNamingFor(intent) { const s = SettingsState && SettingsState.current; const isAvail = intent === 'available'; const defPrefix = 'intune-win-app-'; const defSuffix = isAvail ? '-available' : '-required'; const gBlock = s && (isAvail ? s.availableGroupNaming : s.requiredGroupNaming); let prefix = (gBlock && gBlock.prefix) || defPrefix; let suffix = (gBlock && gBlock.suffix) || defSuffix; // Aktives Tenant-Profil kann pro Feld überschreiben (leer = globale Vorgabe). const prof = activeTenantProfileFromSettings(s); if (prof) { const pBlock = isAvail ? prof.availableGroupNaming : prof.requiredGroupNaming; if (pBlock) { if (pBlock.prefix && String(pBlock.prefix).trim()) prefix = pBlock.prefix; if (pBlock.suffix && String(pBlock.suffix).trim()) suffix = pBlock.suffix; } } return { prefix, suffix }; } 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, isExisting, target } — single source of truth function cgModeMeta(mode) { switch (mode) { case 'req-group': return { intent: 'required', isGroup: true, isExisting: false, target: null }; case 'req-existing': return { intent: 'required', isGroup: false, isExisting: true, target: null }; case 'req-allusers': return { intent: 'required', isGroup: false, isExisting: false, target: 'ALL_USERS' }; case 'req-alldevices': return { intent: 'required', isGroup: false, isExisting: false, target: 'ALL_DEVICES' }; case 'avail-group': return { intent: 'available', isGroup: true, isExisting: false, target: null }; case 'avail-existing': return { intent: 'available', isGroup: false, isExisting: true, target: null }; case 'avail-allusers': return { intent: 'available', isGroup: false, isExisting: false, target: 'ALL_USERS' }; case 'avail-alldevices':return { intent: 'available', isGroup: false, isExisting: false, target: 'ALL_DEVICES' }; } return { intent: 'required', isGroup: true, isExisting: false, 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; resetCgExisting(); 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 existingGroup = document.getElementById('cgExistingGroup'); const createBtn = document.getElementById('cgCreate'); const intentLabel = meta.intent === 'available' ? 'Available' : 'Required'; if (meta.isExisting) { // Bestehende Gruppe(n) suchen + zuweisen (Mehrfachauswahl) nameGroup.hidden = true; assignWrap.hidden = true; if (existingGroup) existingGroup.hidden = false; renderCgSelected(); // setzt Button-Label + disabled anhand der Auswahl } else if (meta.isGroup) { nameGroup.hidden = false; assignWrap.hidden = false; if (existingGroup) existingGroup.hidden = true; 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; if (existingGroup) existingGroup.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); }); // ── "Bestehende Gruppe" — Mehrfach-Suche + -Auswahl im Zuweisungs-Dialog ── let cgExistingTimer = null; let cgExistingAbort = null; function cgSelectedGroups() { if (!createGroupContext) return []; if (!Array.isArray(createGroupContext.selectedGroups)) createGroupContext.selectedGroups = []; return createGroupContext.selectedGroups; } function resetCgExisting() { if (createGroupContext) createGroupContext.selectedGroups = []; const s = document.getElementById('cgExistingSearch'); const r = document.getElementById('cgExistingResults'); if (s) s.value = ''; if (r) r.innerHTML = ''; renderCgSelected(); } // Ausgewaehlte Gruppen als entfernbare Chips zeigen + Button-Status/Label setzen. function renderCgSelected() { const sel = document.getElementById('cgExistingSelected'); const groups = cgSelectedGroups(); if (sel) { sel.className = 'cg-existing-chips'; sel.innerHTML = groups.length === 0 ? 'Noch keine Gruppe ausgewählt.' : groups.map(g => ` ${escapeHtml(g.name)} `).join(''); } // Nur im "Bestehende Gruppe"-Modus den Erstellen/Zuweisen-Button steuern. const meta = cgModeMeta(getCurrentCgMode()); if (meta.isExisting) { const btn = document.getElementById('cgCreate'); const intentLabel = meta.intent === 'available' ? 'Available' : 'Required'; btn.disabled = groups.length === 0; btn.textContent = groups.length > 1 ? `${groups.length} Gruppen als ${intentLabel} zuweisen` : `Als ${intentLabel} zuweisen`; } } document.getElementById('cgExistingSearch')?.addEventListener('input', e => { clearTimeout(cgExistingTimer); const term = e.target.value.trim(); const results = document.getElementById('cgExistingResults'); if (term.length < 2) { if (results) results.innerHTML = ''; return; } cgExistingTimer = setTimeout(() => runCgExistingSearch(term), 250); }); async function runCgExistingSearch(term) { const results = document.getElementById('cgExistingResults'); if (!results) return; if (cgExistingAbort) { try { cgExistingAbort.abort(); } catch {} } cgExistingAbort = new AbortController(); results.innerHTML = '
    Suche…
    '; try { const resp = await fetch('/api/groups/search?q=' + encodeURIComponent(term), { signal: cgExistingAbort.signal }); let data = {}; try { data = await resp.json(); } catch {} if (cgExistingAbort.signal.aborted) return; if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); const items = data.items || []; if (items.length === 0) { results.innerHTML = '
    Keine Treffer
    '; return; } const selIds = new Set(cgSelectedGroups().map(g => g.id)); results.innerHTML = items.map(g => ``).join(''); } catch (e) { if (e.name === 'AbortError') return; results.innerHTML = `
    Fehler: ${escapeHtml(e.message)}
    `; } } // Ergebnis anklicken -> Auswahl togglen (Mehrfachauswahl) document.getElementById('cgExistingResults')?.addEventListener('click', e => { const btn = e.target.closest('.cg-existing-item'); if (!btn) return; const id = btn.dataset.id, name = btn.dataset.name; const groups = cgSelectedGroups(); const idx = groups.findIndex(g => g.id === id); if (idx >= 0) { groups.splice(idx, 1); btn.classList.remove('is-selected'); } else { groups.push({ id, name }); btn.classList.add('is-selected'); } renderCgSelected(); }); // Chip entfernen -> Auswahl abwaehlen (und ggf. Ergebnis-Item entmarkieren) document.getElementById('cgExistingSelected')?.addEventListener('click', e => { const x = e.target.closest('[data-remove-id]'); if (!x) return; const id = x.dataset.removeId; const groups = cgSelectedGroups(); const idx = groups.findIndex(g => g.id === id); if (idx >= 0) groups.splice(idx, 1); document.querySelectorAll('.cg-existing-item').forEach(it => { if (it.dataset.id === id) it.classList.remove('is-selected'); }); renderCgSelected(); }); 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.isExisting) { // Bestehende Gruppe(n) zuweisen — EIN Request mit allen Zielen. Das Backend // mergt die neuen Gruppen mit den bestehenden Zuweisungen (die /assign-Action // ersetzt sonst die komplette Liste -> nur die letzte Gruppe bliebe uebrig). const groups = cgSelectedGroups(); if (groups.length === 0) { toast('Bitte zuerst mind. eine Gruppe auswählen', 'warn'); return; } const intentLabel = meta.intent === 'available' ? 'Available' : 'Required'; setLoading(`Weise ${groups.length} Gruppe(n) als ${intentLabel} zu...`); try { await api(`/api/apps/${encodeURIComponent(createGroupContext.appId)}/assignments`, { method: 'POST', body: { intent: meta.intent, targets: groups.map(g => g.id) }, }); toast(`${groups.length} ${intentLabel}-Zuweisung(en) hinzugefügt`, 'ok'); closeModal('modalCreateGroup'); await loadApps(true); } catch (e) { toast(e.message, 'err', 'Zuweisung fehlgeschlagen'); } finally { clearLoading(); } return; } 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 — Gruppen-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); const n = mmContext.selectedIds.size; document.getElementById('mmInfo').textContent = n === 0 ? 'Keine ausgewählt' : `${n} ausgewählt`; document.getElementById('mmRemove').disabled = n === 0; }); }); } 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('mmRemove').disabled = true; }); document.getElementById('mmRemove')?.addEventListener('click', async () => { const n = mmContext.selectedIds.size; if (n === 0) return; const names = mmContext.members .filter(m => mmContext.selectedIds.has(m.Id)) .map(m => m.DisplayName || m.UserPrincipalName) .slice(0, 5).join('\n'); const more = n > 5 ? `\n… und ${n - 5} weitere` : ''; if (!confirm(`${n} Mitglied${n > 1 ? 'er' : ''} aus "${mmContext.groupName}" entfernen?\n\n${names}${more}`)) return; const btn = document.getElementById('mmRemove'); btn.disabled = true; btn.textContent = 'Entferne…'; const ids = [...mmContext.selectedIds]; let ok = 0, fail = 0; for (const userId of ids) { try { await api(`/api/groups/${encodeURIComponent(mmContext.groupId)}/members/${encodeURIComponent(userId)}`, { method: 'DELETE' }); mmContext.members = mmContext.members.filter(m => m.Id !== userId); mmContext.selectedIds.delete(userId); ok++; } catch (e) { fail++; console.error('Remove member failed:', userId, e); } } btn.textContent = 'Aus Gruppe entfernen'; document.getElementById('mmInfo').textContent = 'Keine ausgewählt'; renderMembersList(); if (fail === 0) toast(`${ok} Mitglied${ok > 1 ? 'er' : ''} entfernt`, 'ok'); else toast(`${ok} entfernt, ${fail} fehlgeschlagen`, 'warn'); }); 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} · Entwickelt von WendeIT – Marco Wende`; } 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 tenant = (c.tenantId || '').trim(); const client = (c.clientId || '').trim(); // Nur Tenant ID + Client ID sind Pflicht fuer eine Verbindung. Abteilungs- // Praefixe und RPA-Gruppen sind optional — fehlen sie, zeigen die jeweiligen // Tabs lediglich einen Konfigurations-Hinweis (kein harter Setup-Blocker). return !tenant || !client; } 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 || '')}
    `); } // RPA-Funktion ist deaktiviert — Test-Ergebnis dazu wird nicht angezeigt. 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(''); } // --- Multi-Tenant Profil-Editor (Einstellungen) --- function tenantNewId() { return (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : 't-' + Date.now() + '-' + Math.random().toString(16).slice(2, 8); } function applyConnModeUI(mode) { const multi = mode === 'multi'; document.getElementById('setSingleConn').classList.toggle('hidden', multi); document.getElementById('setMultiConn').classList.toggle('hidden', !multi); } function tenantRowHtml(t, active) { const id = t.id || tenantNewId(); return `
    `; } const TENANT_EMPTY_HINT = '
    Noch keine Tenants. Füge unten einen hinzu.
    '; function renderTenantRows(tenants, activeId) { const el = document.getElementById('setTenantList'); const list = Array.isArray(tenants) ? tenants.filter(Boolean) : []; let active = activeId; if (!list.some(t => t.id === active) && list.length) active = list[0].id; el.innerHTML = list.length ? list.map(t => tenantRowHtml(t, t.id === active)).join('') : TENANT_EMPTY_HINT; wireTenantRowEvents(); } function wireTenantRowEvents() { document.querySelectorAll('#setTenantList .tenant-remove').forEach(btn => { btn.onclick = () => { const row = btn.closest('.tenant-row'); const wasActive = row.querySelector('input[name="tenantActive"]').checked; row.remove(); if (wasActive) { const first = document.querySelector('#setTenantList input[name="tenantActive"]'); if (first) first.checked = true; } if (!document.querySelector('#setTenantList .tenant-row')) { document.getElementById('setTenantList').innerHTML = TENANT_EMPTY_HINT; } }; }); } function addTenantRow() { const el = document.getElementById('setTenantList'); if (!el.querySelector('.tenant-row')) el.innerHTML = ''; const id = tenantNewId(); const isFirst = !el.querySelector('.tenant-row'); el.insertAdjacentHTML('beforeend', tenantRowHtml({ id }, isFirst)); wireTenantRowEvents(); el.querySelector(`.tenant-row[data-id="${CSS.escape(id)}"] .tenant-label`)?.focus(); } function tenantSplitLines(txt) { return (txt || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean); } function readTenantRows() { const tenants = []; let activeTenantId = ''; document.querySelectorAll('#setTenantList .tenant-row').forEach(row => { const id = row.dataset.id; tenants.push({ id, label: row.querySelector('.tenant-label').value.trim(), tenantId: row.querySelector('.tenant-tid').value.trim(), clientId: row.querySelector('.tenant-cid').value.trim(), clientIdRo: row.querySelector('.tenant-cidro').value.trim(), // Pro-Tenant-Overrides; leer = globale Vorgabe gilt. prefixes: tenantSplitLines(row.querySelector('.tenant-prefixes').value), requiredGroupNaming: { prefix: row.querySelector('.tenant-req-prefix').value.trim(), suffix: row.querySelector('.tenant-req-suffix').value.trim(), }, availableGroupNaming: { prefix: row.querySelector('.tenant-avail-prefix').value.trim(), suffix: row.querySelector('.tenant-avail-suffix').value.trim(), }, }); if (row.querySelector('input[name="tenantActive"]').checked) activeTenantId = id; }); if (!activeTenantId && tenants.length) activeTenantId = tenants[0].id; return { tenants, activeTenantId }; } document.getElementById('setConnMode')?.addEventListener('change', e => { applyConnModeUI(e.target.value); // Beim erstmaligen Wechsel auf Multi ohne Profile: aus den Single-Feldern ein Profil vorbefüllen if (e.target.value === 'multi' && !document.querySelector('#setTenantList .tenant-row')) { const tid = document.getElementById('setTenantId').value.trim(); const cid = document.getElementById('setClientId').value.trim(); if (tid || cid) { renderTenantRows([{ id: tenantNewId(), label: 'Standard', tenantId: tid, clientId: cid, clientIdRo: document.getElementById('setClientIdRo').value.trim(), }], null); } } }); document.getElementById('btnAddTenant')?.addEventListener('click', addTenantRow); function fillSettingsForm(s) { const c = s.connection || {}; document.getElementById('setTenantId').value = c.tenantId || ''; document.getElementById('setClientId').value = c.clientId || ''; document.getElementById('setClientIdRo').value = c.clientIdRo || ''; document.getElementById('setScopes').value = (c.scopes || []).join('\n'); document.getElementById('setScopesRo').value = (c.scopesRo || []).join('\n'); // Verbindungsmodus + Multi-Tenant-Profile const mode = c.multiTenant ? 'multi' : 'single'; document.getElementById('setConnMode').value = mode; renderTenantRows(Array.isArray(c.tenants) ? c.tenants : [], c.activeTenantId || ''); applyConnModeUI(mode); // 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'); })(); 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(); // Policy-Backup (Git) const pb = s.policyBackup || {}; document.getElementById('setPolicyGitPath').value = pb.gitRepoPath || ''; document.getElementById('setPolicyGitPush').checked = !!pb.push; // 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'); const mode = document.getElementById('setConnMode').value; const multiTenant = mode === 'multi'; const connection = { multiTenant, scopes: splitLines(document.getElementById('setScopes').value), scopesRo: splitLines(document.getElementById('setScopesRo').value), }; if (multiTenant) { const { tenants, activeTenantId } = readTenantRows(); connection.tenants = tenants; connection.activeTenantId = activeTenantId; // Aktives Profil in die flachen Felder spiegeln (Rückwärts-Kompatibilität; // Status-Anzeige und evtl. Alt-Code lesen weiter connection.tenantId/clientId). const active = tenants.find(t => t.id === activeTenantId) || tenants[0] || {}; connection.tenantId = active.tenantId || ''; connection.clientId = active.clientId || ''; connection.clientIdRo = active.clientIdRo || ''; } else { connection.tenantId = document.getElementById('setTenantId').value.trim(); connection.clientId = document.getElementById('setClientId').value.trim(); connection.clientIdRo = document.getElementById('setClientIdRo').value.trim(); // tenants/activeTenantId nicht mitschicken -> Merge behält vorhandene Profile. } return { connection, 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: '', }, 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, }, }, policyBackup: { gitRepoPath: document.getElementById('setPolicyGitPath').value.trim(), push: document.getElementById('setPolicyGitPush').checked, }, }; } 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; refreshTenantSwitcher(); // Modus/Profil-Änderungen im Topbar-Umschalter spiegeln // 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 // ============================================================= // Prueft (nicht-blockierend) auf eine neuere Release-Version und zeigt bei Bedarf // das Update-Badge in der Kopfzeile mit Link zur Release-Seite. async function checkForUpdate() { try { const res = await api('/api/update/check'); if (res && res.updateAvailable && res.releaseUrl) { const badge = document.getElementById('updateBadge'); const txt = document.getElementById('updateBadgeText'); if (badge && txt) { txt.textContent = `Update ${res.latest}`; badge.href = res.releaseUrl; badge.title = `Neue Version ${res.latest} verfügbar (aktuell ${res.current}) — Release-Seite öffnen`; badge.classList.remove('hidden'); } } } catch (e) { /* still: Update-Check ist optional */ } } (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(); refreshTenantSwitcher(); checkForUpdate(); // nicht-blockierend 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: '', activeTab: 'export', userSearchResults: [], selectedUsers: new Map(), addMode: 'user', // 'user' | 'group' — was im "Hinzufuegen"-Pane gesucht/addiert wird }; function openGroupExportModal() { if (!State.connected) { toast('Bitte zuerst verbinden.', 'warn'); return; } GexState.searchResults = []; GexState.selectedGroup = null; GexState.members = null; GexState.memberFilter = ''; GexState.activeTab = 'export'; GexState.userSearchResults = []; GexState.selectedUsers = new Map(); 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; // Add-Tab zurücksetzen (Modus auf Benutzer) GexState.addMode = 'user'; document.querySelectorAll('.gex-addmode-btn').forEach(b => b.classList.toggle('active', b.dataset.addmode === 'user')); const gexAddTitle = document.getElementById('gexAddSearchTitle'); if (gexAddTitle) gexAddTitle.textContent = 'Benutzer suchen'; const gexUS = document.getElementById('gexUserSearch'); if (gexUS) gexUS.placeholder = 'Name, UPN oder E-Mail…'; document.getElementById('gexUserSearch').value = ''; document.getElementById('gexUserResults').innerHTML = '
    Mindestens 2 Zeichen eingeben.
    '; document.getElementById('gexSelectedUsers').innerHTML = '
    Noch keine Benutzer ausgewählt.
    '; document.getElementById('gexSelectedCount').textContent = '0'; document.getElementById('gexAddBtn').disabled = true; // Tab-Anzeige auf Export zurücksetzen _gexSwitchTab('export'); switchMainView('groups'); setTimeout(() => document.getElementById('gexSearch').focus(), 80); } function _gexSwitchTab(tab) { GexState.activeTab = tab; document.querySelectorAll('.gex-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab)); document.getElementById('gexPaneExport').classList.toggle('hidden', tab !== 'export'); document.getElementById('gexPaneAdd').classList.toggle('hidden', tab !== 'add'); document.getElementById('gexPaneImport').classList.toggle('hidden', tab !== 'import'); document.getElementById('gexPaneUser').classList.toggle('hidden', tab !== 'user'); document.getElementById('gexLeft').classList.toggle('hidden', tab === 'user'); document.getElementById('gexExportBtn').classList.toggle('hidden', tab !== 'export'); document.getElementById('gexAddBtn').classList.toggle('hidden', tab !== 'add'); document.getElementById('gexStats').textContent = ''; if (tab === 'add') setTimeout(() => document.getElementById('gexUserSearch').focus(), 80); if (tab === 'user') setTimeout(() => document.getElementById('gexUmSearch').focus(), 80); } document.querySelectorAll('.gex-tab').forEach(btn => { btn.addEventListener('click', () => _gexSwitchTab(btn.dataset.tab)); }); // Nav-Tabs: App Management / Group Management / App Report function switchMainView(view) { const layout = document.getElementById('layout'); const groupView = document.getElementById('groupMgmtView'); const reportView = document.getElementById('appReportView'); const devView = document.getElementById('devView'); const polView = document.getElementById('policiesView'); const offView = document.getElementById('offView'); document.querySelectorAll('#topbarNav .nav-tab').forEach(t => { t.classList.toggle('active', t.dataset.view === view); t.setAttribute('aria-selected', t.dataset.view === view ? 'true' : 'false'); }); layout .classList.toggle('hidden', view !== 'apps'); groupView .classList.toggle('hidden', view !== 'groups'); reportView.classList.toggle('hidden', view !== 'report'); devView .classList.toggle('hidden', view !== 'devices'); polView .classList.toggle('hidden', view !== 'policies'); if (offView) offView.classList.toggle('hidden', view !== 'offboard'); } document.querySelectorAll('#topbarNav .nav-tab').forEach(btn => { btn.addEventListener('click', () => { const view = btn.dataset.view; if ((view === 'groups' || view === 'report' || view === 'devices' || view === 'policies' || view === 'offboard') && !State.connected) { toast('Bitte zuerst verbinden.', 'warn'); return; } switchMainView(view); if (view === 'groups') openGroupExportModal(); if (view === 'report' && ReportState.items.length === 0) loadAppReport(); if (view === 'devices') { devLoadAll(); setTimeout(() => document.getElementById('devSearch').focus(), 80); } if (view === 'policies' && PolState.items.length === 0) loadPolicies(); if (view === 'offboard') setTimeout(() => document.getElementById('offSearch').focus(), 80); }); }); // 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 = ''; _gexRenderResults(); // aktualisiert selected-Highlight _gexUpdateAddBtn(); notifyCsvImportGroupSelected(id, name); if (GexState.activeTab !== 'export') return; document.getElementById('gexMemberFilter').value = ''; 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 += '
    '; const directGroupName = m.groupName || ''; for (const [path, users] of byPath.entries()) { const isDirect = path === directGroupName; 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()}`; const cbx = isDirect ? `` : ``; html += ``; } html += '
    '; } area.innerHTML = html; if (GexState.memberFilter) _gexApplyFilter(); // Checkboxen verdrahten const removeBtn = document.getElementById('gexRemoveBtn'); if (removeBtn) removeBtn.style.display = ''; area.querySelectorAll('.gex-member-chk').forEach(chk => { chk.addEventListener('change', () => { const n = area.querySelectorAll('.gex-member-chk:checked').length; if (removeBtn) { removeBtn.disabled = n === 0; removeBtn.textContent = n > 0 ? `${n} Mitglied${n > 1 ? 'er' : ''} entfernen` : 'Aus Gruppe entfernen'; } }); }); } // ---- Mitglieder entfernen ---- document.getElementById('gexRemoveBtn')?.addEventListener('click', async () => { const area = document.getElementById('gexMembersArea'); const checked = [...(area?.querySelectorAll('.gex-member-chk:checked') || [])]; if (!checked.length || !GexState.selectedGroup) return; const names = checked.slice(0, 5).map(c => c.dataset.name).join('\n'); const more = checked.length > 5 ? `\n… und ${checked.length - 5} weitere` : ''; if (!confirm(`${checked.length} Mitglied${checked.length > 1 ? 'er' : ''} aus "${GexState.selectedGroup.DisplayName}" entfernen?\n\n${names}${more}`)) return; const btn = document.getElementById('gexRemoveBtn'); btn.disabled = true; btn.textContent = 'Entferne…'; const groupId = GexState.selectedGroup.Id; let ok = 0, fail = 0; for (const chk of checked) { try { await api(`/api/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(chk.dataset.uid)}`, { method: 'DELETE' }); chk.closest('label').remove(); ok++; } catch (e) { fail++; console.error('Remove failed:', chk.dataset.uid, e); } } btn.textContent = 'Aus Gruppe entfernen'; btn.disabled = true; if (fail === 0) toast(`${ok} Mitglied${ok > 1 ? 'er' : ''} entfernt`, 'ok'); else toast(`${ok} entfernt, ${fail} fehlgeschlagen`, 'warn'); // Mitgliederzahl aktualisieren if (GexState.members) { GexState.members.count = Math.max(0, GexState.members.count - ok); document.getElementById('gexStats').textContent = `${GexState.members.count} Benutzer`; } }); // ---- Benutzer → Gruppen (User-Tab) ---- (function () { let umDebounce = null; let umSelected = null; // { Id, DisplayName, UserPrincipalName } const searchInput = document.getElementById('gexUmSearch'); const resultsDiv = document.getElementById('gexUmResults'); const groupsList = document.getElementById('gexUmGroupsList'); const groupsTitle = document.getElementById('gexUmGroupsTitle'); const groupCount = document.getElementById('gexUmGroupCount'); const filterWrap = document.getElementById('gexUmFilterWrap'); const filterInput = document.getElementById('gexUmFilter'); searchInput.addEventListener('input', () => { clearTimeout(umDebounce); const q = searchInput.value.trim(); if (q.length < 2) { resultsDiv.innerHTML = '
    Mindestens 2 Zeichen eingeben.
    '; return; } umDebounce = setTimeout(() => _umSearchUser(q), 320); }); async function _umSearchUser(q) { resultsDiv.innerHTML = '
    Suche…
    '; try { const data = await api(`/api/users?q=${encodeURIComponent(q)}`); if (!data.items || data.items.length === 0) { resultsDiv.innerHTML = '
    Keine Benutzer gefunden.
    '; return; } let html = ''; for (const u of data.items) { const upn = u.UserPrincipalName || u.Mail || u.Id; html += `
    ${escapeHtml(u.DisplayName || '—')} ${escapeHtml(upn)}
    `; } resultsDiv.innerHTML = html; resultsDiv.querySelectorAll('.gex-result-item').forEach(el => { el.addEventListener('click', () => _umSelectUser({ Id: el.dataset.uid, DisplayName: el.dataset.name, UserPrincipalName: el.dataset.upn })); }); } catch (e) { resultsDiv.innerHTML = `
    Fehler: ${escapeHtml(String(e))}
    `; } } async function _umSelectUser(user) { umSelected = user; searchInput.value = user.DisplayName || user.UserPrincipalName; resultsDiv.innerHTML = ''; groupsList.innerHTML = '
    Lade Gruppen…
    '; groupsTitle.style.display = ''; filterWrap.style.display = ''; filterInput.value = ''; groupCount.textContent = '…'; try { const data = await api(`/api/users/${encodeURIComponent(user.Id)}/memberof`); groupCount.textContent = data.count; if (!data.groups || data.groups.length === 0) { groupsList.innerHTML = '
    Keine Gruppen gefunden.
    '; filterWrap.style.display = 'none'; return; } _umRenderGroups(data.groups); } catch (e) { groupsList.innerHTML = `
    Fehler: ${escapeHtml(String(e))}
    `; } } function _umRenderGroups(groups) { let html = ''; for (const g of groups) { const desc = g.Description ? `${escapeHtml(g.Description)}` : ''; const badge = `${escapeHtml(g.Type)}`; html += `
    ${escapeHtml(g.DisplayName || '—')} ${desc} ${badge}
    `; } groupsList.innerHTML = html; } filterInput.addEventListener('input', () => { const q = filterInput.value.toLowerCase(); groupsList.querySelectorAll('.gex-group-row').forEach(row => { row.classList.toggle('hidden-filter', q.length > 0 && !row.dataset.search.includes(q)); }); }); })(); // ---- Benutzer-Suche im Add-Tab ---- let _gexUserSearchTimer = null; document.getElementById('gexUserSearch')?.addEventListener('input', e => { clearTimeout(_gexUserSearchTimer); const q = e.target.value.trim(); if (q.length < 2) { GexState.userSearchResults = []; document.getElementById('gexUserResults').innerHTML = '
    Mindestens 2 Zeichen eingeben.
    '; return; } document.getElementById('gexUserResults').innerHTML = '
    Suche…
    '; _gexUserSearchTimer = setTimeout(() => _gexDoUserSearch(q), 350); }); async function _gexDoUserSearch(q) { try { if (GexState.addMode === 'group') { // Gruppen suchen; die Zielgruppe selbst rausfiltern. UPN-Feld traegt zur // Anzeige die Beschreibung bzw. "Gruppe" (nur fuers Rendering, Add nutzt Id). const data = await api(`/api/groups/search?q=${encodeURIComponent(q)}`); const targetId = GexState.selectedGroup ? GexState.selectedGroup.Id : null; GexState.userSearchResults = (data.items || []) .filter(g => g.Id !== targetId) .map(g => ({ Id: g.Id, DisplayName: g.DisplayName, UserPrincipalName: g.Description || 'Gruppe', _isGroup: true })); } else { const data = await api(`/api/users?q=${encodeURIComponent(q)}`); GexState.userSearchResults = data.items || []; } _gexRenderUserResults(); } catch (e) { document.getElementById('gexUserResults').innerHTML = `
    Suche fehlgeschlagen: ${escapeHtml(e.message)}
    `; } } // Umschalter Benutzer/Gruppen im "Hinzufuegen"-Pane. document.querySelectorAll('.gex-addmode-btn').forEach(btn => { btn.addEventListener('click', () => { const mode = btn.dataset.addmode; if (GexState.addMode === mode) return; GexState.addMode = mode; document.querySelectorAll('.gex-addmode-btn').forEach(b => b.classList.toggle('active', b === btn)); // Auswahl + Suche zuruecksetzen GexState.selectedUsers.clear(); GexState.userSearchResults = []; _gexRenderSelectedUsers(); _gexUpdateAddBtn(); const search = document.getElementById('gexUserSearch'); if (search) search.value = ''; const isGroup = mode === 'group'; const title = document.getElementById('gexAddSearchTitle'); if (title) title.textContent = isGroup ? 'Gruppen suchen' : 'Benutzer suchen'; if (search) search.placeholder = isGroup ? 'Gruppenname…' : 'Name, UPN oder E-Mail…'; document.getElementById('gexUserResults').innerHTML = '
    Mindestens 2 Zeichen eingeben.
    '; const selHint = document.getElementById('gexSelectedUsers'); if (selHint) selHint.innerHTML = `
    Noch keine ${isGroup ? 'Gruppen' : 'Benutzer'} ausgewählt.
    `; if (search) search.focus(); }); }); function _gexRenderUserResults() { const el = document.getElementById('gexUserResults'); if (!el) return; const items = GexState.userSearchResults; if (items.length === 0) { el.innerHTML = '
    Keine Benutzer gefunden.
    '; return; } el.innerHTML = items.map(u => { const already = GexState.selectedUsers.has(u.Id); return `
    ${escapeHtml(u.DisplayName || '—')} ${escapeHtml(u.UserPrincipalName || u.Mail || '')} ${already ? '✓' : ''}
    `; }).join(''); el.querySelectorAll('.gex-user-result-item:not(.selected)').forEach(item => { item.addEventListener('click', () => _gexSelectUser({ Id: item.dataset.id, DisplayName: item.dataset.name, UserPrincipalName: item.dataset.upn })); }); } function _gexSelectUser(u) { if (GexState.selectedUsers.has(u.Id)) return; GexState.selectedUsers.set(u.Id, u); _gexRenderUserResults(); _gexRenderSelectedUsers(); _gexUpdateAddBtn(); } function _gexDeselectUser(id) { GexState.selectedUsers.delete(id); _gexRenderUserResults(); _gexRenderSelectedUsers(); _gexUpdateAddBtn(); } function _gexRenderSelectedUsers() { const el = document.getElementById('gexSelectedUsers'); const countEl = document.getElementById('gexSelectedCount'); if (!el) return; const users = [...GexState.selectedUsers.values()]; countEl.textContent = users.length; if (users.length === 0) { el.innerHTML = '
    Noch keine Benutzer ausgewählt.
    '; return; } el.innerHTML = users.map(u => `
    ${escapeHtml(u.DisplayName || '—')} ${escapeHtml(u.UserPrincipalName || '')}
    `).join(''); el.querySelectorAll('.gex-selected-chip-remove').forEach(btn => { btn.addEventListener('click', () => _gexDeselectUser(btn.dataset.id)); }); } function _gexUpdateAddBtn() { const btn = document.getElementById('gexAddBtn'); if (!btn) return; btn.disabled = !(GexState.selectedGroup && GexState.selectedUsers.size > 0); } document.getElementById('gexAddBtn')?.addEventListener('click', async () => { if (!GexState.selectedGroup) { toast('Bitte zuerst eine Gruppe auswählen.', 'warn'); return; } if (GexState.selectedUsers.size === 0) { toast('Bitte mindestens einen Benutzer auswählen.', 'warn'); return; } const btn = document.getElementById('gexAddBtn'); btn.disabled = true; const origHtml = btn.innerHTML; btn.innerHTML = '
    Wird hinzugefügt…'; const userIds = [...GexState.selectedUsers.keys()]; try { const data = await api(`/api/groups/${encodeURIComponent(GexState.selectedGroup.Id)}/members`, { method: 'POST', body: { userIds }, }); const kindLbl = GexState.addMode === 'group' ? 'Gruppe(n)' : 'Benutzer'; if (data.errors > 0) { toast(`${data.success} von ${data.total} hinzugefügt, ${data.errors} Fehler.`, 'warn'); } else { toast(`${data.success} ${kindLbl} zu „${GexState.selectedGroup.DisplayName}" hinzugefügt.`, 'ok'); GexState.selectedUsers.clear(); _gexRenderSelectedUsers(); document.getElementById('gexUserSearch').value = ''; document.getElementById('gexUserResults').innerHTML = '
    Mindestens 2 Zeichen eingeben.
    '; } } catch (e) { toast(`Fehler: ${e.message}`, 'err'); } finally { btn.innerHTML = origHtml; _gexUpdateAddBtn(); } }); 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'); }); // ============================================================= // APP REPORT VIEW // ============================================================= const ReportState = { items: [], // alle geladenen Items filtered: [], // nach Suche gefiltert sortCol: 'AppName', sortDir: 1, // 1 = asc, -1 = desc }; async function loadAppReport() { if (!State.connected) { toast('Bitte zuerst verbinden.', 'warn'); return; } const tbody = document.getElementById('reportBody'); tbody.innerHTML = `
    Lade Install-Statistiken… (kann 30–60 s dauern)`; document.getElementById('btnReportExport').disabled = true; document.getElementById('btnReportRefresh').disabled = true; try { const res = await api('/api/apps/report'); ReportState.items = res.items || []; applyReportFilter(); toast(`${ReportState.items.length} Apps geladen`, 'ok'); } catch (e) { tbody.innerHTML = `${escapeHtml(e.message)}`; toast(e.message, 'err', 'Report fehlgeschlagen'); } finally { document.getElementById('btnReportRefresh').disabled = false; } } function applyReportFilter() { const q = (document.getElementById('reportSearch')?.value || '').toLowerCase().trim(); ReportState.filtered = q ? ReportState.items.filter(r => (r.AppName || '').toLowerCase().includes(q) || (r.Publisher || '').toLowerCase().includes(q) || (r.AppType || '').toLowerCase().includes(q)) : ReportState.items.slice(); sortReport(); } function sortReport() { const col = ReportState.sortCol; const dir = ReportState.sortDir; const numCols = new Set(['InstalledDeviceCount','PendingInstallDeviceCount','FailedDeviceCount','NotApplicableDeviceCount']); ReportState.filtered.sort((a, b) => { let av = a[col] ?? '', bv = b[col] ?? ''; if (numCols.has(col)) return dir * (Number(av) - Number(bv)); return dir * String(av).localeCompare(String(bv), 'de', { sensitivity: 'base' }); }); renderReportTable(); } function renderReportTable() { const tbody = document.getElementById('reportBody'); const count = document.getElementById('reportCount'); count.textContent = ReportState.filtered.length; document.getElementById('btnReportExport').disabled = ReportState.filtered.length === 0; // Sort-Icons aktualisieren document.querySelectorAll('#reportTable th.sortable').forEach(th => { const ico = th.querySelector('.sort-ico'); if (!ico) return; if (th.dataset.col === ReportState.sortCol) { ico.textContent = ReportState.sortDir === 1 ? ' ▲' : ' ▼'; } else { ico.textContent = ''; } }); if (ReportState.filtered.length === 0) { tbody.innerHTML = `Keine Apps gefunden.`; return; } tbody.innerHTML = ReportState.filtered.map(r => { const failCls = r.FailedDeviceCount > 0 ? 'report-fail' : ''; return ` ${escapeHtml(r.AppName)} ${escapeHtml(r.Publisher || '—')} ${escapeHtml(r.AppType || '—')} ${escapeHtml(r.Version || '—')} ${r.InstalledDeviceCount} ${r.PendingInstallDeviceCount} ${r.FailedDeviceCount} ${r.NotApplicableDeviceCount} `; }).join(''); // Delegierter Click-Handler für alle Export-Buttons tbody.querySelectorAll('.btn-report-device-export').forEach(btn => { btn.addEventListener('click', () => exportDeviceStatusCsv(btn.dataset.appid, btn.dataset.appname, btn)); }); } async function exportDeviceStatusCsv(appId, appName, btn) { const orig = btn.textContent; btn.disabled = true; btn.textContent = 'Lade…'; try { const res = await api(`/api/apps/devicestatus?appId=${encodeURIComponent(appId)}`); const items = res.items || []; if (items.length === 0) { toast('Keine Gerätedaten gefunden.', 'warn'); return; } const cols = ['DeviceName','UserName','InstallState','InstallStateDetail','ErrorCode','OsVersion','LastSyncDateTime']; const headers = ['Gerätename','Benutzer','Status','Status-Detail','Fehlercode','OS-Version','Letzte Synchronisierung']; const rows = [headers, ...items.map(r => cols.map(c => r[c] ?? ''))]; 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 safe = appName.replace(/[\\/:*?"<>|]/g, '_').slice(0, 60); const a = Object.assign(document.createElement('a'), { href: url, download: `devices-${safe}-${new Date().toISOString().slice(0,10)}.csv` }); a.click(); URL.revokeObjectURL(url); toast(`${items.length} Geräte exportiert`, 'ok'); } catch (e) { toast(e.message, 'err', 'Export fehlgeschlagen'); } finally { btn.disabled = false; btn.textContent = orig; } } function exportReportCsv() { const cols = ['AppName','Publisher','AppType','Version','InstalledDeviceCount','PendingInstallDeviceCount','FailedDeviceCount','NotApplicableDeviceCount']; const headers = ['App-Name','Publisher','Typ','Version','Installiert','Ausstehend','Fehlgeschlagen','Nicht anwendbar']; const rows = [headers, ...ReportState.filtered.map(r => cols.map(c => r[c] ?? ''))]; 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 = Object.assign(document.createElement('a'), { href: url, download: `app-report-${new Date().toISOString().slice(0,10)}.csv` }); a.click(); URL.revokeObjectURL(url); toast(`${ReportState.filtered.length} Apps exportiert`, 'ok'); } document.getElementById('btnReportRefresh')?.addEventListener('click', loadAppReport); document.getElementById('btnReportExport')?.addEventListener('click', exportReportCsv); document.getElementById('reportSearch')?.addEventListener('input', applyReportFilter); document.querySelectorAll('#reportTable th.sortable').forEach(th => { th.addEventListener('click', () => { const col = th.dataset.col; if (ReportState.sortCol === col) { ReportState.sortDir *= -1; } else { ReportState.sortCol = col; ReportState.sortDir = col.includes('Count') ? -1 : 1; } sortReport(); }); }); // ============================================================= // CSV-IMPORT: Benutzer per UPN/CSV in Gruppe importieren // ============================================================= const CsvImport = { resolved: [], // Ergebnis des letzten Resolve-Calls groupId: '', groupName: '', }; // Datei-Upload -> Textarea füllen document.getElementById('csvImportFile')?.addEventListener('change', function () { const file = this.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = e => { document.getElementById('csvImportText').value = e.target.result; this.value = ''; }; reader.readAsText(file, 'utf-8'); }); // UPNs aus Textarea extrahieren (1. Spalte, Header erkennen) function splitCsvLine(line) { // Einfacher CSV-Splitter der quoted fields unterstützt const fields = []; let cur = '', inQ = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (ch === '"') { inQ = !inQ; continue; } if (!inQ && (ch === ',' || ch === ';' || ch === '\t')) { fields.push(cur); cur = ''; continue; } cur += ch; } fields.push(cur); return fields.map(f => f.trim()); } function parseCsvUpns(raw) { const lines = raw.split(/\r?\n/).map(l => l.trim()).filter(Boolean); if (lines.length === 0) return []; // Header-Zeile: prüfen ob erste Zeile Spaltennamen enthält const UPN_HEADERS = ['userprincipalname', 'upn', 'mail', 'email', 'e-mail', 'emailaddress']; const firstFields = splitCsvLine(lines[0]).map(f => f.toLowerCase()); let colIdx = -1; // Suche in Header nach bekannten UPN-Spaltenbezeichnungen for (const h of UPN_HEADERS) { const idx = firstFields.indexOf(h); if (idx !== -1) { colIdx = idx; break; } } const startLine = colIdx !== -1 ? 1 : 0; // Header überspringen wenn gefunden if (colIdx === -1) { // Kein Header erkannt: erste Spalte die ein @ enthält verwenden // oder Spalte 0 als Fallback const firstDataFields = splitCsvLine(lines[0]); colIdx = firstDataFields.findIndex(f => f.includes('@')); if (colIdx === -1) colIdx = 0; } const upns = []; for (let i = startLine; i < lines.length; i++) { const fields = splitCsvLine(lines[i]); const val = (fields[colIdx] || '').trim(); if (!val) continue; if (!val.includes('@')) continue; // muss @ enthalten (UPN/E-Mail) upns.push(val); } return [...new Set(upns)]; } // Resolve-Button document.getElementById('csvResolveBtn')?.addEventListener('click', async () => { const raw = document.getElementById('csvImportText').value; const upns = parseCsvUpns(raw); if (upns.length === 0) { toast('Keine gültigen UPNs/E-Mails gefunden.', 'warn'); return; } const btn = document.getElementById('csvResolveBtn'); const stats = document.getElementById('csvResolveStats'); btn.disabled = true; btn.textContent = 'Löse auf…'; stats.textContent = `${upns.length} Einträge werden aufgelöst…`; try { const res = await api('/api/groups/resolve-upns', { method: 'POST', body: { upns } }); CsvImport.resolved = res.items || []; renderCsvPreview(); stats.textContent = `${res.found} gefunden, ${res.notFound} nicht gefunden`; } catch (e) { toast(e.message, 'err', 'Auflösung fehlgeschlagen'); stats.textContent = ''; } finally { btn.disabled = false; btn.textContent = 'Benutzer auflösen'; } }); function renderCsvPreview() { const wrap = document.getElementById('csvPreviewWrap'); const tbody = document.getElementById('csvPreviewBody'); const foundBadge = document.getElementById('csvFoundBadge'); const notFoundBadge = document.getElementById('csvNotFoundBadge'); const importBtn = document.getElementById('csvImportBtn'); const found = CsvImport.resolved.filter(r => r.Found); const notFound = CsvImport.resolved.filter(r => !r.Found); foundBadge.textContent = `${found.length} gefunden`; notFoundBadge.textContent = `${notFound.length} nicht gefunden`; wrap.classList.remove('hidden'); tbody.innerHTML = CsvImport.resolved.map(r => { const cls = r.Found ? 'csv-row-ok' : 'csv-row-err'; const ico = r.Found ? '✓' : '✗'; const icoCls = r.Found ? 'csv-ico-ok' : 'csv-ico-err'; return ` ${ico} ${escapeHtml(r.Upn)} ${escapeHtml(r.DisplayName || '—')} ${r.Found ? escapeHtml(r.Mail || r.Upn) : 'Nicht gefunden'} `; }).join(''); importBtn.disabled = found.length === 0 || !CsvImport.groupId; updateCsvImportNote(); } function updateCsvImportNote() { const note = document.getElementById('csvImportNote'); const importBtn = document.getElementById('csvImportBtn'); const found = CsvImport.resolved.filter(r => r.Found).length; if (!CsvImport.groupId) { note.textContent = 'Gruppe links auswählen, dann Benutzer auflösen.'; importBtn.disabled = true; } else if (found === 0) { note.textContent = `Gruppe: ${CsvImport.groupName} — keine aufgelösten Benutzer.`; importBtn.disabled = true; } else { note.textContent = `Gruppe: ${CsvImport.groupName}`; importBtn.disabled = false; } } // Import-Button: gefundene User zur Gruppe hinzufügen document.getElementById('csvImportBtn')?.addEventListener('click', async () => { const found = CsvImport.resolved.filter(r => r.Found); if (!CsvImport.groupId || found.length === 0) return; const btn = document.getElementById('csvImportBtn'); btn.disabled = true; btn.textContent = 'Importiere…'; try { const userIds = found.map(r => r.UserId); const res = await api(`/api/groups/${encodeURIComponent(CsvImport.groupId)}/members`, { method: 'POST', body: { userIds } }); let cntAdded = 0, cntExist = 0, cntErr = 0; const notFound = CsvImport.resolved.filter(r => !r.Found).length; // Status in Vorschau aktualisieren if (res.results) { const map = {}; for (const r of res.results) map[r.userId] = r; document.querySelectorAll('#csvPreviewBody tr').forEach((tr, idx) => { const item = CsvImport.resolved[idx]; if (!item?.Found) return; const entry = map[item.UserId] || {}; const st = entry.status || 'Error'; const errMsg = entry.message || ''; let icoCls, ico, label; if (st === 'Success') { icoCls = 'csv-ico-done'; ico = '✔'; label = 'Hinzugefügt'; cntAdded++; } else if (st === 'AlreadyMember') { icoCls = 'csv-ico-warn'; ico = '≈'; label = 'Bereits Mitglied'; cntExist++; } else { icoCls = 'csv-ico-err'; ico = '✗'; label = errMsg || 'Fehler'; cntErr++; } tr.querySelector('td:first-child').className = icoCls; tr.querySelector('td:first-child').textContent = ico; tr.querySelector('.csv-status').textContent = label; tr.className = st === 'Success' ? 'csv-row-done' : st === 'AlreadyMember' ? 'csv-row-warn' : 'csv-row-err'; }); } // Ergebnis-Banner anzeigen const banner = document.getElementById('csvResultBanner'); banner.classList.remove('hidden'); document.getElementById('csvResAdded').textContent = `${cntAdded} hinzugefügt`; document.getElementById('csvResExist').textContent = `${cntExist} bereits Mitglied`; document.getElementById('csvResError').textContent = `${cntErr} Fehler`; document.getElementById('csvResSkipped').textContent = `${notFound} nicht gefunden`; document.getElementById('csvResAdded').classList.toggle('hidden', cntAdded === 0); document.getElementById('csvResExist').classList.toggle('hidden', cntExist === 0); document.getElementById('csvResError').classList.toggle('hidden', cntErr === 0); document.getElementById('csvResSkipped').classList.toggle('hidden', notFound === 0); const tone = cntErr > 0 ? 'warn' : 'ok'; toast(`Import abgeschlossen: ${cntAdded} hinzugefügt, ${cntExist} bereits Mitglied${cntErr > 0 ? `, ${cntErr} Fehler` : ''}`, tone); } catch (e) { toast(e.message, 'err', 'Import fehlgeschlagen'); } finally { btn.disabled = false; btn.textContent = 'Gefundene importieren'; } }); function notifyCsvImportGroupSelected(groupId, groupName) { CsvImport.groupId = groupId; CsvImport.groupName = groupName; if (CsvImport.resolved.length > 0) renderCsvPreview(); else updateCsvImportNote(); } // ============================================================ // Geräte-Tab // ============================================================ const DevState = { items: [], // aktuell geladener Satz (bei Gruppe: alle Geraete der Gruppe) filtered: [], selected: null, sortCol: 'deviceName', sortAsc: true, loaded: false, groupId: null, // aktiver Gruppen-Filter (null = alle Geraete) groupName: '', checked: new Set() // per Checkbox ausgewaehlte Geraete-Ids (fuer Bulk-Offboarding) }; async function devLoadAll() { await devFetch(); } function devShowLoading(on) { document.getElementById('devLoading').classList.toggle('hidden', !on); document.getElementById('devEmpty').classList.add('hidden'); } async function devFetch() { DevState.checked.clear(); // neue Datenladung -> Auswahl zuruecksetzen devShowLoading(true); try { if (DevState.groupId) { // Gruppen-Modus: alle Geraete der Gruppen-User holen, dann clientseitig filtern. const data = await api(`/api/groups/${encodeURIComponent(DevState.groupId)}/devices/export`, { timeoutMs: 180000 }); DevState.items = data.items || []; if (DevState.items.length === 0 && (data.errors || []).length) { toast(`Graph-Fehler: ${data.errors[0]}`, 'err', 'Geräte'); } devApplyClientFilters(); // sortiert + rendert } else { const q = document.getElementById('devSearch').value.trim(); const os = document.getElementById('devOsFilter').value; const cmp = document.getElementById('devComplianceFilter').value; const params = new URLSearchParams(); if (q) params.set('q', q); if (os) params.set('os', os); if (cmp) params.set('compliance', cmp); const data = await api('/api/devices?' + params.toString()); DevState.items = data.items || []; DevState.filtered = DevState.items; devSort(); } } catch (e) { toast('Geräte konnten nicht geladen werden: ' + (e.message || e), 'error'); } finally { devShowLoading(false); } } // Ein Geraet gegen die aktuellen UI-Filter pruefen (fuer den Gruppen-Modus, // wo serverseitig nicht gefiltert wird). function devPassesFilters(d, q, os, cmp) { if (os && !(d.OS || '').toLowerCase().includes(os.toLowerCase())) return false; if (cmp && (d.ComplianceState || '') !== cmp) return false; if (q) { const hay = `${d.DeviceName || ''} ${d.UserDisplayName || ''} ${d.UserPrincipalName || ''} ${d.SerialNumber || ''}`.toLowerCase(); if (!hay.includes(q)) return false; } return true; } function devApplyClientFilters() { const q = document.getElementById('devSearch').value.trim().toLowerCase(); const os = document.getElementById('devOsFilter').value; const cmp = document.getElementById('devComplianceFilter').value; DevState.filtered = DevState.items.filter(d => devPassesFilters(d, q, os, cmp)); devSort(); } // Filteraenderung: bei aktiver Gruppe nur clientseitig neu filtern (kein Re-Fetch), // sonst serverseitig neu laden. function devApplyFilters() { if (DevState.groupId) devApplyClientFilters(); else devFetch(); } // Header-data-col (Graph-Feldnamen) -> tatsaechliche Item-Property. const DEV_COL_MAP = { deviceName: 'DeviceName', userDisplayName: 'UserDisplayName', operatingSystem: 'OS', managementType: 'ManagementType', complianceState: 'ComplianceState', lastSyncDateTime: 'LastSync', enrolledDateTime: 'EnrolledDateTime', }; const DEV_DATE_COLS = new Set(['LastSync', 'EnrolledDateTime']); // Verwaltungsart als Badge: Co-Managed (ConfigMgr + Intune) vs. reines Intune. function devMgmtBadge(type) { if (type === 'Co-Managed') return 'Co-Managed'; if (type === 'Intune') return 'Intune'; return '—'; } function devFmtDate(iso) { if (!iso) return '—'; const d = new Date(iso); if (isNaN(d.getTime())) return '—'; return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); } function devSort() { const col = DEV_COL_MAP[DevState.sortCol] || DevState.sortCol; const asc = DevState.sortAsc ? 1 : -1; const isDate = DEV_DATE_COLS.has(col); DevState.filtered.sort((a, b) => { if (isDate) { // Nach Zeitstempel sortieren; ungueltige/fehlende Daten ans Ende. const at = Date.parse(a[col]); const bt = Date.parse(b[col]); const av = isNaN(at) ? -Infinity : at; const bv = isNaN(bt) ? -Infinity : bt; return (av - bv) * asc; } const sa = (a[col] ?? '').toString().toLowerCase(); const sb = (b[col] ?? '').toString().toLowerCase(); return sa.localeCompare(sb, 'de', { numeric: true }) * asc; }); devRender(); } function devRender() { const tbody = document.getElementById('devTableBody'); const empty = document.getElementById('devEmpty'); document.getElementById('devCount').textContent = DevState.filtered.length; const expBtn = document.getElementById('btnDevExport'); if (expBtn) expBtn.disabled = DevState.filtered.length === 0; if (DevState.filtered.length === 0) { tbody.innerHTML = ''; empty.classList.remove('hidden'); return; } empty.classList.add('hidden'); // Sort-Icons document.querySelectorAll('#devTable th.sortable').forEach(th => { const ico = th.querySelector('.sort-ico'); if (th.dataset.col === DevState.sortCol) { ico.textContent = DevState.sortAsc ? ' ▲' : ' ▼'; } else { ico.textContent = ''; } }); let html = ''; for (const d of DevState.filtered) { const sync = devFmtDate(d.LastSync); const cmpCls = d.ComplianceState === 'compliant' ? 'dev-badge-ok' : d.ComplianceState === 'noncompliant' ? 'dev-badge-err' : 'dev-badge-unk'; const sel = DevState.selected && DevState.selected.Id === d.Id ? ' class="dev-row-selected"' : ''; const chk = DevState.checked.has(d.Id) ? 'checked' : ''; html += ` ${escapeHtml(d.DeviceName || '—')} ${escapeHtml(d.UserDisplayName || '—')} ${escapeHtml(d.OS || '—')} ${devMgmtBadge(d.ManagementType)} ${escapeHtml(d.ComplianceState || '—')} ${sync} `; } tbody.innerHTML = html; tbody.querySelectorAll('.dev-row-check').forEach(cb => { cb.addEventListener('click', e => e.stopPropagation()); cb.addEventListener('change', () => { if (cb.checked) DevState.checked.add(cb.dataset.id); else DevState.checked.delete(cb.dataset.id); devUpdateCheckSel(); }); }); tbody.querySelectorAll('tr').forEach(tr => { tr.addEventListener('click', e => { if (e.target.classList.contains('dev-row-check')) return; const id = tr.dataset.id; const device = DevState.filtered.find(d => d.Id === id); if (device) devSelectDevice(device); }); }); devUpdateCheckSel(); } // Checkbox-Auswahl -> Offboard-Button + Zähler + Header-Checkbox synchronisieren. function devUpdateCheckSel() { // Nur sichtbare (gefilterte) Geraete zaehlen. const visibleIds = DevState.filtered.map(d => d.Id); const n = visibleIds.filter(id => DevState.checked.has(id)).length; const btn = document.getElementById('btnDevOffboard'); if (btn) { btn.disabled = n === 0; const c = document.getElementById('devOffboardCount'); if (c) c.textContent = n; } const all = document.getElementById('devCheckAll'); if (all) { all.checked = n > 0 && n === visibleIds.length; all.indeterminate = n > 0 && n < visibleIds.length; } } async function devSelectDevice(device) { DevState.selected = device; // Auswahl visuell document.querySelectorAll('#devTableBody tr').forEach(tr => tr.classList.toggle('dev-row-selected', tr.dataset.id === device.Id)); // Detail-Panel anzeigen mit Basis-Infos sofort devRenderDetail(device); // Vollständige Details nachladen try { const full = await api(`/api/devices/${encodeURIComponent(device.Id)}`); devRenderDetail(full); } catch (e) { // Basis-Infos bleiben } } function devRenderDetail(d) { const panel = document.getElementById('devDetail'); panel.classList.remove('hidden'); document.getElementById('devDetailName').textContent = d.DeviceName || '—'; document.getElementById('devDetailSub').textContent = `${d.Manufacturer || ''} ${d.Model || ''}`.trim() || '—'; const sync = devFmtDate(d.LastSync); const enrolled = devFmtDate(d.EnrolledDateTime); const storage = (d.TotalStorageGB != null) ? `${d.FreeStorageGB} GB frei / ${d.TotalStorageGB} GB gesamt` : null; const rows = [ ['Benutzer', d.UserDisplayName], ['UPN', d.UserPrincipalName], ['OS', d.OS ? `${d.OS} ${d.OSVersion || ''}`.trim() : null], ['Verwaltung', d.ManagementType], ['Compliance', d.ComplianceState], ['Seriennummer', d.SerialNumber], ['IMEI', d.Imei], ['Wi-Fi MAC', d.WiFiMac], ['Speicher', storage], ['Eingeschrieben', enrolled], ['Autopilot', d.InAutopilot ? 'Registriert' : (d.InAutopilot === false ? 'Nicht in Autopilot' : null)], ['Autopilot-Profil zugewiesen', d.AutopilotProfileAssigned ? devFmtDate(d.AutopilotProfileAssigned) : null], ['Autopilot letzter Kontakt', d.AutopilotLastContacted ? devFmtDate(d.AutopilotLastContacted) : null], ['Letzter Sync', sync], ['Join-Typ', d.JoinType], ['Besitzertyp', d.OwnerType], ['Azure AD ID', d.AzureADDeviceId], ]; let html = ''; for (const [label, val] of rows) { if (!val) continue; html += `
    ${escapeHtml(label)}
    ${escapeHtml(String(val))}
    `; } document.getElementById('devInfoGrid').innerHTML = html; // Aktionen: BitLocker und Autopilot Reset nur für Windows const isWin = (d.OS || '').toLowerCase() === 'windows'; const bitlockerBtn = document.getElementById('devBtnBitlocker'); if (bitlockerBtn) bitlockerBtn.style.display = isWin ? '' : 'none'; const autopilotBtn = document.getElementById('devBtnAutopilot'); if (autopilotBtn) autopilotBtn.style.display = isWin ? '' : 'none'; } // Wipe-Dialog (Promise: true=keepUserData, false=löschen, null=abgebrochen) function devWipeDialog(deviceName) { return new Promise(resolve => { document.getElementById('wipeDeviceName').textContent = deviceName; document.getElementById('wipeKeepUserData').checked = false; const backdrop = document.getElementById('wipeModalBackdrop'); backdrop.classList.remove('hidden'); const close = (result) => { backdrop.classList.add('hidden'); resolve(result); }; document.getElementById('wipeCancel').onclick = () => close(null); document.getElementById('wipeModalClose').onclick = () => close(null); document.getElementById('wipeConfirm').onclick = () => close(document.getElementById('wipeKeepUserData').checked); backdrop.onclick = (e) => { if (e.target === backdrop) close(null); }; }); } // Aktion ausführen document.getElementById('devActions')?.addEventListener('click', async (e) => { const btn = e.target.closest('.dev-action-btn'); if (!btn || !DevState.selected) return; const action = btn.dataset.action; if (!action) return; const labels = { syncDevice: 'Sync', rebootNow: 'Neustart', remoteLock: 'Sperren', collectDiagnostics: 'Diagnose sammeln', rotateBitLockerKeys: 'BitLocker-Schlüssel rotieren', autopilotReset: 'Autopilot Reset', retire: 'Retire (MDM entfernen)', wipe: 'Wipe' }; const devName = DevState.selected.DeviceName || DevState.selected.Id; // Wipe: eigener Dialog mit Benutzerdaten-Option if (action === 'wipe') { const keepUserData = await devWipeDialog(devName); if (keepUserData === null) return; btn.disabled = true; const origText2 = btn.textContent.trim(); btn.textContent = '…'; try { await api(`/api/devices/${encodeURIComponent(DevState.selected.Id)}/action`, { method: 'POST', body: JSON.stringify({ action: 'wipe', keepUserData }) }); toast(`Wipe gesendet${keepUserData ? ' (Benutzerdaten behalten)' : ''}`, 'ok'); DevState.items = DevState.items.filter(d => d.Id !== DevState.selected.Id); DevState.selected = null; document.getElementById('devDetail').classList.add('hidden'); devApplyFilters(); } catch (e) { toast(`Fehler bei Wipe: ${e}`, 'error'); } finally { btn.disabled = false; btn.textContent = origText2; } return; } const isDangerous = action === 'retire' || action === 'autopilotReset'; const dangerMsg = { retire: `ACHTUNG: "Retire" auf "${devName}" ausführen?\n\nDas MDM-Profil wird entfernt — Gerät wird nicht mehr verwaltet.`, autopilotReset: `ACHTUNG: "Autopilot Reset" auf "${devName}" ausführen?\n\nDas Gerät wird auf OOBE zurückgesetzt. Enrollment-Daten bleiben erhalten, Benutzerdaten werden gelöscht.` }; const msg = dangerMsg[action] || `"${labels[action]}" auf "${devName}" ausführen?`; if (!confirm(msg)) return; btn.disabled = true; const origText = btn.textContent.trim(); btn.textContent = '…'; try { await api(`/api/devices/${encodeURIComponent(DevState.selected.Id)}/action`, { method: 'POST', body: JSON.stringify({ action }) }); toast(`${labels[action]}: Aktion wurde gesendet`, 'ok'); if (action === 'retire') { DevState.items = DevState.items.filter(d => d.Id !== DevState.selected.Id); DevState.selected = null; document.getElementById('devDetail').classList.add('hidden'); devApplyFilters(); } } catch (e) { toast(`Fehler bei ${labels[action]}: ${e}`, 'error'); } finally { btn.disabled = false; btn.textContent = origText; } }); // Such- und Filter-Events let devSearchDebounce = null; document.getElementById('devSearch').addEventListener('input', () => { clearTimeout(devSearchDebounce); devSearchDebounce = setTimeout(devApplyFilters, 400); }); document.getElementById('devOsFilter').addEventListener('change', devApplyFilters); document.getElementById('devComplianceFilter').addEventListener('change', devApplyFilters); // Gruppen-Filter (Live-Suche) + CSV-Export let _devGroupTimer = null, _devGroupAbort = null; document.getElementById('devGroupSearch')?.addEventListener('input', e => { clearTimeout(_devGroupTimer); const term = e.target.value.trim(); const results = document.getElementById('devGroupResults'); // Text geaendert -> aktiven Gruppen-Filter loesen (bis eine Gruppe gewaehlt wird) if (DevState.groupId) { DevState.groupId = null; DevState.groupName = ''; document.getElementById('devGroupClear').hidden = true; devFetch(); } if (term.length < 2) { if (results) { results.classList.add('hidden'); results.innerHTML = ''; } return; } _devGroupTimer = setTimeout(() => devGroupSearch(term), 250); }); async function devGroupSearch(term) { const results = document.getElementById('devGroupResults'); if (!results) return; if (_devGroupAbort) { try { _devGroupAbort.abort(); } catch {} } _devGroupAbort = new AbortController(); results.classList.remove('hidden'); results.innerHTML = '
    Suche…
    '; try { const resp = await fetch('/api/groups/search?q=' + encodeURIComponent(term), { signal: _devGroupAbort.signal }); let data = {}; try { data = await resp.json(); } catch {} if (_devGroupAbort.signal.aborted) return; if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); const gs = data.items || []; if (gs.length === 0) { results.innerHTML = '
    Keine Treffer
    '; return; } results.innerHTML = gs.map(g => ``).join(''); } catch (e) { if (e.name === 'AbortError') return; results.innerHTML = `
    Fehler: ${escapeHtml(e.message)}
    `; } } document.getElementById('devGroupResults')?.addEventListener('click', e => { const btn = e.target.closest('.cg-existing-item'); if (!btn) return; DevState.groupId = btn.dataset.gid; DevState.groupName = btn.dataset.gname; document.getElementById('devGroupSearch').value = btn.dataset.gname; document.getElementById('devGroupClear').hidden = false; document.getElementById('devGroupResults').classList.add('hidden'); devFetch(); }); document.getElementById('devGroupClear')?.addEventListener('click', () => { DevState.groupId = null; DevState.groupName = ''; document.getElementById('devGroupSearch').value = ''; document.getElementById('devGroupClear').hidden = true; document.getElementById('devGroupResults').classList.add('hidden'); devFetch(); }); // Klick ausserhalb schliesst die Ergebnisliste document.addEventListener('click', e => { const wrap = document.getElementById('devGroupWrap'); if (wrap && !wrap.contains(e.target)) { document.getElementById('devGroupResults')?.classList.add('hidden'); } }); function devExportCsv() { const items = DevState.filtered || []; if (items.length === 0) { toast('Keine Geräte zum Exportieren.', 'warn'); return; } const header = ['DeviceName','UserDisplayName','UserPrincipalName','OS','OSVersion','ManagementType','ComplianceState','SerialNumber','Model','Manufacturer','LastSync','EnrolledDateTime']; const rows = [header]; for (const d of items) { rows.push([ d.DeviceName || '', d.UserDisplayName || '', d.UserPrincipalName || '', d.OS || '', d.OSVersion || '', d.ManagementType || '', d.ComplianceState || '', d.SerialNumber || '', d.Model || '', d.Manufacturer || '', d.LastSync ? devFmtDate(d.LastSync) : '', d.EnrolledDateTime ? devFmtDate(d.EnrolledDateTime) : '', ]); } 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 scope = DevState.groupName ? DevState.groupName.replace(/[^a-zA-Z0-9_-]/g, '_') : 'alle'; const a = Object.assign(document.createElement('a'), { href: url, download: `devices-${scope}-${new Date().toISOString().slice(0,10)}.csv` }); a.click(); URL.revokeObjectURL(url); toast(`${items.length} Geräte exportiert${DevState.groupName ? ` (Gruppe „${DevState.groupName}")` : ''}.`, 'ok'); } document.getElementById('btnDevExport')?.addEventListener('click', devExportCsv); // Alle sichtbaren Geraete an-/abwaehlen document.getElementById('devCheckAll')?.addEventListener('change', e => { const on = e.target.checked; DevState.filtered.forEach(d => { if (on) DevState.checked.add(d.Id); else DevState.checked.delete(d.Id); }); // Checkboxen in der Tabelle spiegeln, ohne komplett neu zu rendern document.querySelectorAll('#devTableBody .dev-row-check').forEach(cb => { cb.checked = on; }); devUpdateCheckSel(); }); // Ausgewaehlte Geraete offboarden -> IDs aufloesen (Entra/Autopilot) und Dialog oeffnen async function devOffboardSelected() { const ids = DevState.filtered.filter(d => DevState.checked.has(d.Id)).map(d => d.Id); if (ids.length === 0) { toast('Keine Geräte ausgewählt.', 'warn'); return; } setLoading(`Löse ${ids.length} Gerät(e) für Offboarding auf…`); try { const res = await api('/api/offboard/resolve', { method: 'POST', body: { ids }, timeoutMs: 120000 }); const items = res.items || []; if (items.length === 0) { toast('Geräte konnten nicht aufgelöst werden.', 'err'); return; } // Offboard-Dialog mit genau diesen Geräten öffnen (unabhängig vom Offboard-Tab). openOffboardModal(items); } catch (e) { toast('Offboarding-Auflösung fehlgeschlagen: ' + e.message, 'err'); } finally { clearLoading(); } } document.getElementById('btnDevOffboard')?.addEventListener('click', devOffboardSelected); // Sortierung document.querySelectorAll('#devTable th.sortable').forEach(th => { th.addEventListener('click', () => { if (DevState.sortCol === th.dataset.col) { DevState.sortAsc = !DevState.sortAsc; } else { DevState.sortCol = th.dataset.col; DevState.sortAsc = true; } devSort(); }); }); // ============================================================= // Policies (Export / Import) // ============================================================= const PolState = { items: [], // alle geladenen Policies (normalisiert) selected: new Set(), // Keys "type::id" sortCol: 'name', sortAsc: true, search: '', typeFilter: '', }; function polKey(p) { return `${p.type}::${p.id}`; } function polFmtDate(iso) { if (!iso) return '—'; const d = new Date(iso); if (isNaN(d)) return '—'; return d.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' }); } async function loadPolicies() { const body = document.getElementById('polBody'); body.innerHTML = ` Lade Policies…`; // Drei Typen parallel laden; ein fehlschlagender Typ (z.B. fehlender Scope) // soll die anderen nicht blockieren. const endpoints = [ ['/api/policies/settingscatalog', 'Settings Catalog'], ['/api/policies/compliance', 'Compliance'], ['/api/policies/configuration', 'Konfigurationsprofile'], ['/api/policies/administrativetemplate', 'Administrative Vorlagen'], ]; const settled = await Promise.allSettled(endpoints.map(([url]) => api(url))); let items = []; const errors = []; settled.forEach((r, i) => { if (r.status === 'fulfilled') { items = items.concat(r.value.items || []); } else { errors.push(`${endpoints[i][1]}: ${r.reason?.message || 'Fehler'}`); } }); PolState.items = items; // Auswahl auf noch existierende Policies eindampfen const valid = new Set(items.map(polKey)); PolState.selected = new Set([...PolState.selected].filter(k => valid.has(k))); renderPolicies(); if (errors.length) toast('Einige Typen konnten nicht geladen werden. ' + errors.join(' · '), 'warn'); } function polFiltered() { const q = PolState.search.toLowerCase(); const tf = PolState.typeFilter; let list = PolState.items.filter(p => { if (tf && p.type !== tf) return false; if (!q) return true; return (p.name || '').toLowerCase().includes(q) || (p.typeLabel || '').toLowerCase().includes(q) || (p.platform || '').toLowerCase().includes(q); }); const col = PolState.sortCol, asc = PolState.sortAsc ? 1 : -1; list.sort((a, b) => String(a[col] ?? '').localeCompare(String(b[col] ?? ''), 'de', { numeric: true }) * asc); return list; } function renderPolicies() { const body = document.getElementById('polBody'); const list = polFiltered(); document.getElementById('polCount').textContent = list.length; if (!list.length) { body.innerHTML = `${PolState.items.length ? 'Keine Policy passt zum Filter.' : 'Keine Policies gefunden.'}`; polUpdateSelCount(); return; } body.innerHTML = list.map(p => { const key = polKey(p); const checked = PolState.selected.has(key) ? 'checked' : ''; return ` ${escapeHtml(p.name || '—')} ${escapeHtml(p.typeLabel || p.type)} ${escapeHtml(p.platform || '—')} ${polFmtDate(p.lastModifiedDateTime)} `; }).join(''); body.querySelectorAll('.pol-row-check').forEach(cb => { cb.addEventListener('change', () => { if (cb.checked) PolState.selected.add(cb.dataset.key); else PolState.selected.delete(cb.dataset.key); polUpdateSelCount(); polSyncCheckAll(); }); }); // Klick auf Zeile toggelt die Checkbox (außer direkt auf die Checkbox) body.querySelectorAll('tr[data-key]').forEach(tr => { tr.addEventListener('click', e => { if (e.target.classList.contains('pol-row-check')) return; const cb = tr.querySelector('.pol-row-check'); cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }); }); polUpdateSelCount(); polSyncCheckAll(); } function polUpdateSelCount() { const keys = [...PolState.selected]; const n = keys.length; document.getElementById('polSelCount').textContent = n; document.getElementById('btnPolExport').disabled = n === 0; const btnA = document.getElementById('btnPolAssignGroups'); if (btnA) { btnA.disabled = n === 0; const c = document.getElementById('polAssignSelCount'); if (c) c.textContent = n; } // Konsolidieren nur bei >=2 Policies UND alle vom Typ Settings Catalog. const btnC = document.getElementById('btnPolConsolidate'); if (btnC) { const allSc = n >= 2 && keys.every(k => k.startsWith('settingscatalog::')); btnC.disabled = !allSc; btnC.title = allSc ? 'Ausgewählte Settings-Catalog-Policies zu einer neuen Policy zusammenführen' : 'Mindestens zwei Policies vom Typ „Settings Catalog" auswählen'; } } function polSyncCheckAll() { const visible = polFiltered().map(polKey); const all = visible.length > 0 && visible.every(k => PolState.selected.has(k)); const some = visible.some(k => PolState.selected.has(k)); const cb = document.getElementById('polCheckAll'); cb.checked = all; cb.indeterminate = !all && some; } function polDownloadJson(obj, filename) { const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } async function polExportSelected() { const sel = [...PolState.selected]; if (!sel.length) return; const byType = {}; for (const key of sel) { const idx = key.indexOf('::'); const type = key.slice(0, idx), id = key.slice(idx + 2); (byType[type] ||= []).push(id); } setLoading('Exportiere Policies…'); try { let allExports = []; for (const [type, ids] of Object.entries(byType)) { const res = await api('/api/policies/export', { method: 'POST', body: { type, ids }, timeoutMs: 120000 }); if (res.exports) allExports = allExports.concat(res.exports); } if (!allExports.length) { toast('Nichts exportiert.', 'warn'); return; } // Jede ausgewählte Policy als eigene JSON-Datei herunterladen. const usedNames = new Set(); for (let i = 0; i < allExports.length; i++) { const e = allExports[i]; let fn = e.fileName || `policy_${i + 1}.json`; // Dateinamens-Kollisionen (z.B. gleicher Anzeigename) eindeutig machen. if (usedNames.has(fn)) fn = fn.replace(/\.json$/i, '') + `_${i + 1}.json`; usedNames.add(fn); polDownloadJson(e.data, fn); // Kurze Pause, damit der Browser mehrere aufeinanderfolgende Downloads nicht verwirft. if (i < allExports.length - 1) await new Promise(r => setTimeout(r, 150)); } toast(`${allExports.length} Policy(s) als Einzeldatei(en) exportiert (auch im Server-Archiv abgelegt).`, 'ok'); } catch (e) { toast('Export fehlgeschlagen: ' + e.message, 'err'); } finally { clearLoading(); } } // ============================================================= // Policies konsolidieren (Settings Catalog -> eine neue Policy) // ============================================================= let _polConsSelIds = []; // ausgewaehlte settingscatalog-Ids (Reihenfolge = Default-Gewinner) let _polConsPreview = null; // Preview-Response const _polConsResolutions = {}; // settingDefinitionId -> gewaehlte sourceId async function openPolConsolidate() { const keys = [...PolState.selected].filter(k => k.startsWith('settingscatalog::')); if (keys.length < 2) { toast('Mindestens zwei Settings-Catalog-Policies auswählen.', 'warn'); return; } _polConsSelIds = keys.map(k => k.slice('settingscatalog::'.length)); _polConsPreview = null; Object.keys(_polConsResolutions).forEach(k => delete _polConsResolutions[k]); document.getElementById('polConsSummary').innerHTML = ' Analysiere…'; document.getElementById('polConsConflicts').innerHTML = ''; document.getElementById('polConsName').value = ''; document.getElementById('polConsDesc').value = ''; document.getElementById('polConsConfirm').disabled = true; document.getElementById('polConsInfo').textContent = ''; openModal('modalPolConsolidate'); try { const res = await api('/api/policies/consolidate', { method: 'POST', body: { ids: _polConsSelIds, mode: 'preview' }, timeoutMs: 120000 }); _polConsPreview = res; renderPolConsPreview(res); } catch (e) { document.getElementById('polConsSummary').innerHTML = `Analyse fehlgeschlagen: ${escapeHtml(e.message)}`; } } function polConsSourceName(id) { const s = ((_polConsPreview && _polConsPreview.sources) || []).find(x => x.id === id); return s ? s.name : id; } function renderPolConsPreview(res) { const sources = res.sources || []; document.getElementById('polConsSummary').innerHTML = `
    ${sources.length} Policies · Plattform ${escapeHtml(res.platform || '—')}
    ${res.totalSettings} Einstellungen gesamt · ${res.conflictCount} Konflikt(e)
      ${sources.map(s => `
    • ${escapeHtml(s.name)} (${s.settingCount})
    • `).join('')}
    `; const cWrap = document.getElementById('polConsConflicts'); const conflicts = res.conflicts || []; if (conflicts.length === 0) { cWrap.innerHTML = '
    Keine Konflikte — alle gemeinsamen Einstellungen sind identisch.
    '; } else { cWrap.innerHTML = `
    Konflikte — welcher Wert soll gewinnen?
    ` + conflicts.map((c, ci) => { _polConsResolutions[c.settingDefinitionId] = c.chosenSourceId; // Default merken const opts = (c.variants || []).map(v => { const sid = (v.sourceIds || [])[0]; const label = (v.sourceNames || []).join(', ') || polConsSourceName(sid); const checked = sid === c.chosenSourceId ? 'checked' : ''; return ``; }).join(''); return `
    ${escapeHtml(c.settingDefinitionId)}
    ${opts}
    `; }).join(''); cWrap.querySelectorAll('input[type="radio"][data-def]').forEach(r => { r.addEventListener('change', () => { _polConsResolutions[r.dataset.def] = r.dataset.src; }); }); } document.getElementById('polConsName').value = `Konsolidiert – ${sources.length ? sources[0].name : 'Policy'}`; document.getElementById('polConsInfo').textContent = `${res.totalSettings} Einstellungen → 1 Policy`; document.getElementById('polConsConfirm').disabled = false; } async function polDoConsolidate() { if (!_polConsPreview) return; const name = document.getElementById('polConsName').value.trim(); if (!name) { toast('Bitte einen Namen angeben.', 'warn'); return; } const desc = document.getElementById('polConsDesc').value.trim(); const btn = document.getElementById('polConsConfirm'); btn.disabled = true; setLoading('Führe Policies zusammen…'); try { const res = await api('/api/policies/consolidate', { method: 'POST', body: { ids: _polConsSelIds, mode: 'create', name, description: desc, resolutions: _polConsResolutions }, timeoutMs: 180000, }); toast(`Neue Policy „${res.name}" erstellt (${res.settingsCount} Einstellungen, ${res.conflictCount} Konflikt(e) aufgelöst).`, 'ok', 'Konsolidiert'); closeModal('modalPolConsolidate'); await loadPolicies(); } catch (e) { toast('Zusammenführen fehlgeschlagen: ' + e.message, 'err'); btn.disabled = false; } finally { clearLoading(); } } document.getElementById('btnPolConsolidate')?.addEventListener('click', openPolConsolidate); document.getElementById('polConsConfirm')?.addEventListener('click', polDoConsolidate); // Datei-Inhalt -> Liste von Envelopes { exportType, policy, ... } function polExtractEnvelopes(parsed, fileName) { if (parsed && Array.isArray(parsed.policies)) return parsed.policies; // Beide Typ-Felder akzeptieren: exportType (Export-Download) + policyType (Git-Snapshot). if (parsed && (parsed.exportType || parsed.policyType) && parsed.policy) return [parsed]; if (parsed && parsed.policy) return [parsed]; // nachsichtig toast(`${fileName}: unbekanntes Policy-Format.`, 'err'); return []; } let _polImportEnvelopes = []; function polEnvName(env) { return env.policyName || (env.policy && (env.policy.name || env.policy.displayName)) || '(ohne Name)'; } // Dateien einlesen -> Rename-Dialog oeffnen (der eigentliche Import folgt dort). async function polHandleImportFiles(fileList) { const files = [...fileList]; if (!files.length) return; let envelopes = []; for (const f of files) { let parsed = null; try { parsed = JSON.parse(await f.text()); } catch { toast(`${f.name}: kein gültiges JSON.`, 'err'); continue; } envelopes = envelopes.concat(polExtractEnvelopes(parsed, f.name)); } if (!envelopes.length) return; _polImportEnvelopes = envelopes; renderPolImportList(); openModal('modalPolImport'); } function renderPolImportList() { const list = document.getElementById('polImportList'); if (!list) return; list.innerHTML = _polImportEnvelopes.map((env, i) => { const type = env.exportType || env.policyType || ''; return `
    ${escapeHtml(POL_TYPE_LABELS[type] || type || 'Policy')}
    `; }).join(''); const info = document.getElementById('polImportInfo'); if (info) info.textContent = `${_polImportEnvelopes.length} Policy(s)`; } async function polDoImport() { // Geänderte Namen übernehmen (nur wenn nicht leer und wirklich anders). _polImportEnvelopes.forEach((env, i) => { const input = document.querySelector(`.pol-import-name[data-idx="${i}"]`); const nn = input ? input.value.trim() : ''; if (nn && nn !== polEnvName(env)) env.newName = nn; else delete env.newName; }); closeModal('modalPolImport'); const envelopes = _polImportEnvelopes.slice(); const total = envelopes.length; setLoading('Importiere Policies…'); try { // Sequenzieller Server-Import in kleinen Batches: umgeht die 180-s-Timeout-Wand // eines einzelnen Requests bei vielen Policies und erlaubt Fortschrittsanzeige. const BATCH = 5; const loadingText = document.getElementById('loadingText'); const results = []; for (let start = 0; start < total; start += BATCH) { const chunk = envelopes.slice(start, start + BATCH); if (loadingText && total > BATCH) { loadingText.textContent = `Importiere Policies… (${start + 1}–${Math.min(start + chunk.length, total)} von ${total})`; } // Timeout pro Batch nach Batch-Größe skaliert (Anlegen + Retry je Policy). try { const res = await api('/api/policies/import', { method: 'POST', body: { policies: chunk }, timeoutMs: 30000 + chunk.length * 30000 }); if (res && res.results) results.push(...res.results); } catch (be) { // Batch-Fehler nicht den Gesamtimport abbrechen lassen — als fehlgeschlagen // vermerken und mit dem nächsten Batch weitermachen. chunk.forEach(env => results.push({ policyName: polEnvName(env), success: false, error: be.message })); } } const ok = results.filter(r => r.success); const fail = results.filter(r => !r.success); if (fail.length) { const first = fail[0]; toast(`${ok.length} importiert, ${fail.length} fehlgeschlagen. z.B. "${first.policyName || '?'}": ${first.error || 'Fehler'}`, 'err', 'Import'); } else { toast(`${ok.length} Policy(s) importiert.`, 'ok', 'Import'); } await loadPolicies(); // Nach erfolgreichem Import optional Gruppen zuweisen (pro Policy). const assignable = ok.filter(r => r.newId); if (assignable.length && !State.readOnly) openPolAssignModal(assignable); } catch (e) { toast('Import fehlgeschlagen: ' + e.message, 'err'); } finally { clearLoading(); } } document.getElementById('polImportConfirm')?.addEventListener('click', polDoImport); // ============================================================= // Post-Import: Policies pro Stück Include-/Exclude-Gruppen zuweisen // ============================================================= const PolAssignState = { policies: [] }; const POL_TYPE_LABELS = { SettingsCatalog: 'Settings Catalog', CompliancePolicy: 'Compliance', ConfigurationProfile: 'Konfigurationsprofil', AdministrativeTemplate: 'Administrative Vorlage', }; function openPolAssignModal(assignable) { PolAssignState.policies = assignable.map(r => ({ id: r.newId, name: r.policyName || '(ohne Name)', exportType: r.exportType || '', include: [], exclude: [], })); renderPolAssignList(); openModal('modalPolAssign'); } function renderPolAssignList() { const list = document.getElementById('polAssignList'); if (!list) return; list.innerHTML = PolAssignState.policies.map((p, i) => `
    ${escapeHtml(p.name)} ${escapeHtml(POL_TYPE_LABELS[p.exportType] || p.exportType || 'Policy')}
    ${['include', 'exclude'].map(kind => `
    `).join('')}
    `).join(''); PolAssignState.policies.forEach((_, i) => { renderPolAssignChips(i, 'include'); renderPolAssignChips(i, 'exclude'); }); updatePolAssignInfo(); } function renderPolAssignChips(idx, kind) { const box = document.querySelector(`.pol-assign-chips[data-idx="${idx}"][data-kind="${kind}"]`); if (!box) return; const arr = PolAssignState.policies[idx][kind]; box.innerHTML = arr.map((g, j) => ` ${escapeHtml(g.name)} `).join(''); updatePolAssignInfo(); } function updatePolAssignInfo() { const info = document.getElementById('polAssignInfo'); if (!info) return; const n = PolAssignState.policies.filter(p => p.include.length || p.exclude.length).length; info.textContent = n ? `${n} Policy(s) mit Zuweisung` : 'Keine Zuweisung gewählt'; } let _polAssignSearchTimer = null; function polAssignHideDropdowns() { document.querySelectorAll('.pol-assign-dropdown').forEach(d => { d.classList.add('hidden'); d.innerHTML = ''; }); } // Suche (debounced) — Event-Delegation auf der Liste. document.getElementById('polAssignList')?.addEventListener('input', e => { const input = e.target.closest('.pol-assign-search'); if (!input) return; const idx = input.dataset.idx, kind = input.dataset.kind; const dd = document.querySelector(`.pol-assign-dropdown[data-idx="${idx}"][data-kind="${kind}"]`); const q = input.value.trim(); clearTimeout(_polAssignSearchTimer); if (q.length < 2) { if (dd) { dd.classList.add('hidden'); dd.innerHTML = ''; } return; } if (dd) { dd.classList.remove('hidden'); dd.innerHTML = '
    Suche…
    '; } _polAssignSearchTimer = setTimeout(async () => { try { const data = await api(`/api/groups/search?q=${encodeURIComponent(q)}`); const items = (data.items || []).slice(0, 25); if (!dd) return; if (!items.length) { dd.innerHTML = '
    Keine Gruppen gefunden.
    '; return; } dd.innerHTML = items.map(g => `
    ${escapeHtml(g.DisplayName)}
    ${g.Description ? `
    ${escapeHtml(g.Description)}
    ` : ''}
    `).join(''); } catch (err) { if (dd) dd.innerHTML = `
    Suche fehlgeschlagen: ${escapeHtml(err.message)}
    `; } }, 300); }); // Klick auf ein Suchergebnis -> als Chip hinzufügen. Klick auf Chip-× -> entfernen. document.getElementById('polAssignList')?.addEventListener('click', e => { const opt = e.target.closest('.pol-assign-dropdown .opt'); if (opt) { const dd = opt.closest('.pol-assign-dropdown'); const idx = +dd.dataset.idx, kind = dd.dataset.kind; const id = opt.dataset.id, name = opt.dataset.name; const p = PolAssignState.policies[idx]; const other = kind === 'include' ? 'exclude' : 'include'; // Nicht doppelt, und nicht gleichzeitig include+exclude derselben Gruppe. if (!p[kind].some(g => g.id === id) && !p[other].some(g => g.id === id)) { p[kind].push({ id, name }); renderPolAssignChips(idx, kind); } const input = document.querySelector(`.pol-assign-search[data-idx="${idx}"][data-kind="${kind}"]`); if (input) input.value = ''; dd.classList.add('hidden'); dd.innerHTML = ''; return; } const rm = e.target.closest('.pol-assign-chip button'); if (rm) { const idx = +rm.dataset.idx, kind = rm.dataset.kind, j = +rm.dataset.j; PolAssignState.policies[idx][kind].splice(j, 1); renderPolAssignChips(idx, kind); } }); document.addEventListener('click', e => { if (!e.target.closest('.pol-assign-search-wrap')) polAssignHideDropdowns(); }); document.getElementById('polAssignApply')?.addEventListener('click', async () => { const items = PolAssignState.policies .filter(p => p.include.length || p.exclude.length) .map(p => ({ exportType: p.exportType, id: p.id, policyName: p.name, include: p.include.map(g => g.id), exclude: p.exclude.map(g => g.id), })); if (!items.length) { closeModal('modalPolAssign'); return; } setLoading('Weise Gruppen zu…'); try { const res = await api('/api/policies/assign', { method: 'POST', body: { items }, timeoutMs: 120000 }); const results = res.results || []; const fail = results.filter(r => !r.success); if (fail.length) { const first = fail[0]; toast(`${res.assignedCount || 0} zugewiesen, ${fail.length} fehlgeschlagen. z.B. "${first.policyName || '?'}": ${first.error || 'Fehler'}`, 'err', 'Zuweisung'); } else { toast(`${res.assignedCount || 0} Policy(s) zugewiesen.`, 'ok', 'Zuweisung'); } closeModal('modalPolAssign'); } catch (e) { toast('Zuweisung fehlgeschlagen: ' + e.message, 'err'); } finally { clearLoading(); } }); // ============================================================= // Bulk: mehreren ausgewählten Policies dieselben Gruppen zuweisen // ============================================================= const POL_TYPE_TO_EXPORT = { settingscatalog: 'SettingsCatalog', compliance: 'CompliancePolicy', configuration: 'ConfigurationProfile', administrativetemplate: 'AdministrativeTemplate', }; const PolBulkAssign = { policies: [], include: [], exclude: [] }; let _polBulkSearchTimer = null; function openPolBulkAssign() { const sel = PolState.items.filter(p => PolState.selected.has(polKey(p))); if (!sel.length) return; PolBulkAssign.policies = sel.map(p => ({ id: p.id, name: p.name || '(ohne Name)', exportType: POL_TYPE_TO_EXPORT[p.type] || '', })); PolBulkAssign.include = []; PolBulkAssign.exclude = []; document.getElementById('polBulkCount').textContent = sel.length; document.getElementById('polBulkNames').innerHTML = PolBulkAssign.policies.map(p => `${escapeHtml(p.name)} ${escapeHtml(POL_TYPE_LABELS[p.exportType] || 'Policy')}` ).join(''); const addRadio = document.querySelector('input[name="polBulkMode"][value="add"]'); if (addRadio) addRadio.checked = true; const ad = document.getElementById('polBulkAllDevices'); if (ad) ad.checked = false; const au = document.getElementById('polBulkAllUsers'); if (au) au.checked = false; renderPolBulkChips('include'); renderPolBulkChips('exclude'); updatePolBulkApply(); openModal('modalPolBulkAssign'); } function renderPolBulkChips(kind) { const box = document.querySelector(`.pol-assign-chips[data-scope="bulk"][data-kind="${kind}"]`); if (!box) return; box.innerHTML = PolBulkAssign[kind].map((g, j) => ` ${escapeHtml(g.name)} `).join(''); updatePolBulkApply(); } function updatePolBulkApply() { const inc = PolBulkAssign.include.length, exc = PolBulkAssign.exclude.length; const allDev = document.getElementById('polBulkAllDevices')?.checked || false; const allUsr = document.getElementById('polBulkAllUsers')?.checked || false; const has = inc + exc > 0 || allDev || allUsr; const btn = document.getElementById('polBulkApply'); if (btn) btn.disabled = !has; const info = document.getElementById('polBulkInfo'); if (info) { if (!has) { info.textContent = 'Mindestens eine Gruppe oder ein integriertes Ziel wählen.'; } else { const parts = []; if (allDev) parts.push('Alle Geräte'); if (allUsr) parts.push('Alle Benutzer'); if (inc) parts.push(`${inc} Include`); if (exc) parts.push(`${exc} Exclude`); info.textContent = parts.join(' · '); } } } function polBulkHideDropdowns() { document.querySelectorAll('.pol-bulk-dropdown').forEach(d => { d.classList.add('hidden'); d.innerHTML = ''; }); } // Gruppensuche (debounced) im Bulk-Modal document.getElementById('modalPolBulkAssign')?.addEventListener('input', e => { const input = e.target.closest('.pol-bulk-search'); if (!input) return; const kind = input.dataset.kind; const dd = document.querySelector(`.pol-bulk-dropdown[data-kind="${kind}"]`); const q = input.value.trim(); clearTimeout(_polBulkSearchTimer); if (q.length < 2) { if (dd) { dd.classList.add('hidden'); dd.innerHTML = ''; } return; } if (dd) { dd.classList.remove('hidden'); dd.innerHTML = '
    Suche…
    '; } _polBulkSearchTimer = setTimeout(async () => { try { const data = await api(`/api/groups/search?q=${encodeURIComponent(q)}`); const items = (data.items || []).slice(0, 25); if (!dd) return; if (!items.length) { dd.innerHTML = '
    Keine Gruppen gefunden.
    '; return; } dd.innerHTML = items.map(g => `
    ${escapeHtml(g.DisplayName)}
    ${g.Description ? `
    ${escapeHtml(g.Description)}
    ` : ''}
    `).join(''); } catch (err) { if (dd) dd.innerHTML = `
    Suche fehlgeschlagen: ${escapeHtml(err.message)}
    `; } }, 300); }); // Option wählen / Chip entfernen im Bulk-Modal document.getElementById('modalPolBulkAssign')?.addEventListener('click', e => { const opt = e.target.closest('.pol-bulk-dropdown .opt'); if (opt) { const dd = opt.closest('.pol-bulk-dropdown'); const kind = dd.dataset.kind; const id = opt.dataset.id, name = opt.dataset.name; const other = kind === 'include' ? 'exclude' : 'include'; if (!PolBulkAssign[kind].some(g => g.id === id) && !PolBulkAssign[other].some(g => g.id === id)) { PolBulkAssign[kind].push({ id, name }); renderPolBulkChips(kind); } const input = document.querySelector(`.pol-bulk-search[data-kind="${kind}"]`); if (input) input.value = ''; dd.classList.add('hidden'); dd.innerHTML = ''; return; } const rm = e.target.closest('.pol-assign-chip button'); if (rm) { const kind = rm.dataset.kind, j = +rm.dataset.j; PolBulkAssign[kind].splice(j, 1); renderPolBulkChips(kind); } }); document.addEventListener('click', e => { if (!e.target.closest('#modalPolBulkAssign .pol-assign-search-wrap')) polBulkHideDropdowns(); }); document.getElementById('polBulkAllDevices')?.addEventListener('change', updatePolBulkApply); document.getElementById('polBulkAllUsers')?.addEventListener('change', updatePolBulkApply); document.getElementById('polBulkApply')?.addEventListener('click', async () => { const inc = PolBulkAssign.include.map(g => g.id); const exc = PolBulkAssign.exclude.map(g => g.id); const allDevices = document.getElementById('polBulkAllDevices')?.checked || false; const allUsers = document.getElementById('polBulkAllUsers')?.checked || false; if (!inc.length && !exc.length && !allDevices && !allUsers) return; const mode = (document.querySelector('input[name="polBulkMode"]:checked')?.value) === 'replace' ? 'replace' : 'add'; const items = PolBulkAssign.policies.map(p => ({ exportType: p.exportType, id: p.id, policyName: p.name, include: inc, exclude: exc, allDevices, allUsers, mode, })); setLoading('Weise Gruppen zu…'); try { const res = await api('/api/policies/assign', { method: 'POST', body: { items }, timeoutMs: 180000 }); const results = res.results || []; const fail = results.filter(r => !r.success); if (fail.length) { const first = fail[0]; toast(`${res.assignedCount || 0} zugewiesen, ${fail.length} fehlgeschlagen. z.B. "${first.policyName || '?'}": ${first.error || 'Fehler'}`, 'err', 'Zuweisung'); } else { toast(`Gruppen ${mode === 'add' ? 'hinzugefügt' : 'ersetzt'}: ${res.assignedCount || 0} Policy(s).`, 'ok', 'Zuweisung'); } closeModal('modalPolBulkAssign'); } catch (e) { toast('Zuweisung fehlgeschlagen: ' + e.message, 'err'); } finally { clearLoading(); } }); document.getElementById('btnPolAssignGroups')?.addEventListener('click', openPolBulkAssign); // Alle Policies als Snapshot in den konfigurierten Git-Ordner schreiben + committen. async function polGitSnapshot() { setLoading('Erstelle Policy-Snapshot & committe…'); try { const res = await api('/api/policies/git-snapshot', { method: 'POST', timeoutMs: 300000 }); if (res.changed === false) { toast(res.message || 'Keine Änderungen seit dem letzten Snapshot.', 'info', 'Git-Snapshot'); } else { let msg = res.message || `Snapshot committet (${res.total} Policies).`; if (res.pushError) msg += ` — Push-Fehler: ${res.pushError}`; toast(msg, res.pushError ? 'warn' : 'ok', 'Git-Snapshot'); } } catch (e) { toast('Snapshot fehlgeschlagen: ' + e.message, 'err'); } finally { clearLoading(); } } // Ordner-Auswahl für den Git-Repo-Pfad (Einstellungen). async function pickPolicyGitFolder() { try { const res = await api('/api/pickfolder', { method: 'POST', body: { title: 'Git-Repo-Ordner für Policy-Backup wählen' } }); if (res && res.path) document.getElementById('setPolicyGitPath').value = res.path; } catch (e) { toast('Ordner-Auswahl fehlgeschlagen: ' + e.message, 'err'); } } document.getElementById('btnPickPolicyGit')?.addEventListener('click', pickPolicyGitFolder); // --- Policies: Event-Wiring --- document.getElementById('btnPolRefresh')?.addEventListener('click', loadPolicies); document.getElementById('btnPolGitSnapshot')?.addEventListener('click', polGitSnapshot); document.getElementById('btnPolExport')?.addEventListener('click', polExportSelected); document.getElementById('btnPolImport')?.addEventListener('click', () => document.getElementById('polImportFile').click()); document.getElementById('polImportFile')?.addEventListener('change', e => { polHandleImportFiles(e.target.files); e.target.value = ''; // erlaubt erneuten Import derselben Datei }); document.getElementById('polCheckAll')?.addEventListener('change', e => { const visible = polFiltered().map(polKey); if (e.target.checked) visible.forEach(k => PolState.selected.add(k)); else visible.forEach(k => PolState.selected.delete(k)); renderPolicies(); }); let _polSearchTimer = null; document.getElementById('polSearch')?.addEventListener('input', e => { clearTimeout(_polSearchTimer); _polSearchTimer = setTimeout(() => { PolState.search = e.target.value.trim(); renderPolicies(); }, 200); }); document.getElementById('polTypeFilter')?.addEventListener('change', e => { PolState.typeFilter = e.target.value; renderPolicies(); }); document.querySelectorAll('#polTable th.sortable').forEach(th => { th.addEventListener('click', () => { if (PolState.sortCol === th.dataset.col) PolState.sortAsc = !PolState.sortAsc; else { PolState.sortCol = th.dataset.col; PolState.sortAsc = true; } renderPolicies(); }); }); // ============================================================= // OFFBOARDING: Geräte aus Intune / Autopilot / Entra entfernen // ============================================================= const OffState = { items: [], selected: new Set(), pending: [] }; // Eindeutiger Zeilen-Key: Intune-Geräte haben eine intuneDeviceId, reine // Autopilot-Geräte nicht -> auf Autopilot-/Entra-Id bzw. Seriennummer ausweichen. function offKey(d) { return d.intuneDeviceId || d.autopilotId || d.azureADDeviceId || d.entraObjectId || d.serialNumber || ''; } function offSelectedDevices() { return OffState.items.filter(d => OffState.selected.has(offKey(d))); } async function offSearch() { const q = document.getElementById('offSearch').value.trim(); const type = document.getElementById('offSearchType').value; if (q.length < 2) { toast('Mindestens 2 Zeichen.', 'warn'); return; } const body = document.getElementById('offBody'); body.innerHTML = ` Suche…`; try { const res = await api(`/api/offboard/search?query=${encodeURIComponent(q)}&type=${type}`, { timeoutMs: 60000 }); OffState.items = res.items || []; OffState.selected = new Set(); renderOff(); } catch (e) { toast('Suche fehlgeschlagen: ' + e.message, 'err'); body.innerHTML = `Fehler bei der Suche.`; } } function offSvcBadges(d) { const b = []; if (d.inIntune) b.push('Intune'); if (d.inEntra) b.push('Entra'); if (d.inAutopilot) b.push('Autopilot'); if (d.coManaged) b.push('Co-Mgmt'); return b.join(' ') || '—'; } function renderOff() { const body = document.getElementById('offBody'); if (!OffState.items.length) { body.innerHTML = `Keine Geräte gefunden.`; offUpdateSel(); return; } body.innerHTML = OffState.items.map(d => { const k = offKey(d); const checked = OffState.selected.has(k) ? 'checked' : ''; return ` ${escapeHtml(d.deviceName || '—')} ${escapeHtml(d.serialNumber || '—')} ${escapeHtml(((d.operatingSystem || '') + ' ' + (d.osVersion || '')).trim() || '—')} ${escapeHtml(d.primaryUser || '—')} ${polFmtDate(d.lastSync)} ${offSvcBadges(d)} `; }).join(''); body.querySelectorAll('.off-row-check').forEach(cb => { cb.addEventListener('change', () => { if (cb.checked) OffState.selected.add(cb.dataset.key); else OffState.selected.delete(cb.dataset.key); offUpdateSel(); }); }); body.querySelectorAll('tr[data-key]').forEach(tr => { tr.addEventListener('click', e => { if (e.target.classList.contains('off-row-check')) return; const cb = tr.querySelector('.off-row-check'); cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }); }); offUpdateSel(); } function offUpdateSel() { const n = OffState.selected.size; document.getElementById('offSelCount').textContent = `${n} ausgewählt`; document.getElementById('offBtn').disabled = n === 0; } function openOffboardModal(devsArg) { const devs = Array.isArray(devsArg) ? devsArg : offSelectedDevices(); if (!devs.length) return; OffState.pending = devs; document.getElementById('offConfirmList').innerHTML = devs.map(d => `
    ${escapeHtml(d.deviceName || '—')} — Intune: ${d.intuneDeviceId ? '✓' : '–'}, Entra: ${d.entraObjectId ? '✓' : '–'}, Autopilot: ${d.autopilotId ? '✓' : '–'}${(!d.autopilotId && d.autopilotNote) ? ` (${escapeHtml(d.autopilotNote)})` : ''}
    `).join(''); document.getElementById('offModalInfo').textContent = `${devs.length} Gerät(e)`; document.getElementById('offConfirmChk').checked = false; document.getElementById('offExecuteBtn').disabled = true; document.getElementById('offResults').innerHTML = ''; document.getElementById('offKeys').innerHTML = '
    Recovery-Keys
    Wird geladen…
    '; openModal('modalOffboard'); offLoadKeys(devs); } async function offLoadKeys(devs) { const box = document.getElementById('offKeys'); const parts = []; for (const d of devs) { try { const res = await api('/api/offboard/keys', { method: 'POST', body: { intuneDeviceId: d.intuneDeviceId, azureADDeviceId: d.azureADDeviceId, operatingSystem: d.operatingSystem }, timeoutMs: 60000, }); const k = res.keys || {}; let html = `
    ${escapeHtml(d.deviceName || '—')}`; (k.bitlocker || []).forEach(b => { html += `
    BitLocker${b.volumeType ? ' (' + escapeHtml(b.volumeType) + ')' : ''}: ${escapeHtml(b.key)}
    `; }); if (k.fileVault) html += `
    FileVault: ${escapeHtml(k.fileVault)}
    `; if (k.laps) html += `
    LAPS${k.laps.account ? ' (' + escapeHtml(k.laps.account) + ')' : ''}: ${escapeHtml(k.laps.password)}
    `; if (k.notes && k.notes.length) html += `
    ${k.notes.map(escapeHtml).join(' · ')}
    `; html += '
    '; parts.push(html); } catch (e) { parts.push(`
    ${escapeHtml(d.deviceName || '—')} Keys-Fehler: ${escapeHtml(e.message)}
    `); } } box.innerHTML = `
    🔑 Recovery-Keys — vor dem Löschen sichern!
    ${parts.join('')}`; } async function offExecute() { const devs = (OffState.pending && OffState.pending.length) ? OffState.pending : offSelectedDevices(); if (!devs.length) return; const services = { entra: document.querySelector('input[name="offEntra"]:checked').value, intune: document.getElementById('offSvcIntune').checked, autopilot: document.getElementById('offSvcAutopilot').checked, }; if (services.entra === 'none' && !services.intune && !services.autopilot) { toast('Keinen Dienst ausgewählt.', 'warn'); return; } const payload = devs.map(d => ({ deviceName: d.deviceName, intuneDeviceId: d.intuneDeviceId, entraObjectId: d.entraObjectId, autopilotId: d.autopilotId, })); document.getElementById('offExecuteBtn').disabled = true; setLoading('Offboarding läuft…'); try { const res = await api('/api/offboard/execute', { method: 'POST', body: { devices: payload, services }, timeoutMs: 180000 }); const results = res.results || []; // Ergebnis pro Gerät gruppieren const byDev = new Map(); for (const r of results) { if (!byDev.has(r.deviceName)) byDev.set(r.deviceName, []); byDev.get(r.deviceName).push(r); } const devHtml = [...byDev.entries()].map(([name, rows]) => { const errs = rows.filter(r => !r.success).length; const lines = rows.map(r => `
    ${r.success ? '✓' : '✗'} ${escapeHtml(r.service)} ${escapeHtml(r.action)}${r.error ? ': ' + escapeHtml(r.error) : ''}
    ` ).join(''); return `
    ${escapeHtml(name || '—')}${errs ? ` ${errs} Fehler` : ''}
    ${lines}
    `; }).join(''); const reportLink = res.reportUrl ? `
    HTML-Report öffnen ` : ''; document.getElementById('offResults').innerHTML = `
    Ergebnis (${res.successCount}/${res.total})
    ${devHtml}${reportLink}`; toast(`${res.successCount}/${res.total} Aktionen erfolgreich.`, res.successCount === res.total ? 'ok' : 'warn', 'Offboarding'); // Erfolgreich vollständig offgeboardete Geräte aus der Liste nehmen. const doneNames = new Set(results.filter(r => r.success).map(r => r.deviceName)); if (doneNames.size) { OffState.items = OffState.items.filter(d => !doneNames.has(d.deviceName) || results.some(r => r.deviceName === d.deviceName && !r.success)); OffState.selected = new Set(); renderOff(); } } catch (e) { toast('Offboarding fehlgeschlagen: ' + e.message, 'err'); document.getElementById('offExecuteBtn').disabled = false; } finally { clearLoading(); } } document.getElementById('offSearchBtn')?.addEventListener('click', offSearch); document.getElementById('offSearch')?.addEventListener('keydown', e => { if (e.key === 'Enter') offSearch(); }); document.getElementById('offCheckAll')?.addEventListener('change', e => { OffState.items.forEach(d => { const k = offKey(d); if (e.target.checked) OffState.selected.add(k); else OffState.selected.delete(k); }); renderOff(); }); document.getElementById('offBtn')?.addEventListener('click', openOffboardModal); document.getElementById('offConfirmChk')?.addEventListener('change', e => { document.getElementById('offExecuteBtn').disabled = !e.target.checked; }); document.getElementById('offExecuteBtn')?.addEventListener('click', offExecute);