Files
Intune-Manager/www/app.js
T
marcoandClaude Opus 4.8 e4efb0ac32
Build & Release MSI / build-msi (push) Canceled after 0s
v0.1.33 — Offboarding-Report und Update-Hinweis
- Offboarding: HTML-Report pro Geraet (New-OffboardHtmlReport) + reportUrl;
  Dialog-Ergebnis pro Geraet gruppiert + Report-Link
- Update-Check: GET /api/update/check (Gitea releases/latest, Compare-SemVer);
  Kopfzeilen-Badge bei neuer Version, Link zur Release-Seite; optionaler read-only Token

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 15:59:30 +02:00

7085 lines
294 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =============================================================
// 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 = `
<div class="toast-ico">${ico}</div>
<div class="toast-content">
${title ? `<div class="toast-title">${escapeHtml(title)}</div>` : ''}
<div class="toast-msg">${escapeHtml(msg)}</div>
</div>
`;
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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
// Markdown -> HTML. Nutzt marked (gebundelt in /vendor/marked.min.js).
// breaks: true -> einzelne Newlines werden zu <br>, 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 =>
`<option value="${escapeHtml(it.id)}" ${it.id === t.activeId ? 'selected' : ''}>${escapeHtml(it.label || it.tenantId)}</option>`
).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 = `<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0"/><path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1"/></svg>`;
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 = `
<div class="skeleton">
<div class="sk-line med"></div>
<div class="sk-line short"></div>
<div class="sk-line med"></div>
</div>`;
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 = `
<div class="skeleton">
<div class="sk-line med"></div>
<div class="sk-line short"></div>
<div class="sk-line med"></div>
</div>`;
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 = `
<div class="skeleton">
<div class="sk-line med"></div>
<div class="sk-line short"></div>
<div class="sk-line med"></div>
<div class="sk-line short"></div>
</div>`;
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
? `<div class="target-meta">${escapeHtml(t.UserPrincipalName)}${t.Department ? ' · ' + escapeHtml(t.Department) : ''}</div>`
: '';
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
? `<button class="row-expand ${expanded ? 'is-open' : ''}" data-group-id="${escapeHtml(t.Id)}" data-group-name="${escapeHtml(t.DisplayName)}" title="Mitglieder anzeigen">›</button>`
: '';
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 `<div class="target-row-wrap ${expanded ? 'expanded' : ''}">
<label class="target-row ${isSel} ${isEmpty}" data-id="${escapeHtml(t.Id)}" ${isEmpty ? 'title="Diese Gruppe hat keine Mitglieder"' : ''}>
<input type="checkbox" ${checked}>
<div class="target-name">
<span class="target-text">${escapeHtml(t.DisplayName)}</span>
${meta}
</div>
<div class="target-link-icons">${linkIcons}</div>
${expandBtn}
</label>
${expandedHtml}
</div>`;
}).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 `<span class="pin-chip ${isEmpty}" data-id="${escapeHtml(it.id)}" data-mode="${it.mode}" title="${escapeHtml(title)}">
<span class="pin-mode">${escapeHtml(modeLabels[it.mode] || it.mode)}</span>
<span class="pin-name">${escapeHtml(it.name)}</span>
<button class="pin-remove" data-id="${escapeHtml(it.id)}" data-mode="${it.mode}" aria-label="Entfernen" title="Aus Auswahl entfernen">×</button>
</span>`;
}).join('');
wrap.innerHTML = `
<div class="pin-head">
<span class="pin-head-title">Aktuelle Auswahl</span>
<span class="pin-head-count">${items.length}</span>
<button class="link-btn pin-clear-all" type="button">Alles leeren</button>
</div>
<div class="pin-chips">${chips}</div>
`;
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 = `
<div class="skeleton">
<div class="sk-line med"></div>
<div class="sk-line short"></div>
<div class="sk-line med"></div>
<div class="sk-line short"></div>
<div class="sk-line med"></div>
</div>`;
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:<id>" — 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
? `<span class="row-status full" title="Alle ausgewählten Empfänger sind bereits Mitglied einer zugewiesenen Gruppe">✓</span>`
: (isPartialMember ? `<span class="row-status partial" title="Nur ein Teil der Empfänger ist bereits Mitglied">◐</span>` : '');
const queuedCount = sessionPills.length;
const queuedBadge = queuedCount > 0 ? `<span class="row-queued" title="Geplante Zuweisungen für diese App">${queuedCount}</span>` : '';
// 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 ? `<span class="type-btn-count">${assignable.length}</span>` : '';
const queuedSuffix = hasQueued ? `<span class="type-btn-queued">${queuedForType}</span>` : '';
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 `<button class="${cls}" data-app-id="${escapeHtml(app.AppId)}" data-type="${type}" title="${escapeHtml(title)}" ${isNativeOnly ? 'disabled' : ''}>
<span>${type}</span>${countSuffix}${queuedSuffix}
</button>`;
};
const availBtn = renderTypeButton(app.AvailableGroups, 'Available');
const reqBtn = renderTypeButton(app.RequiredGroups, 'Required');
const createBtn = `<button class="type-btn type-create" data-create-group data-app-id="${escapeHtml(app.AppId)}" data-app-name="${escapeHtml(app.AppName)}" title="Zuweisung hinzufügen (Required-Gruppe, Available-Gruppe, All Users, All Devices)">
<span>+</span>
</button>`;
const removableReq = app.RequiredGroups.filter(g => !g.IsNative);
const removableAvail = app.AvailableGroups.filter(g => !g.IsNative);
const mkRemoveBtn = (groups, type, label) => groups.length > 0
? `<button class="type-btn type-remove type-remove-${type}" data-remove-group data-remove-type="${type}" data-app-id="${escapeHtml(app.AppId)}" data-app-name="${escapeHtml(app.AppName)}" title="${label}-Gruppe von App entfernen"><span>−${label}</span></button>`
: `<span class="type-btn type-remove type-remove--empty"></span>`;
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 = `<a class="app-source-link" href="${srcUrl}" target="_blank" rel="noopener" title="${srcTitle}"><img class="app-source ${srcClass}" src="${srcLogo}" alt="${srcTitle}"${vendor ? ` onerror="${srcFallback}"` : ''}></a>`;
// 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
? `<span class="app-delete-btn app-delete-locked" title="${escapeHtml(vendor.displayName)}-Apps können nur im ${escapeHtml(vendor.displayName)}-Portal gelöscht werden, nicht in Intune." aria-label="Löschen nicht möglich (${escapeHtml(vendor.displayName)})">${LOCK_ICON}</span>`
: `<button class="app-delete-btn" data-delete-app data-app-id="${escapeHtml(app.AppId)}" data-app-name="${escapeHtml(app.AppName)}" title="App aus Intune löschen" aria-label="App löschen">${TRASH_ICON}</button>`;
const expanded = State.expandedApps.has(app.AppId);
const detailHtml = expanded ? renderAppDetailPanel(app.AppId) : '';
return `<div class="app-row-wrap ${expanded ? 'expanded' : ''}" data-app-id="${escapeHtml(app.AppId)}">
<div class="app-row ${queuedCount > 0 ? 'has-queued' : ''} ${hasNoAssignments ? 'no-assignments' : ''} ${expanded ? 'is-open' : ''}" data-app-id="${escapeHtml(app.AppId)}">
<div class="col col-status">${statusDot}</div>
<div class="col col-name">
<div class="app-name-line">
<span class="app-name" title="${escapeHtml(app.AppName)}">${escapeHtml(app.AppName)}</span>
${queuedBadge}
<span class="app-name-icons">
${renameLocked
? `<span class="entra-link app-rename-locked" title="${escapeHtml(vendor.displayName)}-Apps können nur im ${escapeHtml(vendor.displayName)}-Portal umbenannt werden." aria-label="Umbenennen nicht möglich (${escapeHtml(vendor.displayName)})">${LOCK_ICON}</span>`
: `<button class="entra-link app-rename-btn" data-rename-app data-app-id="${escapeHtml(app.AppId)}" data-app-name="${escapeHtml(app.AppName)}" title="App umbenennen" aria-label="App umbenennen">${PENCIL_ICON}</button>`}
${intuneIcon}
</span>
</div>
${app.Publisher ? `<div class="app-publisher">${escapeHtml(app.Publisher)}</div>` : ''}
</div>
<div class="col col-source">${srcBadge}</div>
<div class="col col-type"><span class="app-type">${escapeHtml(app.AppType)}</span></div>
<div class="col col-version">${app.Version ? `<span class="app-version" title="Version">${escapeHtml(app.Version)}</span>` : '<span class="cell-empty">—</span>'}</div>
<div class="col col-avail">${availBtn || '<span class="cell-empty">—</span>'}</div>
<div class="col col-req">${reqBtn || '<span class="cell-empty">—</span>'}</div>
<div class="col col-create">${createBtn}</div>
<div class="col col-remove-req">${removeReqBtn}</div>
<div class="col col-remove-avail">${removeAvailBtn}</div>
<div class="col col-delete">${deleteBtn}</div>
</div>
${detailHtml}
</div>`;
}
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 `<div class="asg-empty">Keine ${type === 'available' ? 'Available' : 'Required'}-Zuweisungen.</div>`;
}
return `<ul class="asg-list">${groups.map(g => {
const isNative = g.IsNative;
const memb = getCachedMembership(g.GroupId);
let mLabel = '';
if (memb && !isNative) {
if (memb.status === 'Full') mLabel = `<span class="asg-mb full">alle Mitglied</span>`;
else if (memb.status === 'Partial') mLabel = `<span class="asg-mb partial">${memb.matched}/${memb.total} Mitglied</span>`;
}
const linkIcons = isNative
? `<span class="asg-native-tag" title="Native Zuweisung — nicht änderbar">native</span>`
: `${entraGroupLink(g.GroupId, g.GroupName)}${membersTabLink(g.GroupId, g.GroupName)}`;
const delBtn = isNative
? ''
: `<button class="asg-del" data-remove-assign
data-app-id="${escapeHtml(app.AppId)}"
data-app-name="${escapeHtml(app.AppName)}"
data-group-id="${escapeHtml(g.GroupId)}"
data-group-name="${escapeHtml(g.GroupName)}"
data-type="${type}"
title="Zuweisung dieser Gruppe von der App entfernen">${TRASH_ICON}</button>`;
return `<li class="asg-row ${isNative ? 'native' : ''}">
<span class="asg-name" title="${escapeHtml(g.GroupName)}">${escapeHtml(g.GroupName)}</span>
${mLabel}
<span class="asg-actions">${linkIcons}${delBtn}</span>
</li>`;
}).join('')}</ul>`;
};
const avail = renderGroupList(app.AvailableGroups, 'available');
const req = renderGroupList(app.RequiredGroups, 'required');
return `<div class="app-detail-section app-detail-section--wide asg-section">
<div class="app-detail-section-title">Zuweisungen</div>
<div class="asg-grid">
<div class="asg-col">
<div class="asg-col-head"><span class="asg-badge avail">Available</span><span class="asg-count">${app.AvailableGroups.length}</span></div>
${avail}
</div>
<div class="asg-col">
<div class="asg-col-head"><span class="asg-badge req">Required</span><span class="asg-count">${app.RequiredGroups.length}</span></div>
${req}
</div>
</div>
</div>`;
}
function renderAppDetailPanel(appId) {
const entry = State.appDetails.get(appId);
if (!entry || entry.loading) {
return `<div class="app-detail" data-app-id="${escapeHtml(appId)}">
<div class="app-detail-loading"><div class="spinner spinner-sm"></div> Lade Details aus Intune...</div>
</div>`;
}
if (entry.error) {
return `<div class="app-detail" data-app-id="${escapeHtml(appId)}">
<div class="app-detail-error">Konnte Details nicht laden: ${escapeHtml(entry.error)}</div>
</div>`;
}
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 ? `<button class="app-detail-copy" data-copy="${escapeHtml(String(value))}" title="Kopieren">⧉</button>` : '';
return `<div class="app-detail-row">
<div class="app-detail-lbl">${escapeHtml(label)}</div>
<div class="${cls}">${escapeHtml(String(v))}${copy}</div>
</div>`;
};
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(`<div class="app-detail-section"><div class="app-detail-section-title">Allgemein</div>${general}</div>`);
// Beschreibung
if (d.Description || d.Notes) {
let desc = '';
if (d.Description) desc += `<div class="app-detail-desc md-content">${renderMarkdown(d.Description)}</div>`;
if (d.Notes) desc += `<div class="app-detail-desc app-detail-notes md-content"><span class="app-detail-lbl">Notizen:</span> ${renderMarkdown(d.Notes)}</div>`;
sections.push(`<div class="app-detail-section"><div class="app-detail-section-title">Beschreibung</div>${desc}</div>`);
}
// 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(`<div class="app-detail-section"><div class="app-detail-section-title">Installation</div>${installRows}</div>`);
// 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(`<div class="app-detail-section"><div class="app-detail-section-title">MSI</div>${msiRows}</div>`);
}
// Detection-Rules — wide weil Registry-Pfade lang werden koennen
if (d.DetectionRules && d.DetectionRules.length) {
sections.push(`<div class="app-detail-section app-detail-section--wide">
<div class="app-detail-section-title">Detection</div>
<ul class="app-detail-list">${d.DetectionRules.map(r => `<li>${escapeHtml(r)}</li>`).join('')}</ul>
</div>`);
}
// Return-Codes
if (d.ReturnCodes && d.ReturnCodes.length) {
const rows = d.ReturnCodes.map(rc => `<li><span class="mono">${escapeHtml(String(rc.Code))}</span> · ${escapeHtml(rc.Type)}</li>`).join('');
sections.push(`<div class="app-detail-section"><div class="app-detail-section-title">Return-Codes</div><ul class="app-detail-list">${rows}</ul></div>`);
}
// Meta
const metaRows = [
row('Erstellt', fmtDate(d.CreatedDateTime)),
row('Geändert', fmtDate(d.LastModifiedDateTime)),
d.InformationUrl ? `<div class="app-detail-row"><div class="app-detail-lbl">Info-URL</div><div class="app-detail-val"><a href="${escapeHtml(d.InformationUrl)}" target="_blank" rel="noopener">${escapeHtml(d.InformationUrl)}</a></div></div>` : '',
d.PrivacyInformationUrl ? `<div class="app-detail-row"><div class="app-detail-lbl">Privacy</div><div class="app-detail-val"><a href="${escapeHtml(d.PrivacyInformationUrl)}" target="_blank" rel="noopener">${escapeHtml(d.PrivacyInformationUrl)}</a></div></div>` : '',
].filter(Boolean).join('');
if (metaRows) sections.push(`<div class="app-detail-section"><div class="app-detail-section-title">Meta</div>${metaRows}</div>`);
// 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 = `<strong>${escapeHtml(center.AppName)}</strong>`;
const t = `<strong>${escapeHtml(r.TargetDisplayName || r.TargetId)}</strong>`;
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 = `<strong>${escapeHtml(center.AppName)}</strong>`;
const t = `<strong>${escapeHtml(r.TargetDisplayName || r.TargetId)}</strong>`;
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(`<div class="app-detail-section">
<div class="app-detail-section-title">Setup-Datei</div>
<div class="content-update-row">
<button class="btn btn-secondary btn-sm" data-update-content
data-app-id="${escapeHtml(appId)}"
data-app-name="${escapeHtml(d.DisplayName || app.AppName || '')}"
data-app-type="${escapeHtml(app.AppTypeRaw)}"
data-app-version="${escapeHtml(app.Version || d.DisplayVersion || '')}">Setup-Datei aktualisieren…</button>
<div class="form-hint">Lädt eine neue <code>.intunewin</code> hoch und aktiviert sie als neue Version. Betrifft alle zugewiesenen Geräte beim nächsten Sync.</div>
</div>
</div>`);
}
if (sections.length === 0) {
return `<div class="app-detail" data-app-id="${escapeHtml(appId)}"><div class="app-detail-empty">Keine zusätzlichen Details verfügbar.</div></div>`;
}
return `<div class="app-detail" data-app-id="${escapeHtml(appId)}">${sections.join('')}</div>`;
}
// =============================================================
// 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) => `
<div class="stat-cell stat-${kind}">
<div class="stat-num">${value}</div>
<div class="stat-lbl">${escapeHtml(label)}</div>
</div>`;
// 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 `<div class="install-bar"><div class="install-bar-seg install-bar-na" style="width:100%" title="Keine Daten"></div></div>`;
const w = (v) => `${Math.max(0, Math.round((v / total) * 1000) / 10)}%`;
return `<div class="install-bar" title="${ok} inst. / ${fail} Fehler / ${pend} ausst. / ${not} nicht inst. / ${na} N/A (gesamt ${total})">
${ok ? `<div class="install-bar-seg install-bar-ok" style="width:${w(ok)}" title="${ok} installiert"></div>` : ''}
${fail ? `<div class="install-bar-seg install-bar-err" style="width:${w(fail)}" title="${fail} Fehler"></div>` : ''}
${pend ? `<div class="install-bar-seg install-bar-pend" style="width:${w(pend)}" title="${pend} ausstehend"></div>` : ''}
${not ? `<div class="install-bar-seg install-bar-not" style="width:${w(not)}" title="${not} nicht inst."></div>` : ''}
${na ? `<div class="install-bar-seg install-bar-na" style="width:${w(na)}" title="${na} N/A"></div>` : ''}
</div>`;
};
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) => `
<div class="install-axis">
<div class="install-axis-head">
<span class="install-axis-title">${escapeHtml(title)}</span>
${ratePct !== null ? `<span class="install-axis-rate ${ratePct >= 95 ? 'good' : ratePct >= 75 ? 'mid' : 'bad'}">${ratePct}% erfolgreich</span>` : ''}
</div>
${stackedBar(d.ok, d.fail, d.pend, d.not, d.na)}
<div class="install-bar-legend">
<span class="ibl ibl-ok">Installiert</span>
<span class="ibl ibl-err">Fehler</span>
<span class="ibl ibl-pend">Ausstehend</span>
<span class="ibl ibl-not">Nicht inst.</span>
<span class="ibl ibl-na">N/A</span>
</div>
<div class="install-stats-grid">
${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')}
</div>
</div>`;
return `<div class="app-detail-section app-detail-section--wide">
<div class="app-detail-section-title">Installationsübersicht</div>
<div class="install-stats">
${axis('Geräte', dev, devPct)}
${axis('Benutzer', usr, usrPct)}
</div>
</div>`;
}
// 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 `<div class="${cls}" ${!isCenter && known ? `data-jump-app="${escapeHtml(id)}" role="button" tabindex="0" title="Zu dieser App springen"` : ''}>
<div class="flow-node-name">${name}</div>
${meta ? `<div class="flow-node-meta">${meta}</div>` : ''}
</div>`;
};
// 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 `<path class="flow-arrow" d="${d}" marker-end="url(#${flowId}-arr)"/>
${kind ? `<g class="flow-arrow-label-g"><rect class="flow-arrow-label-bg" x="${labelX - 32}" y="${labelY - 10}" width="64" height="14" rx="3"/><text class="flow-arrow-label" x="${labelX}" y="${labelY}" text-anchor="middle">${escapeHtml(kind)}</text></g>` : ''}`;
}).join('');
// Target-Nodes (HTML overlay via foreignObject)
const targetNodesSvg = related.map((r, i) => {
const y = targetY(i);
return `<foreignObject x="${targetX}" y="${y}" width="${NODE_W}" height="${NODE_H}">
<div xmlns="http://www.w3.org/1999/xhtml" class="flow-node-wrap">${nodeHtml(r, false)}</div>
</foreignObject>`;
}).join('');
// Klartext-Erklaerungen pro Relation
const explanations = explanationFn ? related.map(r => explanationFn(centerApp, r)).filter(Boolean) : [];
const explanationHtml = explanations.length ? `
<div class="flow-explanations">
<div class="flow-explanations-title">Was bedeutet das?</div>
<ul>${explanations.map(e => `<li>${e}</li>`).join('')}</ul>
</div>` : '';
return `<div class="app-detail-section app-detail-section--wide">
<div class="app-detail-section-title">${escapeHtml(title)}</div>
<div class="flow-subtitle">${escapeHtml(subtitle)}</div>
<div class="flow-container" data-flow-id="${flowId}">
<svg class="flow-svg" viewBox="0 0 ${svgW} ${svgH}" width="100%" preserveAspectRatio="xMidYMid meet" style="height:${svgH}px; max-height:${svgH}px;">
<defs>
<marker id="${flowId}-arr" markerWidth="12" markerHeight="12" refX="11" refY="4" orient="auto">
<path d="M0,0 L0,8 L11,4 z" fill="currentColor"/>
</marker>
</defs>
${arrows}
<foreignObject x="${centerX}" y="${centerY}" width="${NODE_W}" height="${NODE_H}">
<div xmlns="http://www.w3.org/1999/xhtml" class="flow-node-wrap">${nodeHtml(centerApp, true)}</div>
</foreignObject>
${targetNodesSvg}
</svg>
</div>
${explanationHtml}
</div>`;
}
// 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 <a> 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 = `
<div class="picker-head">${escapeHtml(type)} — Gruppe wählen</div>
<div class="picker-list">
${groups.map(g => {
const queued = queuedSet.has(g.GroupId);
const memb = getCachedMembership(g.GroupId);
let mLabel = '';
if (memb) {
if (memb.status === 'Full') mLabel = `<span class="picker-mb full">Mitglied</span>`;
else if (memb.status === 'Partial') mLabel = `<span class="picker-mb partial">${memb.matched}/${memb.total}</span>`;
}
const sideActions = `
<span class="picker-actions" data-stop>
${entraGroupLink(g.GroupId, g.GroupName)}
${membersTabLink(g.GroupId, g.GroupName)}
<button class="picker-del" data-remove-assign
data-app-id="${escapeHtml(app.AppId)}"
data-app-name="${escapeHtml(app.AppName)}"
data-group-id="${escapeHtml(g.GroupId)}"
data-group-name="${escapeHtml(g.GroupName)}"
data-type="${type.toLowerCase()}"
title="Zuweisung dieser Gruppe von der App entfernen">${TRASH_ICON}</button>
</span>
`;
return `
<div class="picker-row ${queued ? 'queued' : ''}">
<button class="picker-item" data-group-id="${escapeHtml(g.GroupId)}" data-group-name="${escapeHtml(g.GroupName)}">
<span class="picker-check">${queued ? '✓' : ''}</span>
<span class="picker-name">${escapeHtml(g.GroupName)}</span>
${mLabel}
</button>
${sideActions}
</div>
`;
}).join('')}
</div>
`;
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 = `
<div class="picker-head">Gruppe entfernen — ${escapeHtml(app.AppName)}</div>
<div class="picker-list">
${groups.map(g => {
const badge = g._type === 'required'
? `<span class="asg-badge req" style="font-size:10px;padding:1px 5px">Req</span>`
: `<span class="asg-badge avail" style="font-size:10px;padding:1px 5px">Avail</span>`;
return `
<div class="picker-row">
<button class="picker-item picker-remove-item"
data-group-id="${escapeHtml(g.GroupId)}"
data-group-name="${escapeHtml(g.GroupName)}"
data-type="${escapeHtml(g._type)}">
${badge}
<span class="picker-name">${escapeHtml(g.GroupName)}</span>
</button>
</div>`;
}).join('')}
</div>
`;
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 = `<div class="session-scope" title="${escapeHtml(single.name)}">
<span class="scope-tag ${tagCls}">${tagLbl}</span>
<span class="scope-target">${escapeHtml(single.name)}</span>
</div>`;
} 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(`<span class="scope-tag scope-dept">${deptN}&nbsp;Gruppe${deptN === 1 ? '' : 'n'}</span>`);
if (userN > 0) tags.push(`<span class="scope-tag scope-user">${userN}&nbsp;Benutzer</span>`);
const tip = scope.map(x => x.name).join('\n');
scopeBlock = `<div class="session-scope" title="${escapeHtml(tip)}">${tags.join('')}</div>`;
}
return `<div class="session-item">
<div class="session-info">
<div class="session-app">${escapeHtml(s.appName)}</div>
<div class="session-grp">${escapeHtml(s.groupName)}</div>
${scopeBlock}
</div>
<span class="session-type-badge ${s.type === 'Available' ? 'avail' : 'req'}">${s.type === 'Available' ? 'A' : 'R'}</span>
<button class="session-remove" data-id="${s.id}" title="Entfernen">×</button>
</div>`;
}).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 }) => `
<div class="existing-item">
<div class="existing-app" title="${escapeHtml(app.AppName)}">${escapeHtml(app.AppName)}</div>
<div class="existing-flags">
${flags.map(f => `<span class="exist-flag ${f.cls}">${escapeHtml(f.label)}</span>`).join('')}
</div>
</div>
`).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 = `
<strong>${total}</strong> Vorgang${total === 1 ? '' : 'e'} wird ausgeführt:
<ul style="margin: 8px 0 0 18px; line-height: 1.6;">
${deptOps > 0 ? `<li><strong>${deptOps}</strong> auf Gruppen</li>` : ''}
${userOps > 0 ? `<li><strong>${userOps}</strong> auf Einzelbenutzer</li>` : ''}
</ul>`;
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 = `<span class="res-banner-ico">${bannerIco}</span><span>${escapeHtml(bannerText)}</span>`;
// 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 `<div class="res-row res-row-${cls}">
<span class="res-row-ico">${sym}</span>
<div class="res-row-main">
<div class="res-row-line1"><strong>${escapeHtml(d.target || '')}</strong> → ${escapeHtml(d.app || '')}</div>
<div class="res-row-line2">${escapeHtml(d.group || '')} <span class="res-row-type">${escapeHtml(d.type || '')}</span></div>
${d.message ? `<div class="res-row-msg">${escapeHtml(d.message)}</div>` : ''}
</div>
</div>`;
}).join('');
det.innerHTML = `<div class="res-details-head">Details (${details.length})</div><div class="res-details-list">${rows}</div>`;
}
// Report-Link als prominenter Button
const link = document.getElementById('resReportLink');
if (res.reportUrl) {
link.innerHTML = `<a href="${res.reportUrl}" target="_blank" class="btn btn-secondary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></svg>
HTML-Report öffnen
</a>`;
} else {
link.innerHTML = `<div class="muted small" style="text-align:center;font-size:11px;">⚠ Kein Report-URL in der Response — siehe Debug unten</div>`;
}
// 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 = `<details>
<summary>Raw Output${res.build ? ' (Backend-Build: ' + escapeHtml(res.build) + ')' : ' (kein build-Stamp — Backend wurde nicht neu gestartet!)'}</summary>
<pre>${escapeHtml(raw)}</pre>
</details>`;
}
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
? '<span class="form-hint">Noch keine Gruppe ausgewählt.</span>'
: groups.map(g =>
`<span class="cg-chip" title="${escapeHtml(g.name)}">
<span class="cg-chip-name">${escapeHtml(g.name)}</span>
<button type="button" class="cg-chip-x" data-remove-id="${escapeHtml(g.id)}" aria-label="Entfernen">×</button>
</span>`).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 = '<div class="cg-existing-empty">Suche…</div>';
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 = '<div class="cg-existing-empty">Keine Treffer</div>'; return; }
const selIds = new Set(cgSelectedGroups().map(g => g.id));
results.innerHTML = items.map(g =>
`<button type="button" class="cg-existing-item${selIds.has(String(g.Id)) ? ' is-selected' : ''}" data-id="${escapeHtml(g.Id)}" data-name="${escapeHtml(g.DisplayName)}">
<span class="cg-existing-name">${escapeHtml(g.DisplayName)}</span>
${g.Description ? `<span class="cg-existing-desc">${escapeHtml(g.Description)}</span>` : ''}
</button>`).join('');
} catch (e) {
if (e.name === 'AbortError') return;
results.innerHTML = `<div class="cg-existing-empty">Fehler: ${escapeHtml(e.message)}</div>`;
}
}
// 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 = '<div class="spinner spinner-sm"></div> 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 = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`;
const MEMBERS_ICON = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M3 19c0-3 3-5 6-5s6 2 6 5"/><circle cx="17" cy="9" r="2.4"/><path d="M15.5 19c0-2 1.5-3.5 4-3.5"/></svg>`;
const TRASH_ICON = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>`;
const LOCK_ICON = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>`;
const PENCIL_ICON = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>`;
// Microsoft Teams Icon — original aus Bootstrap Icons (microsoft-teams)
const TEAMS_ICON = `<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M9.186 4.797a2.42 2.42 0 1 0-2.86-2.448h1.178c.929 0 1.682.753 1.682 1.682zm-4.295 7.738h2.613c.929 0 1.682-.753 1.682-1.682V5.58h2.783a.7.7 0 0 1 .682.716v4.294a4.197 4.197 0 0 1-4.093 4.293c-1.618-.04-3-.99-3.667-2.35Zm10.737-9.372a1.674 1.674 0 1 1-3.349 0 1.674 1.674 0 0 1 3.349 0m-2.238 9.488-.12-.002a5.2 5.2 0 0 0 .381-2.07V6.306a1.7 1.7 0 0 0-.15-.725h1.792c.39 0 .707.317.707.707v3.765a2.6 2.6 0 0 1-2.598 2.598z"/><path d="M.682 3.349h6.822c.377 0 .682.305.682.682v6.822a.68.68 0 0 1-.682.682H.682A.68.68 0 0 1 0 10.853V4.03c0-.377.305-.682.682-.682Zm5.206 2.596v-.72h-3.59v.72h1.357V9.66h.87V5.945z"/></svg>`;
function entraUserLink(id, name) {
const url = `https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/${encodeURIComponent(id)}`;
return `<a class="entra-link" href="${url}" target="_blank" rel="noopener" title="${escapeHtml(name)} in Entra öffnen" onclick="event.stopPropagation()">${ENTRA_ICON}</a>`;
}
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 `<a class="entra-link" href="${url}" target="_blank" rel="noopener" title="${escapeHtml(name)} im Intune-Portal öffnen" onclick="event.stopPropagation()">${ENTRA_ICON}</a>`;
}
function intuneAppLink(id, name) {
const url = `https://intune.microsoft.com/#view/Microsoft_Intune_Apps/SettingsMenu/~/2/appId/${encodeURIComponent(id)}`;
return `<a class="entra-link" href="${url}" target="_blank" rel="noopener" title="${escapeHtml(name || 'App')} in Intune öffnen" onclick="event.stopPropagation()">${ENTRA_ICON}</a>`;
}
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 `<a class="entra-link members-link" href="${url}" target="_blank" rel="noopener" title="Mitglieder von ${escapeHtml(name || 'Gruppe')} im Intune-Portal öffnen" onclick="event.stopPropagation()">${MEMBERS_ICON}</a>`;
}
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 `<a class="entra-link teams-link" href="${url}" rel="noopener" title="${escapeHtml(name || 'User')} via Teams anschreiben (Desktop-App)" onclick="event.stopPropagation()">${TEAMS_ICON}</a>`;
}
// =============================================================
// 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 `<div class="expand-body">
<div class="expand-loading">
<div class="spinner spinner-sm"></div>
<span>Lade Mitglieder...</span>
</div>
</div>`;
}
if (entry.error) {
return `<div class="expand-body">
<div class="expand-error">Fehler: ${escapeHtml(entry.error)}</div>
</div>`;
}
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
? `<div class="expand-search">
<input type="search" class="input input-search expand-filter" data-group-id="${escapeHtml(groupId)}" placeholder="Mitglieder filtern..." value="${escapeHtml(entry.filter)}">
</div>`
: '';
const memberRows = items.length === 0
? `<div class="expand-empty">${all.length === 0 ? 'Keine direkten Benutzer in dieser Gruppe.' : 'Keine Treffer.'}</div>`
: items.map(m => {
const isSel = entry.selected.has(m.Id) ? 'selected' : '';
const checked = entry.selected.has(m.Id) ? 'checked' : '';
return `<label class="ex-member-row ${isSel}" data-group-id="${escapeHtml(groupId)}" data-member-id="${escapeHtml(m.Id)}">
<input type="checkbox" ${checked}>
<div class="ex-member-info">
<span class="ex-member-name">${escapeHtml(m.DisplayName || '?')}</span>
${m.UserPrincipalName ? `<div class="ex-member-upn">${escapeHtml(m.UserPrincipalName)}</div>` : ''}
</div>
<div class="ex-member-icons">${entraUserLink(m.Id, m.DisplayName || '')}${teamsChatLink(m.UserPrincipalName, m.DisplayName || '')}</div>
</label>`;
}).join('');
return `<div class="expand-body">
${filterInput}
<div class="expand-toolbar">
<span class="expand-count">${all.length} Mitglieder${selectedCount ? ` · <strong>${selectedCount} ausgewählt</strong>` : ''}</span>
<div class="expand-actions">
<button class="link-btn" data-expand-action="all" data-group-id="${escapeHtml(groupId)}">Alle</button>
<span class="dotsep">·</span>
<button class="link-btn" data-expand-action="clear" data-group-id="${escapeHtml(groupId)}">Leeren</button>
</div>
</div>
<div class="expand-list">${memberRows}</div>
<div class="expand-foot">
<button class="btn btn-sm btn-primary" data-expand-action="apply" data-group-id="${escapeHtml(groupId)}" ${selectedCount === 0 ? 'disabled' : ''}>
${selectedCount > 0 ? `${selectedCount} Benutzer übernehmen` : 'Benutzer übernehmen'}
</button>
</div>
</div>`;
}
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 ? ` · <strong>${entry.selected.size} ausgewählt</strong>` : ''}`;
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 = `
<div class="skeleton">
<div class="sk-line med"></div>
<div class="sk-line short"></div>
<div class="sk-line med"></div>
<div class="sk-line short"></div>
</div>`;
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 `<label class="member-row ${sel}" data-id="${escapeHtml(m.Id)}">
<input type="checkbox" ${checked}>
<div class="member-info">
<div class="member-name">${escapeHtml(m.DisplayName || '?')}</div>
${m.UserPrincipalName ? `<div class="member-upn">${escapeHtml(m.UserPrincipalName)}</div>` : ''}
</div>
</label>`;
}).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 `<div class="empty-state ${small ? 'small' : ''}">
${ico ? `<div class="empty-ico">${ico}</div>` : ''}
${title ? `<div class="empty-title">${escapeHtml(title)}</div>` : ''}
${text ? `<div class="empty-text">${escapeHtml(text)}</div>` : ''}
</div>`;
}
// =============================================================
// 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 -> <a> 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 = `<div class="help-error">Hilfe konnte nicht geladen werden: ${escapeHtml(e.message)}</div>`;
}
}
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 = `<img src="${url}" alt="Logo" class="logo-img" onerror="this.parentNode.innerHTML='<span class=\\'logo-letter\\'>I</span>'">`;
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 = '<div class="set-test-line set-test-pending">Pruefe Werte gegen Microsoft Graph…</div>';
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 = `<div class="set-test-line set-test-fail">Test fehlgeschlagen: ${escapeHtml(e.message || String(e))}</div>`;
} finally {
if (btn) btn.disabled = false;
}
}
function renderSettingsTestResult(res) {
const lines = [];
const ok = '<span class="set-test-icon ok">OK</span>';
const fail = '<span class="set-test-icon fail">FAIL</span>';
const conn = res.connection || {};
if (conn.ok) {
lines.push(`<div class="set-test-line">${ok} Verbindung: <code>${escapeHtml(conn.account || '')}</code> (Tenant <code>${escapeHtml(conn.tenantId || '')}</code>)</div>`);
} else {
const why = conn.reason === 'not_connected' ? 'Bitte zuerst per Connect-Button verbinden.' : (conn.message || 'unbekannter Grund');
lines.push(`<div class="set-test-line set-test-fail">${fail} Verbindung: ${escapeHtml(why)}</div>`);
return lines.join('');
}
const d = res.departments || {};
if (d.reason === 'not_configured') {
lines.push(`<div class="set-test-line set-test-fail">${fail} Abteilungs-Praefix(e) nicht gesetzt.</div>`);
} else if (Array.isArray(d.perPrefix) && d.perPrefix.length > 0) {
// Neue Form: eine Zeile pro Praefix
lines.push(`<div class="set-test-line">${d.ok ? ok : fail} Abteilungs-Gruppen — Total: <strong>${d.totalCount || 0}</strong></div>`);
for (const pp of d.perPrefix) {
if (pp.ok) {
const sample = pp.sample && pp.sample.length ? ` <span class="set-test-sample">(z.B. ${pp.sample.map(escapeHtml).join(', ')})</span>` : '';
const icon = pp.count > 0 ? ok : '<span class="set-test-icon warn">0</span>';
const cls = pp.count > 0 ? '' : 'set-test-warn';
lines.push(`<div class="set-test-line ${cls}" style="padding-left:20px">${icon} <code>${escapeHtml(pp.prefix)}</code>: <strong>${pp.count}</strong> Treffer${sample}</div>`);
} else {
lines.push(`<div class="set-test-line set-test-fail" style="padding-left:20px">${fail} <code>${escapeHtml(pp.prefix)}</code>: ${escapeHtml(pp.error || '')}</div>`);
}
}
} else if (d.ok) {
// Backward-compat: alte Single-Prefix-Response
const sample = d.sample && d.sample.length ? ` <span class="set-test-sample">(z.B. ${d.sample.map(escapeHtml).join(', ')})</span>` : '';
lines.push(`<div class="set-test-line">${ok} Abteilungs-Gruppen: <strong>${d.count}</strong> Treffer fuer <code>${escapeHtml(d.prefix)}</code>${sample}</div>`);
} else {
lines.push(`<div class="set-test-line set-test-fail">${fail} Abteilungs-Gruppen-Lookup fehlgeschlagen: ${escapeHtml(d.error || '')}</div>`);
}
// RPA-Funktion ist deaktiviert — Test-Ergebnis dazu wird nicht angezeigt.
const u = res.userSearch || {};
if (u.ok) {
lines.push(`<div class="set-test-line">${ok} Benutzer-Suche: ok (${(u.fields || []).join(', ')})</div>`);
} else if (u.reason === 'no_fields') {
lines.push(`<div class="set-test-line set-test-fail">${fail} Keine User-Such-Felder gewaehlt.</div>`);
} else {
lines.push(`<div class="set-test-line set-test-fail">${fail} Benutzer-Suche fehlgeschlagen: ${escapeHtml(u.error || '')}</div>`);
}
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 : '<span class="set-test-icon warn">0</span>';
const cls = c.count > 0 ? '' : 'set-test-warn';
lines.push(`<div class="set-test-line ${cls}">${icon} Vendor <strong>${escapeHtml(c.displayName)}</strong>: <strong>${c.count}</strong> Apps <span class="set-test-sample">(Regel: <code>${escapeHtml(c.detection)}</code>)</span></div>`);
}
if (ven.hint) {
lines.push(`<div class="set-test-line set-test-warn">Hinweis: ${escapeHtml(ven.hint)}</div>`);
}
}
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 `<div class="tenant-row" data-id="${escapeHtml(id)}">
<div class="tenant-row-head">
<label class="tenant-active"><input type="radio" name="tenantActive" value="${escapeHtml(id)}" ${active ? 'checked' : ''}><span>Aktiv</span></label>
<input type="text" class="input tenant-label" placeholder="Bezeichnung (z.B. Kunde A)" value="${escapeHtml(t.label || '')}" autocomplete="off">
<button type="button" class="btn btn-danger btn-sm tenant-remove" title="Profil entfernen">✕</button>
</div>
<div class="tenant-row-grid">
<input type="text" class="input mono tenant-tid" placeholder="Tenant ID (GUID)" value="${escapeHtml(t.tenantId || '')}" spellcheck="false" autocomplete="off">
<input type="text" class="input mono tenant-cid" placeholder="Client ID Read/Write (GUID)" value="${escapeHtml(t.clientId || '')}" spellcheck="false" autocomplete="off">
<input type="text" class="input mono tenant-cidro" placeholder="Client ID Read-Only (optional)" value="${escapeHtml(t.clientIdRo || '')}" spellcheck="false" autocomplete="off">
</div>
<div class="tenant-row-overrides">
<label class="tenant-sub-lbl">Abteilungs-Präfixe <span class="tenant-sub-hint">(leer = globale Vorgabe)</span></label>
<textarea class="input mono tenant-prefixes" rows="2" spellcheck="false" placeholder="eine pro Zeile, z.B. abt-hm">${escapeHtml((t.prefixes || []).join('\n'))}</textarea>
<label class="tenant-sub-lbl">Required-Gruppen-Naming <span class="tenant-sub-hint">(leer = globale Vorgabe)</span></label>
<div class="tenant-naming-grid">
<input type="text" class="input mono tenant-req-prefix" placeholder="Präfix" value="${escapeHtml((t.requiredGroupNaming && t.requiredGroupNaming.prefix) || '')}" spellcheck="false" autocomplete="off">
<input type="text" class="input mono tenant-req-suffix" placeholder="Suffix" value="${escapeHtml((t.requiredGroupNaming && t.requiredGroupNaming.suffix) || '')}" spellcheck="false" autocomplete="off">
</div>
<label class="tenant-sub-lbl">Available-Gruppen-Naming <span class="tenant-sub-hint">(leer = globale Vorgabe)</span></label>
<div class="tenant-naming-grid">
<input type="text" class="input mono tenant-avail-prefix" placeholder="Präfix" value="${escapeHtml((t.availableGroupNaming && t.availableGroupNaming.prefix) || '')}" spellcheck="false" autocomplete="off">
<input type="text" class="input mono tenant-avail-suffix" placeholder="Suffix" value="${escapeHtml((t.availableGroupNaming && t.availableGroupNaming.suffix) || '')}" spellcheck="false" autocomplete="off">
</div>
</div>
</div>`;
}
const TENANT_EMPTY_HINT = '<div class="form-hint tenant-empty">Noch keine Tenants. Füge unten einen hinzu.</div>';
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 = `<img src="${url}" alt="Logo" class="logo-preview-img" onerror="this.parentNode.innerHTML='<span class=\\'logo-letter\\'>!</span>'">`;
// "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 = '<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>';
document.getElementById('gexMembersArea').innerHTML = '<div class="gex-hint">Gruppe auswählen um Mitglieder zu laden.</div>';
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 = '<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>';
document.getElementById('gexSelectedUsers').innerHTML = '<div class="gex-hint">Noch keine Benutzer ausgewählt.</div>';
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 = '<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>';
return;
}
document.getElementById('gexSearchResults').innerHTML = '<div class="gex-loading"><div class="spinner" style="width:14px;height:14px;border-width:2px;"></div><span>Suche…</span></div>';
_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 = `<div class="gex-error">Suche fehlgeschlagen: ${escapeHtml(e.message)}</div>`;
}
}
function _gexRenderResults() {
const el = document.getElementById('gexSearchResults');
if (!el) return;
const items = GexState.searchResults;
if (items.length === 0) {
el.innerHTML = '<div class="gex-empty">Keine Gruppen gefunden.</div>';
return;
}
el.innerHTML = items.map(g => `
<div class="gex-group-item ${GexState.selectedGroup?.Id === g.Id ? 'selected' : ''}"
data-id="${escapeHtml(g.Id)}" data-name="${escapeHtml(g.DisplayName)}">
<div class="gex-group-name">${escapeHtml(g.DisplayName)}</div>
${g.Description ? `<div class="gex-group-desc">${escapeHtml(g.Description)}</div>` : ''}
</div>
`).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 =
'<div class="gex-loading"><div class="spinner" style="width:16px;height:16px;border-width:2px;"></div><span>Mitglieder werden aufgelöst (inkl. verschachtelte Gruppen)…</span></div>';
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 =
`<div class="gex-error">Fehler beim Laden: ${escapeHtml(e.message)}</div>`;
}
}
// 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 = '<div class="gex-empty">Keine Benutzer in dieser Gruppe gefunden.</div>';
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 = `<div class="gex-summary">${m.count} Benutzer`;
if (m.duplicates > 0) html += ` (${m.duplicates} Duplikate aus verschachtelten Gruppen zusammengeführt)`;
html += '</div>';
const directGroupName = m.groupName || '';
for (const [path, users] of byPath.entries()) {
const isDirect = path === directGroupName;
html += `<div class="gex-path-group">
<div class="gex-path-head">${escapeHtml(path)} <span class="count-badge">${users.length}</span></div>
<div class="gex-user-list">`;
for (const u of users) {
const upn = u.UserPrincipalName || u.Mail || u.Id;
const searchKey = `${(u.DisplayName || '').toLowerCase()} ${upn.toLowerCase()}`;
const cbx = isDirect
? `<input type="checkbox" class="gex-member-chk" data-uid="${escapeHtml(u.Id)}" data-name="${escapeHtml(u.DisplayName || upn)}">`
: `<span style="width:14px;flex-shrink:0;"></span>`;
html += `<label class="gex-user-item" data-search="${escapeHtml(searchKey)}">
${cbx}
<span class="gex-user-name">${escapeHtml(u.DisplayName || '—')}</span>
<span class="gex-user-upn">${escapeHtml(upn)}</span>
</label>`;
}
html += '</div></div>';
}
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 = '<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>';
return;
}
umDebounce = setTimeout(() => _umSearchUser(q), 320);
});
async function _umSearchUser(q) {
resultsDiv.innerHTML = '<div class="gex-hint">Suche…</div>';
try {
const data = await api(`/api/users?q=${encodeURIComponent(q)}`);
if (!data.items || data.items.length === 0) {
resultsDiv.innerHTML = '<div class="gex-hint">Keine Benutzer gefunden.</div>';
return;
}
let html = '';
for (const u of data.items) {
const upn = u.UserPrincipalName || u.Mail || u.Id;
html += `<div class="gex-result-item" data-uid="${escapeHtml(u.Id)}"
data-name="${escapeHtml(u.DisplayName || upn)}"
data-upn="${escapeHtml(upn)}">
<span class="gex-user-name">${escapeHtml(u.DisplayName || '—')}</span>
<span class="gex-user-upn">${escapeHtml(upn)}</span>
</div>`;
}
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 = `<div class="gex-hint error">Fehler: ${escapeHtml(String(e))}</div>`;
}
}
async function _umSelectUser(user) {
umSelected = user;
searchInput.value = user.DisplayName || user.UserPrincipalName;
resultsDiv.innerHTML = '';
groupsList.innerHTML = '<div class="gex-hint">Lade Gruppen…</div>';
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 = '<div class="gex-hint">Keine Gruppen gefunden.</div>';
filterWrap.style.display = 'none';
return;
}
_umRenderGroups(data.groups);
} catch (e) {
groupsList.innerHTML = `<div class="gex-hint error">Fehler: ${escapeHtml(String(e))}</div>`;
}
}
function _umRenderGroups(groups) {
let html = '';
for (const g of groups) {
const desc = g.Description ? `<span class="gex-user-upn">${escapeHtml(g.Description)}</span>` : '';
const badge = `<span class="gex-type-badge">${escapeHtml(g.Type)}</span>`;
html += `<div class="gex-group-row" data-search="${escapeHtml((g.DisplayName || '').toLowerCase())} ${escapeHtml((g.Description || '').toLowerCase())}">
<span class="gex-user-name">${escapeHtml(g.DisplayName || '—')}</span>
${desc}
${badge}
</div>`;
}
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 = '<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>';
return;
}
document.getElementById('gexUserResults').innerHTML = '<div class="gex-loading"><div class="spinner" style="width:14px;height:14px;border-width:2px;"></div><span>Suche…</span></div>';
_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 = `<div class="gex-error">Suche fehlgeschlagen: ${escapeHtml(e.message)}</div>`;
}
}
// 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 = '<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>';
const selHint = document.getElementById('gexSelectedUsers');
if (selHint) selHint.innerHTML = `<div class="gex-hint">Noch keine ${isGroup ? 'Gruppen' : 'Benutzer'} ausgewählt.</div>`;
if (search) search.focus();
});
});
function _gexRenderUserResults() {
const el = document.getElementById('gexUserResults');
if (!el) return;
const items = GexState.userSearchResults;
if (items.length === 0) {
el.innerHTML = '<div class="gex-empty">Keine Benutzer gefunden.</div>';
return;
}
el.innerHTML = items.map(u => {
const already = GexState.selectedUsers.has(u.Id);
return `<div class="gex-user-result-item ${already ? 'selected' : ''}"
data-id="${escapeHtml(u.Id)}" data-name="${escapeHtml(u.DisplayName || '')}" data-upn="${escapeHtml(u.UserPrincipalName || '')}">
<span class="gex-user-name">${escapeHtml(u.DisplayName || '—')}</span>
<span class="gex-user-upn">${escapeHtml(u.UserPrincipalName || u.Mail || '')}</span>
${already ? '<span class="gex-user-added">✓</span>' : ''}
</div>`;
}).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 = '<div class="gex-hint">Noch keine Benutzer ausgewählt.</div>';
return;
}
el.innerHTML = users.map(u => `
<div class="gex-selected-chip">
<span class="gex-selected-chip-name">${escapeHtml(u.DisplayName || '—')}</span>
<span class="gex-selected-chip-upn">${escapeHtml(u.UserPrincipalName || '')}</span>
<button class="gex-selected-chip-remove" data-id="${escapeHtml(u.Id)}" title="Entfernen">×</button>
</div>
`).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 = '<div class="spinner" style="width:14px;height:14px;border-width:2px;display:inline-block;vertical-align:middle;margin-right:6px;"></div>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 = '<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>';
}
} 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 = `<tr><td colspan="9" class="report-hint"><div class="spinner spinner-sm" style="display:inline-block;margin-right:6px"></div>Lade Install-Statistiken… (kann 30–60 s dauern)</td></tr>`;
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 = `<tr><td colspan="9" class="report-hint" style="color:var(--error)">${escapeHtml(e.message)}</td></tr>`;
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 = `<tr><td colspan="9" class="report-hint">Keine Apps gefunden.</td></tr>`;
return;
}
tbody.innerHTML = ReportState.filtered.map(r => {
const failCls = r.FailedDeviceCount > 0 ? 'report-fail' : '';
return `<tr>
<td class="report-name" title="${escapeHtml(r.AppName)}">${escapeHtml(r.AppName)}</td>
<td class="report-pub">${escapeHtml(r.Publisher || '—')}</td>
<td>${escapeHtml(r.AppType || '—')}</td>
<td>${escapeHtml(r.Version || '—')}</td>
<td class="num report-ok">${r.InstalledDeviceCount}</td>
<td class="num report-pending">${r.PendingInstallDeviceCount}</td>
<td class="num ${failCls}">${r.FailedDeviceCount}</td>
<td class="num">${r.NotApplicableDeviceCount}</td>
<td class="report-export-cell">
<button class="btn-report-device-export" data-appid="${escapeHtml(r.AppId)}" data-appname="${escapeHtml(r.AppName)}" title="Installationsstatus aller Geräte als CSV herunterladen">Geräte-Export</button>
</td>
</tr>`;
}).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 `<tr class="${cls}">
<td class="${icoCls}">${ico}</td>
<td class="csv-upn">${escapeHtml(r.Upn)}</td>
<td class="csv-name">${escapeHtml(r.DisplayName || '—')}</td>
<td class="csv-status">${r.Found ? escapeHtml(r.Mail || r.Upn) : 'Nicht gefunden'}</td>
</tr>`;
}).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',
complianceState: 'ComplianceState',
lastSyncDateTime: 'LastSync',
enrolledDateTime: 'EnrolledDateTime',
};
const DEV_DATE_COLS = new Set(['LastSync', 'EnrolledDateTime']);
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 += `<tr data-id="${escapeHtml(d.Id)}"${sel}>
<td class="dev-col-check"><input type="checkbox" class="dev-row-check" data-id="${escapeHtml(d.Id)}" ${chk}></td>
<td>${escapeHtml(d.DeviceName || '—')}</td>
<td>${escapeHtml(d.UserDisplayName || '—')}</td>
<td>${escapeHtml(d.OS || '—')}</td>
<td><span class="dev-badge ${cmpCls}">${escapeHtml(d.ComplianceState || '—')}</span></td>
<td>${sync}</td>
</tr>`;
}
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],
['Compliance', d.ComplianceState],
['Seriennummer', d.SerialNumber],
['IMEI', d.Imei],
['Wi-Fi MAC', d.WiFiMac],
['Speicher', storage],
['Eingeschrieben', enrolled],
['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 += `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(String(val))}</dd>`;
}
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 = '<div class="cg-existing-empty">Suche…</div>';
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 = '<div class="cg-existing-empty">Keine Treffer</div>'; return; }
results.innerHTML = gs.map(g =>
`<button type="button" class="cg-existing-item" data-gid="${escapeHtml(g.Id)}" data-gname="${escapeHtml(g.DisplayName)}">
<span class="cg-existing-name">${escapeHtml(g.DisplayName)}</span>
${g.Description ? `<span class="cg-existing-desc">${escapeHtml(g.Description)}</span>` : ''}
</button>`).join('');
} catch (e) {
if (e.name === 'AbortError') return;
results.innerHTML = `<div class="cg-existing-empty">Fehler: ${escapeHtml(e.message)}</div>`;
}
}
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','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.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 = `<tr><td colspan="5" class="pol-hint"><span class="spinner" style="width:14px;height:14px;border-width:2px;"></span> Lade Policies…</td></tr>`;
// 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 = `<tr><td colspan="5" class="pol-empty">${PolState.items.length ? 'Keine Policy passt zum Filter.' : 'Keine Policies gefunden.'}</td></tr>`;
polUpdateSelCount();
return;
}
body.innerHTML = list.map(p => {
const key = polKey(p);
const checked = PolState.selected.has(key) ? 'checked' : '';
return `<tr data-key="${escapeHtml(key)}">
<td class="pol-col-check"><input type="checkbox" class="pol-row-check" data-key="${escapeHtml(key)}" ${checked}></td>
<td class="pol-name" title="${escapeHtml(p.name || '')}">${escapeHtml(p.name || '—')}</td>
<td><span class="pol-type-badge">${escapeHtml(p.typeLabel || p.type)}</span></td>
<td>${escapeHtml(p.platform || '—')}</td>
<td>${polFmtDate(p.lastModifiedDateTime)}</td>
</tr>`;
}).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;
// 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 = '<span class="spinner" style="width:14px;height:14px;border-width:2px;"></span> 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 = `<span class="pol-cons-err">Analyse fehlgeschlagen: ${escapeHtml(e.message)}</span>`;
}
}
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 = `
<div><b>${sources.length}</b> Policies · Plattform <b>${escapeHtml(res.platform || '—')}</b></div>
<div><b>${res.totalSettings}</b> Einstellungen gesamt · <b class="${res.conflictCount ? 'pol-cons-warn' : ''}">${res.conflictCount}</b> Konflikt(e)</div>
<ul class="pol-cons-src">${sources.map(s => `<li>${escapeHtml(s.name)} <span class="muted">(${s.settingCount})</span></li>`).join('')}</ul>`;
const cWrap = document.getElementById('polConsConflicts');
const conflicts = res.conflicts || [];
if (conflicts.length === 0) {
cWrap.innerHTML = '<div class="form-hint ok">Keine Konflikte — alle gemeinsamen Einstellungen sind identisch.</div>';
} else {
cWrap.innerHTML = `<div class="pol-cons-conf-head">Konflikte — welcher Wert soll gewinnen?</div>` +
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 `<label class="pol-cons-opt">
<input type="radio" name="polcons-${ci}" data-def="${escapeHtml(c.settingDefinitionId)}" data-src="${escapeHtml(sid)}" ${checked}>
<span>${escapeHtml(label)}</span>
</label>`;
}).join('');
return `<div class="pol-cons-conf">
<div class="pol-cons-def" title="${escapeHtml(c.settingDefinitionId)}">${escapeHtml(c.settingDefinitionId)}</div>
<div class="pol-cons-opts">${opts}</div>
</div>`;
}).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 `<div class="pol-import-row">
<input class="input pol-import-name" data-idx="${i}" value="${escapeHtml(polEnvName(env))}" placeholder="Policy-Name">
<span class="pol-type-badge">${escapeHtml(POL_TYPE_LABELS[type] || type || 'Policy')}</span>
</div>`;
}).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) => `
<div class="pol-assign-row" data-idx="${i}">
<div class="pol-assign-head">
<span class="pol-name">${escapeHtml(p.name)}</span>
<span class="pol-type-badge">${escapeHtml(POL_TYPE_LABELS[p.exportType] || p.exportType || 'Policy')}</span>
</div>
<div class="pol-assign-pickers">
${['include', 'exclude'].map(kind => `
<div class="pol-assign-picker" data-kind="${kind}">
<label class="lbl">${kind === 'include' ? 'Include-Gruppen' : 'Exclude-Gruppen'}</label>
<div class="pol-assign-chips" data-idx="${i}" data-kind="${kind}"></div>
<div class="pol-assign-search-wrap">
<input type="text" class="input pol-assign-search" data-idx="${i}" data-kind="${kind}" placeholder="Gruppe suchen…" autocomplete="off">
<div class="pol-assign-dropdown hidden" data-idx="${i}" data-kind="${kind}"></div>
</div>
</div>
`).join('')}
</div>
</div>
`).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) => `
<span class="pol-assign-chip ${kind === 'exclude' ? 'exclude' : ''}">
${escapeHtml(g.name)}
<button type="button" data-idx="${idx}" data-kind="${kind}" data-j="${j}" title="Entfernen">×</button>
</span>
`).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 = '<div class="msg">Suche…</div>'; }
_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 = '<div class="msg">Keine Gruppen gefunden.</div>'; return; }
dd.innerHTML = items.map(g => `
<div class="opt" data-id="${escapeHtml(g.Id)}" data-name="${escapeHtml(g.DisplayName)}">
<div>${escapeHtml(g.DisplayName)}</div>
${g.Description ? `<div class="desc">${escapeHtml(g.Description)}</div>` : ''}
</div>`).join('');
} catch (err) {
if (dd) dd.innerHTML = `<div class="msg">Suche fehlgeschlagen: ${escapeHtml(err.message)}</div>`;
}
}, 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();
}
});
// 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: [] };
function offKey(d) { return d.intuneDeviceId; }
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 = `<tr><td colspan="7" class="pol-hint"><span class="spinner" style="width:14px;height:14px;border-width:2px;"></span> Suche…</td></tr>`;
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 = `<tr><td colspan="7" class="pol-empty">Fehler bei der Suche.</td></tr>`;
}
}
function offSvcBadges(d) {
const b = [];
if (d.inIntune) b.push('<span class="off-badge">Intune</span>');
if (d.inEntra) b.push('<span class="off-badge">Entra</span>');
if (d.inAutopilot) b.push('<span class="off-badge">Autopilot</span>');
if (d.coManaged) b.push('<span class="off-badge warn">Co-Mgmt</span>');
return b.join(' ') || '—';
}
function renderOff() {
const body = document.getElementById('offBody');
if (!OffState.items.length) {
body.innerHTML = `<tr><td colspan="7" class="pol-empty">Keine Geräte gefunden.</td></tr>`;
offUpdateSel();
return;
}
body.innerHTML = OffState.items.map(d => {
const k = offKey(d);
const checked = OffState.selected.has(k) ? 'checked' : '';
return `<tr data-key="${escapeHtml(k)}">
<td><input type="checkbox" class="off-row-check" data-key="${escapeHtml(k)}" ${checked}></td>
<td>${escapeHtml(d.deviceName || '—')}</td>
<td>${escapeHtml(d.serialNumber || '—')}</td>
<td>${escapeHtml(((d.operatingSystem || '') + ' ' + (d.osVersion || '')).trim() || '—')}</td>
<td>${escapeHtml(d.primaryUser || '—')}</td>
<td>${polFmtDate(d.lastSync)}</td>
<td>${offSvcBadges(d)}</td>
</tr>`;
}).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 => `
<div class="off-confirm-row"><b>${escapeHtml(d.deviceName || '—')}</b>
— Intune: ${d.intuneDeviceId ? '✓' : '–'}, Entra: ${d.entraObjectId ? '✓' : '–'}, Autopilot: ${d.autopilotId ? '✓' : '–'}${(!d.autopilotId && d.autopilotNote) ? ` <span class="off-note" style="color:var(--warning,#e0a030);">(${escapeHtml(d.autopilotNote)})</span>` : ''}</div>
`).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 = '<div class="lbl">Recovery-Keys</div><div class="muted">Wird geladen…</div>';
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 = `<div class="off-key-dev"><b>${escapeHtml(d.deviceName || '—')}</b>`;
(k.bitlocker || []).forEach(b => { html += `<div class="off-key">BitLocker${b.volumeType ? ' (' + escapeHtml(b.volumeType) + ')' : ''}: <code>${escapeHtml(b.key)}</code></div>`; });
if (k.fileVault) html += `<div class="off-key">FileVault: <code>${escapeHtml(k.fileVault)}</code></div>`;
if (k.laps) html += `<div class="off-key">LAPS${k.laps.account ? ' (' + escapeHtml(k.laps.account) + ')' : ''}: <code>${escapeHtml(k.laps.password)}</code></div>`;
if (k.notes && k.notes.length) html += `<div class="muted">${k.notes.map(escapeHtml).join(' · ')}</div>`;
html += '</div>';
parts.push(html);
} catch (e) {
parts.push(`<div class="off-key-dev"><b>${escapeHtml(d.deviceName || '—')}</b> <span class="muted">Keys-Fehler: ${escapeHtml(e.message)}</span></div>`);
}
}
box.innerHTML = `<div class="lbl">🔑 Recovery-Keys — vor dem Löschen sichern!</div>${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 =>
`<div class="off-result ${r.success ? 'ok' : 'fail'}">${r.success ? '✓' : '✗'} ${escapeHtml(r.service)} ${escapeHtml(r.action)}${r.error ? ': ' + escapeHtml(r.error) : ''}</div>`
).join('');
return `<div class="off-result-dev">
<div class="off-result-devname">${escapeHtml(name || '—')}${errs ? ` <span class="off-result-badge">${errs} Fehler</span>` : ''}</div>
${lines}
</div>`;
}).join('');
const reportLink = res.reportUrl
? `<a href="${escapeHtml(res.reportUrl)}" target="_blank" rel="noopener" class="btn btn-secondary btn-sm" style="margin-top:12px;display:inline-flex;align-items:center;gap:6px;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>
HTML-Report öffnen
</a>`
: '';
document.getElementById('offResults').innerHTML =
`<div class="lbl">Ergebnis (${res.successCount}/${res.total})</div>${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);