Keine ${type === 'available' ? 'Available' : 'Required'}-Zuweisungen.
`;
}
return `
${escapeHtml(title)}
${ratePct !== null ? `${ratePct}% erfolgreich ` : ''}
${stackedBar(d.ok, d.fail, d.pend, d.not, d.na)}
Installiert
Fehler
Ausstehend
Nicht inst.
N/A
${stat('Installiert', d.ok, 'ok')}
${stat('Fehler', d.fail, 'err')}
${stat('Ausstehend', d.pend, 'pend')}
${stat('Nicht inst.', d.not, 'not')}
${stat('N/A', d.na, 'na')}
`;
return `
Fehler: ${escapeHtml(entry.error)}
`;
}
const all = entry.members || [];
const q = entry.filter.toLowerCase();
const items = q
? all.filter(m => (m.DisplayName || '').toLowerCase().includes(q) || (m.UserPrincipalName || '').toLowerCase().includes(q))
: all;
const selectedCount = entry.selected.size;
const filterInput = all.length > 6
? `${all.length === 0 ? 'Keine direkten Benutzer in dieser Gruppe.' : 'Keine Treffer.'}
`
: items.map(m => {
const isSel = entry.selected.has(m.Id) ? 'selected' : '';
const checked = entry.selected.has(m.Id) ? 'checked' : '';
return `
${filterInput}
${memberRows}
${selectedCount > 0 ? `${selectedCount} Benutzer übernehmen` : 'Benutzer übernehmen'}
`;
}
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 ? ` · element
contentEl.querySelectorAll('h2').forEach(h => {
let id = slug(h.textContent);
let n = 2;
while (seen.has(id)) { id = slug(h.textContent) + '-' + (n++); }
seen.add(id);
h.id = id;
headings.push(h);
const li = document.createElement('li');
const a = document.createElement('a');
a.href = '#' + id;
a.textContent = h.textContent;
a.addEventListener('click', e => {
e.preventDefault();
h.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
li.appendChild(a);
tocEl.appendChild(li);
links.set(id, a);
});
// Aktiver TOC-Eintrag via Scroll-Position erkennen.
// IntersectionObserver mit Top-Marker (kleiner Slot oben im Viewport) —
// sobald ein H2 darin auftaucht, ist es der "aktive" Abschnitt.
const setActive = (id) => {
links.forEach((a, key) => a.classList.toggle('active', key === id));
};
// Default: erstes Heading aktiv
if (headings.length > 0) setActive(headings[0].id);
// Beobachtung: scroll-Position im help-content-Container
const onScroll = () => {
const containerTop = contentEl.getBoundingClientRect().top;
let currentId = headings[0]?.id;
// Nimm das letzte Heading dessen oben-Kante schon ueber dem 1/4-Mark
// des Containers ist — wirkt natuerlicher als "schwellt rein".
const threshold = containerTop + contentEl.clientHeight * 0.25;
for (const h of headings) {
if (h.getBoundingClientRect().top <= threshold) currentId = h.id;
else break;
}
if (currentId) setActive(currentId);
};
contentEl.addEventListener('scroll', onScroll, { passive: true });
onScroll();
helpLoaded = true;
} catch (e) {
contentEl.innerHTML = `Hilfe konnte nicht geladen werden: ${escapeHtml(e.message)}
`;
}
}
document.getElementById('btnHelp')?.addEventListener('click', openHelpModal);
// =============================================================
// Branding / Theme: Logo + benutzerdefinierte Farben
// =============================================================
// Default-Hex pro Konzept — wird bei "Reset" pro Farbe verwendet.
// Diese Werte sind die VOM USER vorgegebenen Defaults (kein willkuerliches
// Bootstrap-Blau mehr). Spiegelt Get-DefaultSettings im Backend.
const ThemeDefaults = {
brand: '#27a078',
accent: '#3b82f6',
required: '#cb2a7a',
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
rowSelected: '#71e5c4',
detailPanel: '#f7f7f7',
};
// Welche Konzept-Farbe auf welche CSS-Variablen abgebildet wird. Die soft/
// strong/bg/border/shadow-Werte werden zur Laufzeit aus dem Hex per rgba()
// abgeleitet (Alpha-Konstanten siehe applyThemeColors).
const ThemeMap = {
brand: { main: '--brand-primary' },
accent: { main: '--brand-accent', soft: '--accent-soft', strong: '--accent-strong', bg: '--accent-bg' },
required: { main: '--pink', soft: '--pink-soft' },
success: { main: '--success', soft: '--success-soft', strong: '--success-strong', bg: '--success-bg', border: '--success-border', shadow: '--success-shadow' },
warning: { main: '--warning', soft: '--warning-soft', strong: '--warning-strong', bg: '--warning-bg', border: '--warning-border' },
error: { main: '--error', soft: '--error-soft', strong: '--error-strong', bg: '--error-bg', border: '--error-border' },
};
function hexToRgb(hex) {
if (!hex) return null;
let h = hex.replace(/^#/, '');
if (h.length === 3) h = h.split('').map(c => c + c).join('');
if (!/^[0-9a-f]{6}$/i.test(h)) return null;
const n = parseInt(h, 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function rgbaStr(hex, alpha) {
const rgb = hexToRgb(hex);
if (!rgb) return null;
return `rgba(${rgb.r},${rgb.g},${rgb.b},${alpha})`;
}
// WCAG-Relative-Luminance — basis fuer Kontrast-Entscheidung
function relLuminance(rgb) {
const ch = (v) => {
const s = v / 255;
return s < 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * ch(rgb.r) + 0.7152 * ch(rgb.g) + 0.0722 * ch(rgb.b);
}
// WCAG Contrast Ratio zwischen zwei Farben
function contrastRatio(rgb1, rgb2) {
const l1 = relLuminance(rgb1);
const l2 = relLuminance(rgb2);
const hi = Math.max(l1, l2);
const lo = Math.min(l1, l2);
return (hi + 0.05) / (lo + 0.05);
}
// Liefert eine gut lesbare Vordergrund-Palette fuer den gegebenen Hintergrund.
// Pickt zwischen weiss/schwarz basierend auf WCAG-Contrast (nicht nur Luminanz-
// Schwellwert — der wuerde bei mittel-hellen Farben wie Pink danebenliegen).
// Liefert {primary, secondary, muted} jeweils mit absteigender Saettigung
// gegen den Hintergrund.
function readableForeground(bgRgb) {
if (!bgRgb) return { primary: '#111111', secondary: '#374151', muted: '#6b7280' };
const WHITE = { r: 255, g: 255, b: 255 };
const BLACK = { r: 17, g: 17, b: 17 };
const crWhite = contrastRatio(bgRgb, WHITE);
const crBlack = contrastRatio(bgRgb, BLACK);
if (crWhite >= crBlack) {
// Weisser Text ist kontrastreicher -> bg ist eher dunkel
return { primary: '#ffffff', secondary: '#e7eaf0', muted: '#c9d0db' };
}
// Schwarzer Text ist kontrastreicher -> bg ist eher hell
return { primary: '#111111', secondary: '#374151', muted: '#475569' };
}
function applyThemeColors(colors) {
if (!colors) return;
const root = document.documentElement.style;
for (const key of Object.keys(ThemeMap)) {
const hex = colors[key];
if (!hex || !hexToRgb(hex)) continue;
const vars = ThemeMap[key];
root.setProperty(vars.main, hex);
if (vars.soft) root.setProperty(vars.soft, rgbaStr(hex, 0.12));
if (vars.strong) root.setProperty(vars.strong, rgbaStr(hex, 0.20));
if (vars.border) root.setProperty(vars.border, rgbaStr(hex, 0.30));
if (vars.bg) root.setProperty(vars.bg, rgbaStr(hex, 0.06));
if (vars.shadow) root.setProperty(vars.shadow, rgbaStr(hex, 0.35));
}
// Background der aufgeklappten App: picked color wird 1:1 als solid
// Hintergrund gesetzt (keine Transparenz). Text und Icons werden per
// WCAG-Kontrast automatisch in Schwarz oder Weiss gewaehlt.
// Innere Surfaces (Chips, File-Paths, Asg-Boxen) werden als SOLID-Shade
// der Picked Color berechnet (heller bei dunkler Tint, dunkler bei heller).
const rgbToHex = (rgb) => '#' + ['r','g','b'].map(c => Math.max(0, Math.min(255, Math.round(rgb[c]))).toString(16).padStart(2,'0')).join('');
const shade = (rgb, delta) => ({
r: Math.max(0, Math.min(255, rgb.r + Math.round(delta * 255))),
g: Math.max(0, Math.min(255, rgb.g + Math.round(delta * 255))),
b: Math.max(0, Math.min(255, rgb.b + Math.round(delta * 255))),
});
const surfaceShades = (rgb) => {
const isDark = relLuminance(rgb) < 0.5;
const d = isDark ? 0.06 : -0.04; // Dark bg -> heller machen, Light bg -> dunkler
return {
surface: rgbToHex(shade(rgb, d)),
surfaceStrong: rgbToHex(shade(rgb, d * 2)),
border: rgbToHex(shade(rgb, d * 3)),
borderSoft: rgbToHex(shade(rgb, d * 1.5)),
};
};
if (colors.rowSelected && hexToRgb(colors.rowSelected)) {
const rgb = hexToRgb(colors.rowSelected);
// Picked color wird 1:1 als Background gesetzt — keine Transparenz, kein rgba.
root.setProperty('--row-selected-border', colors.rowSelected);
root.setProperty('--row-selected-bg', colors.rowSelected);
root.setProperty('--row-selected-bg-hover', colors.rowSelected);
const fg = readableForeground(rgb);
root.setProperty('--row-selected-text', fg.primary);
root.setProperty('--row-selected-text-muted', fg.secondary);
root.setProperty('--row-selected-icon', fg.primary);
root.setProperty('--row-selected-icon-hover', fg.primary);
const so = surfaceShades(rgb);
root.setProperty('--row-selected-surface', so.surface);
root.setProperty('--row-selected-surface-strong', so.surfaceStrong);
}
if (colors.detailPanel && hexToRgb(colors.detailPanel)) {
const rgb = hexToRgb(colors.detailPanel);
root.setProperty('--app-expanded-bg', colors.detailPanel);
const fg = readableForeground(rgb);
root.setProperty('--detail-text', fg.primary);
root.setProperty('--detail-text-muted', fg.secondary);
root.setProperty('--detail-text-faint', fg.muted);
const so = surfaceShades(rgb);
root.setProperty('--detail-surface', so.surface);
root.setProperty('--detail-surface-strong', so.surfaceStrong);
root.setProperty('--detail-border', so.border);
root.setProperty('--detail-border-soft', so.borderSoft);
}
}
// Default-Logo das immer im Projekt-Root liegt — wird verwendet, wenn der
// User kein eigenes Logo hochgeladen hat (oder seines wieder entfernt).
const DEFAULT_LOGO_FILE = 'application.png';
function applyBranding(branding, cacheBust) {
const logoEl = document.getElementById('brandLogo');
if (!logoEl) return;
const file = (branding && branding.logoFile) || DEFAULT_LOGO_FILE;
// cacheBust-Fallback-Kette: explizit uebergeben -> branding.logoCacheTag -> 1
// (Default-Logo ist statisch, einmaliges Laden reicht; uploaded Logos
// bekommen vom Backend einen mtime-Tag.)
const tag = cacheBust || (branding && branding.logoCacheTag) || '1';
const url = `/assets/${encodeURIComponent(file)}?v=${encodeURIComponent(tag)}`;
logoEl.innerHTML = ` `;
logoEl.classList.add('has-custom-logo');
}
// =============================================================
// Einstellungen (Tenant/Client-ID, Gruppen-Praefixe, Naming, ...)
// =============================================================
// Fallback-Vendor-Definitionen falls SettingsState aus irgendeinem Grund
// (Settings-Load-Race, kaputte settings.json) leer ist. Damit zeigen PMPC-
// und Robopack-Apps IMMER Source-Badge + Lock-Icons, unabhaengig von Backend.
const VENDOR_FALLBACKS = [
{ id: 'patchmypc', displayName: 'PatchMyPC', portalUrl: 'https://portal.patchmypc.com/', logoFile: 'pmpc.png', block: { delete: true, rename: true } },
{ id: 'robopack', displayName: 'Robopack', portalUrl: 'https://app.robopack.com/', logoFile: 'robopack.png', block: { delete: true, rename: true } },
];
const SettingsState = {
current: null, // zuletzt geladene Settings vom Server
filePath: null,
// Vendor-Indizes — werden bei jedem Settings-Load via rebuildVendorIndexes()
// neu aufgebaut, damit Render + Filter ohne wiederholten Lookup laufen.
// Initial mit Fallbacks befuellt, damit der erste Render schon korrekt rendert.
vendors: VENDOR_FALLBACKS.slice(),
vendorsById: Object.fromEntries(VENDOR_FALLBACKS.map(v => [v.id, v])),
vendorsByDisplayName: Object.fromEntries(VENDOR_FALLBACKS.map(v => [v.displayName, v])),
};
function rebuildVendorIndexes(settings) {
let list = (settings && Array.isArray(settings.vendors)) ? settings.vendors : [];
// Fehlende Standard-Vendoren immer mit reinmischen — verhindert dass eine
// unvollstaendige settings.json PMPC/Robopack aus dem UI verschwinden laesst.
for (const fb of VENDOR_FALLBACKS) {
if (!list.some(v => v && v.id === fb.id)) list = list.concat([fb]);
}
SettingsState.vendors = list;
SettingsState.vendorsById = {};
SettingsState.vendorsByDisplayName = {};
for (const v of list) {
if (v && v.id) SettingsState.vendorsById[v.id] = v;
if (v && v.displayName) SettingsState.vendorsByDisplayName[v.displayName] = v;
}
rebuildVendorFilterOptions();
}
function rebuildVendorFilterOptions() {
// Filter-Select hat statische Optionen fuer "Alle/Intune"; Vendor-Optionen
// werden hier nachgereicht. Vorhandene srcVendor:-Options vorher entsorgen.
const sel = document.getElementById('appFilter');
if (!sel) return;
// Bestehende dynamische Eintraege entfernen
Array.from(sel.querySelectorAll('option[data-vendor-option]')).forEach(o => o.remove());
// "Nur Intune" als Anker — Vendor-Eintraege werden direkt danach eingefuegt
const intuneOpt = Array.from(sel.options).find(o => o.value === 'srcIntune');
const insertAfter = intuneOpt || sel.options[sel.options.length - 1];
for (const v of SettingsState.vendors) {
if (!v || !v.id || !v.displayName) continue;
const opt = document.createElement('option');
opt.value = `srcVendor:${v.id}`;
opt.textContent = `Nur ${v.displayName}-Apps`;
opt.dataset.vendorOption = '1';
insertAfter.insertAdjacentElement('afterend', opt);
}
}
function rebuildCategoryFilterOptions() {
// Baut die Optionen des Kategorie-Filters aus den tatsaechlich in den
// geladenen Apps vorkommenden Kategorien. Auswahl bleibt erhalten falls
// die Kategorie noch existiert.
const sel = document.getElementById('appCategoryFilter');
if (!sel) return;
const prev = sel.value;
// Distinkte Kategorien sammeln (alphabetisch, de-DE)
const set = new Set();
let anyWithout = false;
for (const a of State.apps) {
if (Array.isArray(a.Categories) && a.Categories.length > 0) {
for (const c of a.Categories) { if (c) set.add(String(c)); }
} else {
anyWithout = true;
}
}
const cats = Array.from(set).sort((x, y) => x.localeCompare(y, 'de-DE'));
// Neu aufbauen: "Alle Kategorien" fix, dann optional "Ohne Kategorie", dann die Namen
sel.innerHTML = '';
const optAll = document.createElement('option');
optAll.value = '';
optAll.textContent = 'Alle Kategorien';
sel.appendChild(optAll);
if (anyWithout) {
const optNone = document.createElement('option');
optNone.value = '__none__';
optNone.textContent = 'Ohne Kategorie (Default)';
sel.appendChild(optNone);
}
for (const c of cats) {
const opt = document.createElement('option');
opt.value = c;
opt.textContent = c;
sel.appendChild(opt);
}
// Vorherige Auswahl wiederherstellen wenn noch gueltig
if (prev && (prev === '__none__' || cats.includes(prev))) {
sel.value = prev;
} else {
sel.value = '';
State.appFilter.category = '';
}
}
function isSetupIncomplete(s) {
if (!s) return true;
const c = s.connection || {};
const tenant = (c.tenantId || '').trim();
const client = (c.clientId || '').trim();
// Nur Tenant ID + Client ID sind Pflicht fuer eine Verbindung. Abteilungs-
// Praefixe und RPA-Gruppen sind optional — fehlen sie, zeigen die jeweiligen
// Tabs lediglich einen Konfigurations-Hinweis (kein harter Setup-Blocker).
return !tenant || !client;
}
function applySetupNeededUI(needed) {
// Setup-Banner im Modal + Hinweis im Onboarding-Panel + Connect-Button disablen
const banner = document.getElementById('setupBanner');
if (banner) banner.classList.toggle('hidden', !needed);
const hint = document.getElementById('onboardingSetupHint');
if (hint) hint.classList.toggle('hidden', !needed);
const btn = document.getElementById('btnConnect');
if (btn) {
btn.disabled = !!needed;
btn.title = needed ? 'Bitte zuerst in Einstellungen konfigurieren.' : '';
}
}
async function openSettingsModal() {
// Modal sofort oeffnen, Inhalt nachladen
document.getElementById('setFilePath').textContent = SettingsState.filePath || 'wird geladen…';
// Test-Result-Box bei jedem Oeffnen leeren — stale Ergebnisse vermeiden.
const trBox = document.getElementById('settingsTestResult');
if (trBox) trBox.innerHTML = '';
openModal('modalSettings');
try {
const res = await api('/api/settings');
SettingsState.current = res.settings;
SettingsState.filePath = res.path;
rebuildVendorIndexes(res.settings);
fillSettingsForm(res.settings);
document.getElementById('setFilePath').textContent = res.path || '(unbekannt)';
const banner = document.getElementById('setupBanner');
if (banner) banner.classList.toggle('hidden', !isSetupIncomplete(res.settings));
} catch (e) {
toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'err');
}
}
async function onSettingsTest() {
const box = document.getElementById('settingsTestResult');
const btn = document.getElementById('btnSettingsTest');
if (!box) return;
// Aktuellen Form-State testen (nicht den gespeicherten).
const payload = readSettingsForm();
box.innerHTML = 'Pruefe Werte gegen Microsoft Graph…
';
if (btn) btn.disabled = true;
try {
const res = await api('/api/settings/test', { method: 'POST', body: payload });
box.innerHTML = renderSettingsTestResult(res);
} catch (e) {
box.innerHTML = `Test fehlgeschlagen: ${escapeHtml(e.message || String(e))}
`;
} finally {
if (btn) btn.disabled = false;
}
}
function renderSettingsTestResult(res) {
const lines = [];
const ok = 'OK ';
const fail = 'FAIL ';
const conn = res.connection || {};
if (conn.ok) {
lines.push(`${ok} Verbindung: ${escapeHtml(conn.account || '')} (Tenant ${escapeHtml(conn.tenantId || '')})
`);
} else {
const why = conn.reason === 'not_connected' ? 'Bitte zuerst per Connect-Button verbinden.' : (conn.message || 'unbekannter Grund');
lines.push(`${fail} Verbindung: ${escapeHtml(why)}
`);
return lines.join('');
}
const d = res.departments || {};
if (d.reason === 'not_configured') {
lines.push(`${fail} Abteilungs-Praefix(e) nicht gesetzt.
`);
} else if (Array.isArray(d.perPrefix) && d.perPrefix.length > 0) {
// Neue Form: eine Zeile pro Praefix
lines.push(`${d.ok ? ok : fail} Abteilungs-Gruppen — Total: ${d.totalCount || 0}
`);
for (const pp of d.perPrefix) {
if (pp.ok) {
const sample = pp.sample && pp.sample.length ? ` (z.B. ${pp.sample.map(escapeHtml).join(', ')}) ` : '';
const icon = pp.count > 0 ? ok : '0 ';
const cls = pp.count > 0 ? '' : 'set-test-warn';
lines.push(`${icon} ${escapeHtml(pp.prefix)}: ${pp.count} Treffer${sample}
`);
} else {
lines.push(`${fail} ${escapeHtml(pp.prefix)}: ${escapeHtml(pp.error || '')}
`);
}
}
} else if (d.ok) {
// Backward-compat: alte Single-Prefix-Response
const sample = d.sample && d.sample.length ? ` (z.B. ${d.sample.map(escapeHtml).join(', ')}) ` : '';
lines.push(`${ok} Abteilungs-Gruppen: ${d.count} Treffer fuer ${escapeHtml(d.prefix)}${sample}
`);
} else {
lines.push(`${fail} Abteilungs-Gruppen-Lookup fehlgeschlagen: ${escapeHtml(d.error || '')}
`);
}
// RPA-Funktion ist deaktiviert — Test-Ergebnis dazu wird nicht angezeigt.
const u = res.userSearch || {};
if (u.ok) {
lines.push(`${ok} Benutzer-Suche: ok (${(u.fields || []).join(', ')})
`);
} else if (u.reason === 'no_fields') {
lines.push(`${fail} Keine User-Such-Felder gewaehlt.
`);
} else {
lines.push(`${fail} Benutzer-Suche fehlgeschlagen: ${escapeHtml(u.error || '')}
`);
}
const ven = res.vendors || {};
if (Array.isArray(ven.counts) && ven.counts.length > 0) {
for (const c of ven.counts) {
const icon = c.count > 0 ? ok : '0 ';
const cls = c.count > 0 ? '' : 'set-test-warn';
lines.push(`${icon} Vendor ${escapeHtml(c.displayName)} : ${c.count} Apps (Regel: ${escapeHtml(c.detection)})
`);
}
if (ven.hint) {
lines.push(`Hinweis: ${escapeHtml(ven.hint)}
`);
}
}
return lines.join('');
}
// --- Multi-Tenant Profil-Editor (Einstellungen) ---
function tenantNewId() {
return (window.crypto && crypto.randomUUID)
? crypto.randomUUID()
: 't-' + Date.now() + '-' + Math.random().toString(16).slice(2, 8);
}
function applyConnModeUI(mode) {
const multi = mode === 'multi';
document.getElementById('setSingleConn').classList.toggle('hidden', multi);
document.getElementById('setMultiConn').classList.toggle('hidden', !multi);
}
function tenantRowHtml(t, active) {
const id = t.id || tenantNewId();
return ``;
}
const TENANT_EMPTY_HINT = 'Noch keine Tenants. Füge unten einen hinzu.
';
function renderTenantRows(tenants, activeId) {
const el = document.getElementById('setTenantList');
const list = Array.isArray(tenants) ? tenants.filter(Boolean) : [];
let active = activeId;
if (!list.some(t => t.id === active) && list.length) active = list[0].id;
el.innerHTML = list.length ? list.map(t => tenantRowHtml(t, t.id === active)).join('') : TENANT_EMPTY_HINT;
wireTenantRowEvents();
}
function wireTenantRowEvents() {
document.querySelectorAll('#setTenantList .tenant-remove').forEach(btn => {
btn.onclick = () => {
const row = btn.closest('.tenant-row');
const wasActive = row.querySelector('input[name="tenantActive"]').checked;
row.remove();
if (wasActive) {
const first = document.querySelector('#setTenantList input[name="tenantActive"]');
if (first) first.checked = true;
}
if (!document.querySelector('#setTenantList .tenant-row')) {
document.getElementById('setTenantList').innerHTML = TENANT_EMPTY_HINT;
}
};
});
}
function addTenantRow() {
const el = document.getElementById('setTenantList');
if (!el.querySelector('.tenant-row')) el.innerHTML = '';
const id = tenantNewId();
const isFirst = !el.querySelector('.tenant-row');
el.insertAdjacentHTML('beforeend', tenantRowHtml({ id }, isFirst));
wireTenantRowEvents();
el.querySelector(`.tenant-row[data-id="${CSS.escape(id)}"] .tenant-label`)?.focus();
}
function tenantSplitLines(txt) {
return (txt || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean);
}
function readTenantRows() {
const tenants = [];
let activeTenantId = '';
document.querySelectorAll('#setTenantList .tenant-row').forEach(row => {
const id = row.dataset.id;
tenants.push({
id,
label: row.querySelector('.tenant-label').value.trim(),
tenantId: row.querySelector('.tenant-tid').value.trim(),
clientId: row.querySelector('.tenant-cid').value.trim(),
clientIdRo: row.querySelector('.tenant-cidro').value.trim(),
// Pro-Tenant-Overrides; leer = globale Vorgabe gilt.
prefixes: tenantSplitLines(row.querySelector('.tenant-prefixes').value),
requiredGroupNaming: {
prefix: row.querySelector('.tenant-req-prefix').value.trim(),
suffix: row.querySelector('.tenant-req-suffix').value.trim(),
},
availableGroupNaming: {
prefix: row.querySelector('.tenant-avail-prefix').value.trim(),
suffix: row.querySelector('.tenant-avail-suffix').value.trim(),
},
});
if (row.querySelector('input[name="tenantActive"]').checked) activeTenantId = id;
});
if (!activeTenantId && tenants.length) activeTenantId = tenants[0].id;
return { tenants, activeTenantId };
}
document.getElementById('setConnMode')?.addEventListener('change', e => {
applyConnModeUI(e.target.value);
// Beim erstmaligen Wechsel auf Multi ohne Profile: aus den Single-Feldern ein Profil vorbefüllen
if (e.target.value === 'multi' && !document.querySelector('#setTenantList .tenant-row')) {
const tid = document.getElementById('setTenantId').value.trim();
const cid = document.getElementById('setClientId').value.trim();
if (tid || cid) {
renderTenantRows([{
id: tenantNewId(), label: 'Standard',
tenantId: tid, clientId: cid,
clientIdRo: document.getElementById('setClientIdRo').value.trim(),
}], null);
}
}
});
document.getElementById('btnAddTenant')?.addEventListener('click', addTenantRow);
function fillSettingsForm(s) {
const c = s.connection || {};
document.getElementById('setTenantId').value = c.tenantId || '';
document.getElementById('setClientId').value = c.clientId || '';
document.getElementById('setClientIdRo').value = c.clientIdRo || '';
document.getElementById('setScopes').value = (c.scopes || []).join('\n');
document.getElementById('setScopesRo').value = (c.scopesRo || []).join('\n');
// Verbindungsmodus + Multi-Tenant-Profile
const mode = c.multiTenant ? 'multi' : 'single';
document.getElementById('setConnMode').value = mode;
renderTenantRows(Array.isArray(c.tenants) ? c.tenants : [], c.activeTenantId || '');
applyConnModeUI(mode);
// Backward-compat: lese 'prefixes' (Array) bevorzugt, falle sonst auf
// alten Single-String 'prefix' zurueck. Beide werden in die Textarea
// gemerged (gleiche Logik wie Get-DepartmentPrefixes im Backend).
(function () {
const d = s.departments || {};
const list = [];
if (Array.isArray(d.prefixes)) { for (const p of d.prefixes) if (p) list.push(String(p)); }
if (d.prefix) list.push(String(d.prefix));
const seen = {}; const out = [];
for (const raw of list) {
const t = raw.trim(); if (!t) continue;
const k = t.toLowerCase();
if (seen[k]) continue; seen[k] = true; out.push(t);
}
document.getElementById('setDeptPrefixes').value = out.join('\n');
})();
const fields = (s.userSearch && s.userSearch.fields) || [];
document.getElementById('setUsfDisplayName').checked = fields.includes('displayName');
document.getElementById('setUsfUpn').checked = fields.includes('userPrincipalName');
document.getElementById('setUsfMail').checked = fields.includes('mail');
document.getElementById('setUsfDepartment').checked = fields.includes('department');
const r = s.requiredGroupNaming || {};
document.getElementById('setReqPrefix').value = r.prefix || '';
document.getElementById('setReqSuffix').value = r.suffix || '';
updateReqPreview();
const av = s.availableGroupNaming || {};
document.getElementById('setAvailPrefix').value = av.prefix || '';
document.getElementById('setAvailSuffix').value = av.suffix || '';
updateAvailPreview();
// Policy-Backup (Git)
const pb = s.policyBackup || {};
document.getElementById('setPolicyGitPath').value = pb.gitRepoPath || '';
document.getElementById('setPolicyGitPush').checked = !!pb.push;
// Branding-Logo Preview
fillLogoPreview(s.branding);
// Farben
const cols = (s.theme && s.theme.colors) || {};
for (const key of Object.keys(ThemeDefaults)) {
const hex = cols[key] || ThemeDefaults[key];
setColorInputs(key, hex);
}
}
function setColorInputs(key, hex) {
const cap = key[0].toUpperCase() + key.slice(1);
const picker = document.getElementById(`setCol${cap}`);
const text = document.getElementById(`setCol${cap}Hex`);
const swatch = document.querySelector(`.color-swatch[data-swatch="${key}"]`);
if (picker) picker.value = hex;
if (text) text.value = hex;
if (swatch) swatch.style.background = hex;
}
function fillLogoPreview(branding) {
const userFile = branding && branding.logoFile;
const file = userFile || DEFAULT_LOGO_FILE;
const preview = document.getElementById('logoPreview');
const removeBtn = document.getElementById('btnLogoRemove');
if (!preview) return;
const tag = (branding && branding.logoCacheTag) || '1';
const url = `/assets/${encodeURIComponent(file)}?v=${encodeURIComponent(tag)}`;
preview.innerHTML = ` `;
// "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
// =============================================================
(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();
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(),
};
function openGroupExportModal() {
if (!State.connected) { toast('Bitte zuerst verbinden.', 'warn'); return; }
GexState.searchResults = [];
GexState.selectedGroup = null;
GexState.members = null;
GexState.memberFilter = '';
GexState.activeTab = 'export';
GexState.userSearchResults = [];
GexState.selectedUsers = new Map();
document.getElementById('gexSearch').value = '';
document.getElementById('gexMemberFilter').value = '';
document.getElementById('gexSearchResults').innerHTML = 'Mindestens 2 Zeichen eingeben.
';
document.getElementById('gexMembersArea').innerHTML = 'Gruppe auswählen um Mitglieder zu laden.
';
document.getElementById('gexMembersTitle').textContent = 'Mitglieder';
document.getElementById('gexStats').textContent = '';
document.getElementById('gexExportBtn').disabled = true;
// Add-Tab zurücksetzen
document.getElementById('gexUserSearch').value = '';
document.getElementById('gexUserResults').innerHTML = 'Mindestens 2 Zeichen eingeben.
';
document.getElementById('gexSelectedUsers').innerHTML = 'Noch keine Benutzer ausgewählt.
';
document.getElementById('gexSelectedCount').textContent = '0';
document.getElementById('gexAddBtn').disabled = true;
// Tab-Anzeige auf Export zurücksetzen
_gexSwitchTab('export');
switchMainView('groups');
setTimeout(() => document.getElementById('gexSearch').focus(), 80);
}
function _gexSwitchTab(tab) {
GexState.activeTab = tab;
document.querySelectorAll('.gex-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
document.getElementById('gexPaneExport').classList.toggle('hidden', tab !== 'export');
document.getElementById('gexPaneAdd').classList.toggle('hidden', tab !== 'add');
document.getElementById('gexPaneImport').classList.toggle('hidden', tab !== 'import');
document.getElementById('gexPaneUser').classList.toggle('hidden', tab !== 'user');
document.getElementById('gexLeft').classList.toggle('hidden', tab === 'user');
document.getElementById('gexExportBtn').classList.toggle('hidden', tab !== 'export');
document.getElementById('gexAddBtn').classList.toggle('hidden', tab !== 'add');
document.getElementById('gexStats').textContent = '';
if (tab === 'add') setTimeout(() => document.getElementById('gexUserSearch').focus(), 80);
if (tab === 'user') setTimeout(() => document.getElementById('gexUmSearch').focus(), 80);
}
document.querySelectorAll('.gex-tab').forEach(btn => {
btn.addEventListener('click', () => _gexSwitchTab(btn.dataset.tab));
});
// Nav-Tabs: App Management / Group Management / App Report
function switchMainView(view) {
const layout = document.getElementById('layout');
const groupView = document.getElementById('groupMgmtView');
const reportView = document.getElementById('appReportView');
const devView = document.getElementById('devView');
const polView = document.getElementById('policiesView');
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');
}
document.querySelectorAll('#topbarNav .nav-tab').forEach(btn => {
btn.addEventListener('click', () => {
const view = btn.dataset.view;
if ((view === 'groups' || view === 'report' || view === 'devices' || view === 'policies') && !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();
});
});
// Suche mit Debounce
let _gexSearchTimer = null;
document.getElementById('gexSearch')?.addEventListener('input', e => {
clearTimeout(_gexSearchTimer);
const q = e.target.value.trim();
if (q.length < 2) {
GexState.searchResults = [];
document.getElementById('gexSearchResults').innerHTML = 'Mindestens 2 Zeichen eingeben.
';
return;
}
document.getElementById('gexSearchResults').innerHTML = '';
_gexSearchTimer = setTimeout(() => _gexDoSearch(q), 350);
});
async function _gexDoSearch(q) {
try {
const data = await api(`/api/groups/search?q=${encodeURIComponent(q)}`);
GexState.searchResults = data.items || [];
_gexRenderResults();
} catch (e) {
document.getElementById('gexSearchResults').innerHTML = `Suche fehlgeschlagen: ${escapeHtml(e.message)}
`;
}
}
function _gexRenderResults() {
const el = document.getElementById('gexSearchResults');
if (!el) return;
const items = GexState.searchResults;
if (items.length === 0) {
el.innerHTML = 'Keine Gruppen gefunden.
';
return;
}
el.innerHTML = items.map(g => `
${escapeHtml(g.DisplayName)}
${g.Description ? `
${escapeHtml(g.Description)}
` : ''}
`).join('');
el.querySelectorAll('.gex-group-item').forEach(item => {
item.addEventListener('click', () => _gexSelectGroup(item.dataset.id, item.dataset.name));
});
}
async function _gexSelectGroup(id, name) {
GexState.selectedGroup = { Id: id, DisplayName: name };
GexState.members = null;
GexState.memberFilter = '';
_gexRenderResults(); // aktualisiert selected-Highlight
_gexUpdateAddBtn();
notifyCsvImportGroupSelected(id, name);
if (GexState.activeTab !== 'export') return;
document.getElementById('gexMemberFilter').value = '';
document.getElementById('gexMembersTitle').textContent = `Mitglieder — ${name}`;
document.getElementById('gexStats').textContent = '';
document.getElementById('gexExportBtn').disabled = true;
document.getElementById('gexMembersArea').innerHTML =
'
Mitglieder werden aufgelöst (inkl. verschachtelte Gruppen)… ';
try {
const data = await api(`/api/groups/${encodeURIComponent(id)}/members/export`);
GexState.members = data;
_gexRenderMembers();
document.getElementById('gexExportBtn').disabled = data.count === 0;
let stats = `${data.count} Benutzer`;
if (data.duplicates > 0) stats += ` · ${data.duplicates} Duplikate aus Verschachtelung entfernt`;
stats += ` · ${data.resolvedMs}ms`;
document.getElementById('gexStats').textContent = stats;
} catch (e) {
document.getElementById('gexMembersArea').innerHTML =
`Fehler beim Laden: ${escapeHtml(e.message)}
`;
}
}
// Live-Filter für die Mitgliederliste
document.getElementById('gexMemberFilter')?.addEventListener('input', e => {
GexState.memberFilter = e.target.value.trim().toLowerCase();
_gexApplyFilter();
});
function _gexApplyFilter() {
const q = GexState.memberFilter;
document.querySelectorAll('#gexMembersArea .gex-user-item').forEach(row => {
const text = row.dataset.search || '';
row.classList.toggle('hidden-filter', q !== '' && !text.includes(q));
});
// Pfad-Gruppen ohne sichtbare Zeilen ausblenden
document.querySelectorAll('#gexMembersArea .gex-path-group').forEach(pg => {
const visible = pg.querySelectorAll('.gex-user-item:not(.hidden-filter)').length > 0;
pg.style.display = visible ? '' : 'none';
});
}
function _gexRenderMembers() {
const area = document.getElementById('gexMembersArea');
if (!area || !GexState.members) return;
const m = GexState.members;
if (m.count === 0) {
area.innerHTML = 'Keine Benutzer in dieser Gruppe gefunden.
';
return;
}
// Nach SourcePath gruppieren
const byPath = new Map();
for (const u of m.users) {
const p = u.SourcePath || m.groupName;
if (!byPath.has(p)) byPath.set(p, []);
byPath.get(p).push(u);
}
let html = `${m.count} Benutzer`;
if (m.duplicates > 0) html += ` (${m.duplicates} Duplikate aus verschachtelten Gruppen zusammengeführt)`;
html += '
';
const directGroupName = m.groupName || '';
for (const [path, users] of byPath.entries()) {
const isDirect = path === directGroupName;
html += `';
}
area.innerHTML = html;
if (GexState.memberFilter) _gexApplyFilter();
// Checkboxen verdrahten
const removeBtn = document.getElementById('gexRemoveBtn');
if (removeBtn) removeBtn.style.display = '';
area.querySelectorAll('.gex-member-chk').forEach(chk => {
chk.addEventListener('change', () => {
const n = area.querySelectorAll('.gex-member-chk:checked').length;
if (removeBtn) {
removeBtn.disabled = n === 0;
removeBtn.textContent = n > 0 ? `${n} Mitglied${n > 1 ? 'er' : ''} entfernen` : 'Aus Gruppe entfernen';
}
});
});
}
// ---- Mitglieder entfernen ----
document.getElementById('gexRemoveBtn')?.addEventListener('click', async () => {
const area = document.getElementById('gexMembersArea');
const checked = [...(area?.querySelectorAll('.gex-member-chk:checked') || [])];
if (!checked.length || !GexState.selectedGroup) return;
const names = checked.slice(0, 5).map(c => c.dataset.name).join('\n');
const more = checked.length > 5 ? `\n… und ${checked.length - 5} weitere` : '';
if (!confirm(`${checked.length} Mitglied${checked.length > 1 ? 'er' : ''} aus "${GexState.selectedGroup.DisplayName}" entfernen?\n\n${names}${more}`)) return;
const btn = document.getElementById('gexRemoveBtn');
btn.disabled = true;
btn.textContent = 'Entferne…';
const groupId = GexState.selectedGroup.Id;
let ok = 0, fail = 0;
for (const chk of checked) {
try {
await api(`/api/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(chk.dataset.uid)}`, { method: 'DELETE' });
chk.closest('label').remove();
ok++;
} catch (e) {
fail++;
console.error('Remove failed:', chk.dataset.uid, e);
}
}
btn.textContent = 'Aus Gruppe entfernen';
btn.disabled = true;
if (fail === 0) toast(`${ok} Mitglied${ok > 1 ? 'er' : ''} entfernt`, 'ok');
else toast(`${ok} entfernt, ${fail} fehlgeschlagen`, 'warn');
// Mitgliederzahl aktualisieren
if (GexState.members) {
GexState.members.count = Math.max(0, GexState.members.count - ok);
document.getElementById('gexStats').textContent = `${GexState.members.count} Benutzer`;
}
});
// ---- Benutzer → Gruppen (User-Tab) ----
(function () {
let umDebounce = null;
let umSelected = null; // { Id, DisplayName, UserPrincipalName }
const searchInput = document.getElementById('gexUmSearch');
const resultsDiv = document.getElementById('gexUmResults');
const groupsList = document.getElementById('gexUmGroupsList');
const groupsTitle = document.getElementById('gexUmGroupsTitle');
const groupCount = document.getElementById('gexUmGroupCount');
const filterWrap = document.getElementById('gexUmFilterWrap');
const filterInput = document.getElementById('gexUmFilter');
searchInput.addEventListener('input', () => {
clearTimeout(umDebounce);
const q = searchInput.value.trim();
if (q.length < 2) {
resultsDiv.innerHTML = 'Mindestens 2 Zeichen eingeben.
';
return;
}
umDebounce = setTimeout(() => _umSearchUser(q), 320);
});
async function _umSearchUser(q) {
resultsDiv.innerHTML = 'Suche…
';
try {
const data = await api(`/api/users?q=${encodeURIComponent(q)}`);
if (!data.items || data.items.length === 0) {
resultsDiv.innerHTML = 'Keine Benutzer gefunden.
';
return;
}
let html = '';
for (const u of data.items) {
const upn = u.UserPrincipalName || u.Mail || u.Id;
html += `
${escapeHtml(u.DisplayName || '—')}
${escapeHtml(upn)}
`;
}
resultsDiv.innerHTML = html;
resultsDiv.querySelectorAll('.gex-result-item').forEach(el => {
el.addEventListener('click', () => _umSelectUser({
Id: el.dataset.uid,
DisplayName: el.dataset.name,
UserPrincipalName: el.dataset.upn
}));
});
} catch (e) {
resultsDiv.innerHTML = `Fehler: ${escapeHtml(String(e))}
`;
}
}
async function _umSelectUser(user) {
umSelected = user;
searchInput.value = user.DisplayName || user.UserPrincipalName;
resultsDiv.innerHTML = '';
groupsList.innerHTML = 'Lade Gruppen…
';
groupsTitle.style.display = '';
filterWrap.style.display = '';
filterInput.value = '';
groupCount.textContent = '…';
try {
const data = await api(`/api/users/${encodeURIComponent(user.Id)}/memberof`);
groupCount.textContent = data.count;
if (!data.groups || data.groups.length === 0) {
groupsList.innerHTML = 'Keine Gruppen gefunden.
';
filterWrap.style.display = 'none';
return;
}
_umRenderGroups(data.groups);
} catch (e) {
groupsList.innerHTML = `Fehler: ${escapeHtml(String(e))}
`;
}
}
function _umRenderGroups(groups) {
let html = '';
for (const g of groups) {
const desc = g.Description ? `${escapeHtml(g.Description)} ` : '';
const badge = `${escapeHtml(g.Type)} `;
html += `
${escapeHtml(g.DisplayName || '—')}
${desc}
${badge}
`;
}
groupsList.innerHTML = html;
}
filterInput.addEventListener('input', () => {
const q = filterInput.value.toLowerCase();
groupsList.querySelectorAll('.gex-group-row').forEach(row => {
row.classList.toggle('hidden-filter', q.length > 0 && !row.dataset.search.includes(q));
});
});
})();
// ---- Benutzer-Suche im Add-Tab ----
let _gexUserSearchTimer = null;
document.getElementById('gexUserSearch')?.addEventListener('input', e => {
clearTimeout(_gexUserSearchTimer);
const q = e.target.value.trim();
if (q.length < 2) {
GexState.userSearchResults = [];
document.getElementById('gexUserResults').innerHTML = 'Mindestens 2 Zeichen eingeben.
';
return;
}
document.getElementById('gexUserResults').innerHTML = '';
_gexUserSearchTimer = setTimeout(() => _gexDoUserSearch(q), 350);
});
async function _gexDoUserSearch(q) {
try {
const data = await api(`/api/users?q=${encodeURIComponent(q)}`);
GexState.userSearchResults = data.items || [];
_gexRenderUserResults();
} catch (e) {
document.getElementById('gexUserResults').innerHTML = `Suche fehlgeschlagen: ${escapeHtml(e.message)}
`;
}
}
function _gexRenderUserResults() {
const el = document.getElementById('gexUserResults');
if (!el) return;
const items = GexState.userSearchResults;
if (items.length === 0) {
el.innerHTML = 'Keine Benutzer gefunden.
';
return;
}
el.innerHTML = items.map(u => {
const already = GexState.selectedUsers.has(u.Id);
return `
${escapeHtml(u.DisplayName || '—')}
${escapeHtml(u.UserPrincipalName || u.Mail || '')}
${already ? '✓ ' : ''}
`;
}).join('');
el.querySelectorAll('.gex-user-result-item:not(.selected)').forEach(item => {
item.addEventListener('click', () => _gexSelectUser({ Id: item.dataset.id, DisplayName: item.dataset.name, UserPrincipalName: item.dataset.upn }));
});
}
function _gexSelectUser(u) {
if (GexState.selectedUsers.has(u.Id)) return;
GexState.selectedUsers.set(u.Id, u);
_gexRenderUserResults();
_gexRenderSelectedUsers();
_gexUpdateAddBtn();
}
function _gexDeselectUser(id) {
GexState.selectedUsers.delete(id);
_gexRenderUserResults();
_gexRenderSelectedUsers();
_gexUpdateAddBtn();
}
function _gexRenderSelectedUsers() {
const el = document.getElementById('gexSelectedUsers');
const countEl = document.getElementById('gexSelectedCount');
if (!el) return;
const users = [...GexState.selectedUsers.values()];
countEl.textContent = users.length;
if (users.length === 0) {
el.innerHTML = 'Noch keine Benutzer ausgewählt.
';
return;
}
el.innerHTML = users.map(u => `
${escapeHtml(u.DisplayName || '—')}
${escapeHtml(u.UserPrincipalName || '')}
×
`).join('');
el.querySelectorAll('.gex-selected-chip-remove').forEach(btn => {
btn.addEventListener('click', () => _gexDeselectUser(btn.dataset.id));
});
}
function _gexUpdateAddBtn() {
const btn = document.getElementById('gexAddBtn');
if (!btn) return;
btn.disabled = !(GexState.selectedGroup && GexState.selectedUsers.size > 0);
}
document.getElementById('gexAddBtn')?.addEventListener('click', async () => {
if (!GexState.selectedGroup) { toast('Bitte zuerst eine Gruppe auswählen.', 'warn'); return; }
if (GexState.selectedUsers.size === 0) { toast('Bitte mindestens einen Benutzer auswählen.', 'warn'); return; }
const btn = document.getElementById('gexAddBtn');
btn.disabled = true;
const origHtml = btn.innerHTML;
btn.innerHTML = '
Wird hinzugefügt…';
const userIds = [...GexState.selectedUsers.keys()];
try {
const data = await api(`/api/groups/${encodeURIComponent(GexState.selectedGroup.Id)}/members`, {
method: 'POST',
body: { userIds },
});
if (data.errors > 0) {
toast(`${data.success} von ${data.total} hinzugefügt, ${data.errors} Fehler.`, 'warn');
} else {
toast(`${data.success} Benutzer zu „${GexState.selectedGroup.DisplayName}" hinzugefügt.`, 'ok');
GexState.selectedUsers.clear();
_gexRenderSelectedUsers();
document.getElementById('gexUserSearch').value = '';
document.getElementById('gexUserResults').innerHTML = 'Mindestens 2 Zeichen eingeben.
';
}
} catch (e) {
toast(`Fehler: ${e.message}`, 'err');
} finally {
btn.innerHTML = origHtml;
_gexUpdateAddBtn();
}
});
document.getElementById('gexExportBtn')?.addEventListener('click', () => {
if (!GexState.members?.users) return;
const m = GexState.members;
const rows = [['DisplayName', 'UserPrincipalName', 'Mail', 'SourcePath', 'GroupId', 'GroupName']];
for (const u of m.users) {
rows.push([
u.DisplayName || '',
u.UserPrincipalName || '',
u.Mail || '',
u.SourcePath || m.groupName,
m.groupId,
m.groupName,
]);
}
const csv = rows.map(r =>
r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')
).join('\r\n');
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${m.groupName.replace(/[^a-zA-Z0-9_-]/g, '_')}-members.csv`;
a.click();
URL.revokeObjectURL(url);
toast(`${m.count} Benutzer exportiert.`, 'ok');
});
// =============================================================
// APP REPORT VIEW
// =============================================================
const ReportState = {
items: [], // alle geladenen Items
filtered: [], // nach Suche gefiltert
sortCol: 'AppName',
sortDir: 1, // 1 = asc, -1 = desc
};
async function loadAppReport() {
if (!State.connected) { toast('Bitte zuerst verbinden.', 'warn'); return; }
const tbody = document.getElementById('reportBody');
tbody.innerHTML = `
Lade Install-Statistiken… (kann 30–60 s dauern) `;
document.getElementById('btnReportExport').disabled = true;
document.getElementById('btnReportRefresh').disabled = true;
try {
const res = await api('/api/apps/report');
ReportState.items = res.items || [];
applyReportFilter();
toast(`${ReportState.items.length} Apps geladen`, 'ok');
} catch (e) {
tbody.innerHTML = `${escapeHtml(e.message)} `;
toast(e.message, 'err', 'Report fehlgeschlagen');
} finally {
document.getElementById('btnReportRefresh').disabled = false;
}
}
function applyReportFilter() {
const q = (document.getElementById('reportSearch')?.value || '').toLowerCase().trim();
ReportState.filtered = q
? ReportState.items.filter(r =>
(r.AppName || '').toLowerCase().includes(q) ||
(r.Publisher || '').toLowerCase().includes(q) ||
(r.AppType || '').toLowerCase().includes(q))
: ReportState.items.slice();
sortReport();
}
function sortReport() {
const col = ReportState.sortCol;
const dir = ReportState.sortDir;
const numCols = new Set(['InstalledDeviceCount','PendingInstallDeviceCount','FailedDeviceCount','NotApplicableDeviceCount']);
ReportState.filtered.sort((a, b) => {
let av = a[col] ?? '', bv = b[col] ?? '';
if (numCols.has(col)) return dir * (Number(av) - Number(bv));
return dir * String(av).localeCompare(String(bv), 'de', { sensitivity: 'base' });
});
renderReportTable();
}
function renderReportTable() {
const tbody = document.getElementById('reportBody');
const count = document.getElementById('reportCount');
count.textContent = ReportState.filtered.length;
document.getElementById('btnReportExport').disabled = ReportState.filtered.length === 0;
// Sort-Icons aktualisieren
document.querySelectorAll('#reportTable th.sortable').forEach(th => {
const ico = th.querySelector('.sort-ico');
if (!ico) return;
if (th.dataset.col === ReportState.sortCol) {
ico.textContent = ReportState.sortDir === 1 ? ' ▲' : ' ▼';
} else {
ico.textContent = '';
}
});
if (ReportState.filtered.length === 0) {
tbody.innerHTML = `Keine Apps gefunden. `;
return;
}
tbody.innerHTML = ReportState.filtered.map(r => {
const failCls = r.FailedDeviceCount > 0 ? 'report-fail' : '';
return `
${escapeHtml(r.AppName)}
${escapeHtml(r.Publisher || '—')}
${escapeHtml(r.AppType || '—')}
${escapeHtml(r.Version || '—')}
${r.InstalledDeviceCount}
${r.PendingInstallDeviceCount}
${r.FailedDeviceCount}
${r.NotApplicableDeviceCount}
Geräte-Export
`;
}).join('');
// Delegierter Click-Handler für alle Export-Buttons
tbody.querySelectorAll('.btn-report-device-export').forEach(btn => {
btn.addEventListener('click', () => exportDeviceStatusCsv(btn.dataset.appid, btn.dataset.appname, btn));
});
}
async function exportDeviceStatusCsv(appId, appName, btn) {
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = 'Lade…';
try {
const res = await api(`/api/apps/devicestatus?appId=${encodeURIComponent(appId)}`);
const items = res.items || [];
if (items.length === 0) { toast('Keine Gerätedaten gefunden.', 'warn'); return; }
const cols = ['DeviceName','UserName','InstallState','InstallStateDetail','ErrorCode','OsVersion','LastSyncDateTime'];
const headers = ['Gerätename','Benutzer','Status','Status-Detail','Fehlercode','OS-Version','Letzte Synchronisierung'];
const rows = [headers, ...items.map(r => cols.map(c => r[c] ?? ''))];
const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\r\n');
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const safe = appName.replace(/[\\/:*?"<>|]/g, '_').slice(0, 60);
const a = Object.assign(document.createElement('a'), { href: url, download: `devices-${safe}-${new Date().toISOString().slice(0,10)}.csv` });
a.click();
URL.revokeObjectURL(url);
toast(`${items.length} Geräte exportiert`, 'ok');
} catch (e) {
toast(e.message, 'err', 'Export fehlgeschlagen');
} finally {
btn.disabled = false;
btn.textContent = orig;
}
}
function exportReportCsv() {
const cols = ['AppName','Publisher','AppType','Version','InstalledDeviceCount','PendingInstallDeviceCount','FailedDeviceCount','NotApplicableDeviceCount'];
const headers = ['App-Name','Publisher','Typ','Version','Installiert','Ausstehend','Fehlgeschlagen','Nicht anwendbar'];
const rows = [headers, ...ReportState.filtered.map(r => cols.map(c => r[c] ?? ''))];
const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\r\n');
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: `app-report-${new Date().toISOString().slice(0,10)}.csv` });
a.click();
URL.revokeObjectURL(url);
toast(`${ReportState.filtered.length} Apps exportiert`, 'ok');
}
document.getElementById('btnReportRefresh')?.addEventListener('click', loadAppReport);
document.getElementById('btnReportExport')?.addEventListener('click', exportReportCsv);
document.getElementById('reportSearch')?.addEventListener('input', applyReportFilter);
document.querySelectorAll('#reportTable th.sortable').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
if (ReportState.sortCol === col) {
ReportState.sortDir *= -1;
} else {
ReportState.sortCol = col;
ReportState.sortDir = col.includes('Count') ? -1 : 1;
}
sortReport();
});
});
// =============================================================
// CSV-IMPORT: Benutzer per UPN/CSV in Gruppe importieren
// =============================================================
const CsvImport = {
resolved: [], // Ergebnis des letzten Resolve-Calls
groupId: '',
groupName: '',
};
// Datei-Upload -> Textarea füllen
document.getElementById('csvImportFile')?.addEventListener('change', function () {
const file = this.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
document.getElementById('csvImportText').value = e.target.result;
this.value = '';
};
reader.readAsText(file, 'utf-8');
});
// UPNs aus Textarea extrahieren (1. Spalte, Header erkennen)
function splitCsvLine(line) {
// Einfacher CSV-Splitter der quoted fields unterstützt
const fields = [];
let cur = '', inQ = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') { inQ = !inQ; continue; }
if (!inQ && (ch === ',' || ch === ';' || ch === '\t')) { fields.push(cur); cur = ''; continue; }
cur += ch;
}
fields.push(cur);
return fields.map(f => f.trim());
}
function parseCsvUpns(raw) {
const lines = raw.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
if (lines.length === 0) return [];
// Header-Zeile: prüfen ob erste Zeile Spaltennamen enthält
const UPN_HEADERS = ['userprincipalname', 'upn', 'mail', 'email', 'e-mail', 'emailaddress'];
const firstFields = splitCsvLine(lines[0]).map(f => f.toLowerCase());
let colIdx = -1;
// Suche in Header nach bekannten UPN-Spaltenbezeichnungen
for (const h of UPN_HEADERS) {
const idx = firstFields.indexOf(h);
if (idx !== -1) { colIdx = idx; break; }
}
const startLine = colIdx !== -1 ? 1 : 0; // Header überspringen wenn gefunden
if (colIdx === -1) {
// Kein Header erkannt: erste Spalte die ein @ enthält verwenden
// oder Spalte 0 als Fallback
const firstDataFields = splitCsvLine(lines[0]);
colIdx = firstDataFields.findIndex(f => f.includes('@'));
if (colIdx === -1) colIdx = 0;
}
const upns = [];
for (let i = startLine; i < lines.length; i++) {
const fields = splitCsvLine(lines[i]);
const val = (fields[colIdx] || '').trim();
if (!val) continue;
if (!val.includes('@')) continue; // muss @ enthalten (UPN/E-Mail)
upns.push(val);
}
return [...new Set(upns)];
}
// Resolve-Button
document.getElementById('csvResolveBtn')?.addEventListener('click', async () => {
const raw = document.getElementById('csvImportText').value;
const upns = parseCsvUpns(raw);
if (upns.length === 0) { toast('Keine gültigen UPNs/E-Mails gefunden.', 'warn'); return; }
const btn = document.getElementById('csvResolveBtn');
const stats = document.getElementById('csvResolveStats');
btn.disabled = true;
btn.textContent = 'Löse auf…';
stats.textContent = `${upns.length} Einträge werden aufgelöst…`;
try {
const res = await api('/api/groups/resolve-upns', { method: 'POST', body: { upns } });
CsvImport.resolved = res.items || [];
renderCsvPreview();
stats.textContent = `${res.found} gefunden, ${res.notFound} nicht gefunden`;
} catch (e) {
toast(e.message, 'err', 'Auflösung fehlgeschlagen');
stats.textContent = '';
} finally {
btn.disabled = false;
btn.textContent = 'Benutzer auflösen';
}
});
function renderCsvPreview() {
const wrap = document.getElementById('csvPreviewWrap');
const tbody = document.getElementById('csvPreviewBody');
const foundBadge = document.getElementById('csvFoundBadge');
const notFoundBadge = document.getElementById('csvNotFoundBadge');
const importBtn = document.getElementById('csvImportBtn');
const found = CsvImport.resolved.filter(r => r.Found);
const notFound = CsvImport.resolved.filter(r => !r.Found);
foundBadge.textContent = `${found.length} gefunden`;
notFoundBadge.textContent = `${notFound.length} nicht gefunden`;
wrap.classList.remove('hidden');
tbody.innerHTML = CsvImport.resolved.map(r => {
const cls = r.Found ? 'csv-row-ok' : 'csv-row-err';
const ico = r.Found ? '✓' : '✗';
const icoCls = r.Found ? 'csv-ico-ok' : 'csv-ico-err';
return `
${ico}
${escapeHtml(r.Upn)}
${escapeHtml(r.DisplayName || '—')}
${r.Found ? escapeHtml(r.Mail || r.Upn) : 'Nicht gefunden'}
`;
}).join('');
importBtn.disabled = found.length === 0 || !CsvImport.groupId;
updateCsvImportNote();
}
function updateCsvImportNote() {
const note = document.getElementById('csvImportNote');
const importBtn = document.getElementById('csvImportBtn');
const found = CsvImport.resolved.filter(r => r.Found).length;
if (!CsvImport.groupId) {
note.textContent = 'Gruppe links auswählen, dann Benutzer auflösen.';
importBtn.disabled = true;
} else if (found === 0) {
note.textContent = `Gruppe: ${CsvImport.groupName} — keine aufgelösten Benutzer.`;
importBtn.disabled = true;
} else {
note.textContent = `Gruppe: ${CsvImport.groupName}`;
importBtn.disabled = false;
}
}
// Import-Button: gefundene User zur Gruppe hinzufügen
document.getElementById('csvImportBtn')?.addEventListener('click', async () => {
const found = CsvImport.resolved.filter(r => r.Found);
if (!CsvImport.groupId || found.length === 0) return;
const btn = document.getElementById('csvImportBtn');
btn.disabled = true;
btn.textContent = 'Importiere…';
try {
const userIds = found.map(r => r.UserId);
const res = await api(`/api/groups/${encodeURIComponent(CsvImport.groupId)}/members`, {
method: 'POST',
body: { userIds }
});
let cntAdded = 0, cntExist = 0, cntErr = 0;
const notFound = CsvImport.resolved.filter(r => !r.Found).length;
// Status in Vorschau aktualisieren
if (res.results) {
const map = {};
for (const r of res.results) map[r.userId] = r;
document.querySelectorAll('#csvPreviewBody tr').forEach((tr, idx) => {
const item = CsvImport.resolved[idx];
if (!item?.Found) return;
const entry = map[item.UserId] || {};
const st = entry.status || 'Error';
const errMsg = entry.message || '';
let icoCls, ico, label;
if (st === 'Success') { icoCls = 'csv-ico-done'; ico = '✔'; label = 'Hinzugefügt'; cntAdded++; }
else if (st === 'AlreadyMember') { icoCls = 'csv-ico-warn'; ico = '≈'; label = 'Bereits Mitglied'; cntExist++; }
else { icoCls = 'csv-ico-err'; ico = '✗'; label = errMsg || 'Fehler'; cntErr++; }
tr.querySelector('td:first-child').className = icoCls;
tr.querySelector('td:first-child').textContent = ico;
tr.querySelector('.csv-status').textContent = label;
tr.className = st === 'Success' ? 'csv-row-done' : st === 'AlreadyMember' ? 'csv-row-warn' : 'csv-row-err';
});
}
// Ergebnis-Banner anzeigen
const banner = document.getElementById('csvResultBanner');
banner.classList.remove('hidden');
document.getElementById('csvResAdded').textContent = `${cntAdded} hinzugefügt`;
document.getElementById('csvResExist').textContent = `${cntExist} bereits Mitglied`;
document.getElementById('csvResError').textContent = `${cntErr} Fehler`;
document.getElementById('csvResSkipped').textContent = `${notFound} nicht gefunden`;
document.getElementById('csvResAdded').classList.toggle('hidden', cntAdded === 0);
document.getElementById('csvResExist').classList.toggle('hidden', cntExist === 0);
document.getElementById('csvResError').classList.toggle('hidden', cntErr === 0);
document.getElementById('csvResSkipped').classList.toggle('hidden', notFound === 0);
const tone = cntErr > 0 ? 'warn' : 'ok';
toast(`Import abgeschlossen: ${cntAdded} hinzugefügt, ${cntExist} bereits Mitglied${cntErr > 0 ? `, ${cntErr} Fehler` : ''}`, tone);
} catch (e) {
toast(e.message, 'err', 'Import fehlgeschlagen');
} finally {
btn.disabled = false;
btn.textContent = 'Gefundene importieren';
}
});
function notifyCsvImportGroupSelected(groupId, groupName) {
CsvImport.groupId = groupId;
CsvImport.groupName = groupName;
if (CsvImport.resolved.length > 0) renderCsvPreview();
else updateCsvImportNote();
}
// ============================================================
// Geräte-Tab
// ============================================================
const DevState = {
items: [],
filtered: [],
selected: null,
sortCol: 'deviceName',
sortAsc: true,
loaded: false
};
async function devLoadAll() {
await devFetch();
}
function devShowLoading(on) {
document.getElementById('devLoading').classList.toggle('hidden', !on);
document.getElementById('devEmpty').classList.add('hidden');
}
async function devFetch() {
const q = document.getElementById('devSearch').value.trim();
const os = document.getElementById('devOsFilter').value;
const cmp = document.getElementById('devComplianceFilter').value;
devShowLoading(true);
try {
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, 'error');
} finally {
devShowLoading(false);
}
}
function devApplyFilters() {
devFetch();
}
function devSort() {
const col = DevState.sortCol;
const asc = DevState.sortAsc;
DevState.filtered.sort((a, b) => {
const av = (a[col] || '').toString().toLowerCase();
const bv = (b[col] || '').toString().toLowerCase();
return asc ? av.localeCompare(bv) : bv.localeCompare(av);
});
devRender();
}
function devRender() {
const tbody = document.getElementById('devTableBody');
const empty = document.getElementById('devEmpty');
document.getElementById('devCount').textContent = DevState.filtered.length;
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 = d.LastSync ? new Date(d.LastSync).toLocaleDateString('de-DE') : '—';
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"' : '';
html += `
${escapeHtml(d.DeviceName || '—')}
${escapeHtml(d.UserDisplayName || '—')}
${escapeHtml(d.OS || '—')}
${escapeHtml(d.ComplianceState || '—')}
${sync}
`;
}
tbody.innerHTML = html;
tbody.querySelectorAll('tr').forEach(tr => {
tr.addEventListener('click', () => {
const id = tr.dataset.id;
const device = DevState.filtered.find(d => d.Id === id);
if (device) devSelectDevice(device);
});
});
}
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 = d.LastSync ? new Date(d.LastSync).toLocaleString('de-DE') : '—';
const enrolled = d.EnrolledDateTime ? new Date(d.EnrolledDateTime).toLocaleDateString('de-DE') : '—';
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 += `${escapeHtml(label)} ${escapeHtml(String(val))} `;
}
document.getElementById('devInfoGrid').innerHTML = html;
// Aktionen: BitLocker und Autopilot Reset nur für Windows
const isWin = (d.OS || '').toLowerCase() === 'windows';
const bitlockerBtn = document.getElementById('devBtnBitlocker');
if (bitlockerBtn) bitlockerBtn.style.display = isWin ? '' : 'none';
const autopilotBtn = document.getElementById('devBtnAutopilot');
if (autopilotBtn) autopilotBtn.style.display = isWin ? '' : 'none';
}
// Wipe-Dialog (Promise: true=keepUserData, false=löschen, null=abgebrochen)
function devWipeDialog(deviceName) {
return new Promise(resolve => {
document.getElementById('wipeDeviceName').textContent = deviceName;
document.getElementById('wipeKeepUserData').checked = false;
const backdrop = document.getElementById('wipeModalBackdrop');
backdrop.classList.remove('hidden');
const close = (result) => {
backdrop.classList.add('hidden');
resolve(result);
};
document.getElementById('wipeCancel').onclick = () => close(null);
document.getElementById('wipeModalClose').onclick = () => close(null);
document.getElementById('wipeConfirm').onclick = () => close(document.getElementById('wipeKeepUserData').checked);
backdrop.onclick = (e) => { if (e.target === backdrop) close(null); };
});
}
// Aktion ausführen
document.getElementById('devActions')?.addEventListener('click', async (e) => {
const btn = e.target.closest('.dev-action-btn');
if (!btn || !DevState.selected) return;
const action = btn.dataset.action;
if (!action) return;
const labels = {
syncDevice: 'Sync',
rebootNow: 'Neustart',
remoteLock: 'Sperren',
collectDiagnostics: 'Diagnose sammeln',
rotateBitLockerKeys: 'BitLocker-Schlüssel rotieren',
autopilotReset: 'Autopilot Reset',
retire: 'Retire (MDM entfernen)',
wipe: 'Wipe'
};
const devName = DevState.selected.DeviceName || DevState.selected.Id;
// Wipe: eigener Dialog mit Benutzerdaten-Option
if (action === 'wipe') {
const keepUserData = await devWipeDialog(devName);
if (keepUserData === null) return;
btn.disabled = true;
const origText2 = btn.textContent.trim();
btn.textContent = '…';
try {
await api(`/api/devices/${encodeURIComponent(DevState.selected.Id)}/action`, {
method: 'POST',
body: JSON.stringify({ action: 'wipe', keepUserData })
});
toast(`Wipe gesendet${keepUserData ? ' (Benutzerdaten behalten)' : ''}`, 'ok');
DevState.items = DevState.items.filter(d => d.Id !== DevState.selected.Id);
DevState.selected = null;
document.getElementById('devDetail').classList.add('hidden');
devApplyFilters();
} catch (e) {
toast(`Fehler bei Wipe: ${e}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = origText2;
}
return;
}
const isDangerous = action === 'retire' || action === 'autopilotReset';
const dangerMsg = {
retire: `ACHTUNG: "Retire" auf "${devName}" ausführen?\n\nDas MDM-Profil wird entfernt — Gerät wird nicht mehr verwaltet.`,
autopilotReset: `ACHTUNG: "Autopilot Reset" auf "${devName}" ausführen?\n\nDas Gerät wird auf OOBE zurückgesetzt. Enrollment-Daten bleiben erhalten, Benutzerdaten werden gelöscht.`
};
const msg = dangerMsg[action] || `"${labels[action]}" auf "${devName}" ausführen?`;
if (!confirm(msg)) return;
btn.disabled = true;
const origText = btn.textContent.trim();
btn.textContent = '…';
try {
await api(`/api/devices/${encodeURIComponent(DevState.selected.Id)}/action`, {
method: 'POST',
body: JSON.stringify({ action })
});
toast(`${labels[action]}: Aktion wurde gesendet`, 'ok');
if (action === 'retire') {
DevState.items = DevState.items.filter(d => d.Id !== DevState.selected.Id);
DevState.selected = null;
document.getElementById('devDetail').classList.add('hidden');
devApplyFilters();
}
} catch (e) {
toast(`Fehler bei ${labels[action]}: ${e}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = origText;
}
});
// Such- und Filter-Events
let devSearchDebounce = null;
document.getElementById('devSearch').addEventListener('input', () => {
clearTimeout(devSearchDebounce);
devSearchDebounce = setTimeout(devFetch, 400);
});
document.getElementById('devOsFilter').addEventListener('change', devFetch);
document.getElementById('devComplianceFilter').addEventListener('change', devFetch);
// Sortierung
document.querySelectorAll('#devTable th.sortable').forEach(th => {
th.addEventListener('click', () => {
if (DevState.sortCol === th.dataset.col) {
DevState.sortAsc = !DevState.sortAsc;
} else {
DevState.sortCol = th.dataset.col;
DevState.sortAsc = true;
}
devSort();
});
});
// =============================================================
// Policies (Export / Import)
// =============================================================
const PolState = {
items: [], // alle geladenen Policies (normalisiert)
selected: new Set(), // Keys "type::id"
sortCol: 'name',
sortAsc: true,
search: '',
typeFilter: '',
};
function polKey(p) { return `${p.type}::${p.id}`; }
function polFmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d)) return '—';
return d.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
}
async function loadPolicies() {
const body = document.getElementById('polBody');
body.innerHTML = ` Lade Policies… `;
// Drei Typen parallel laden; ein fehlschlagender Typ (z.B. fehlender Scope)
// soll die anderen nicht blockieren.
const endpoints = [
['/api/policies/settingscatalog', 'Settings Catalog'],
['/api/policies/compliance', 'Compliance'],
['/api/policies/configuration', 'Konfigurationsprofile'],
['/api/policies/administrativetemplate', 'Administrative Vorlagen'],
];
const settled = await Promise.allSettled(endpoints.map(([url]) => api(url)));
let items = [];
const errors = [];
settled.forEach((r, i) => {
if (r.status === 'fulfilled') {
items = items.concat(r.value.items || []);
} else {
errors.push(`${endpoints[i][1]}: ${r.reason?.message || 'Fehler'}`);
}
});
PolState.items = items;
// Auswahl auf noch existierende Policies eindampfen
const valid = new Set(items.map(polKey));
PolState.selected = new Set([...PolState.selected].filter(k => valid.has(k)));
renderPolicies();
if (errors.length) toast('Einige Typen konnten nicht geladen werden. ' + errors.join(' · '), 'warn');
}
function polFiltered() {
const q = PolState.search.toLowerCase();
const tf = PolState.typeFilter;
let list = PolState.items.filter(p => {
if (tf && p.type !== tf) return false;
if (!q) return true;
return (p.name || '').toLowerCase().includes(q)
|| (p.typeLabel || '').toLowerCase().includes(q)
|| (p.platform || '').toLowerCase().includes(q);
});
const col = PolState.sortCol, asc = PolState.sortAsc ? 1 : -1;
list.sort((a, b) => String(a[col] ?? '').localeCompare(String(b[col] ?? ''), 'de', { numeric: true }) * asc);
return list;
}
function renderPolicies() {
const body = document.getElementById('polBody');
const list = polFiltered();
document.getElementById('polCount').textContent = list.length;
if (!list.length) {
body.innerHTML = `${PolState.items.length ? 'Keine Policy passt zum Filter.' : 'Keine Policies gefunden.'} `;
polUpdateSelCount();
return;
}
body.innerHTML = list.map(p => {
const key = polKey(p);
const checked = PolState.selected.has(key) ? 'checked' : '';
return `
${escapeHtml(p.name || '—')}
${escapeHtml(p.typeLabel || p.type)}
${escapeHtml(p.platform || '—')}
${polFmtDate(p.lastModifiedDateTime)}
`;
}).join('');
body.querySelectorAll('.pol-row-check').forEach(cb => {
cb.addEventListener('change', () => {
if (cb.checked) PolState.selected.add(cb.dataset.key);
else PolState.selected.delete(cb.dataset.key);
polUpdateSelCount();
polSyncCheckAll();
});
});
// Klick auf Zeile toggelt die Checkbox (außer direkt auf die Checkbox)
body.querySelectorAll('tr[data-key]').forEach(tr => {
tr.addEventListener('click', e => {
if (e.target.classList.contains('pol-row-check')) return;
const cb = tr.querySelector('.pol-row-check');
cb.checked = !cb.checked;
cb.dispatchEvent(new Event('change'));
});
});
polUpdateSelCount();
polSyncCheckAll();
}
function polUpdateSelCount() {
const n = PolState.selected.size;
document.getElementById('polSelCount').textContent = n;
document.getElementById('btnPolExport').disabled = n === 0;
}
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();
}
}
// 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 [];
}
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;
if (!window.confirm(`${envelopes.length} Policy(s) als NEUE Policies im verbundenen Tenant anlegen?\n\nBestehende Policies werden dabei nicht verändert.`)) return;
setLoading('Importiere Policies…');
try {
const res = await api('/api/policies/import', { method: 'POST', body: { policies: envelopes }, timeoutMs: 180000 });
const results = res.results || [];
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();
}
}
// =============================================================
// Post-Import: Policies pro Stück Include-/Exclude-Gruppen zuweisen
// =============================================================
const PolAssignState = { policies: [] };
const POL_TYPE_LABELS = {
SettingsCatalog: 'Settings Catalog',
CompliancePolicy: 'Compliance',
ConfigurationProfile: 'Konfigurationsprofil',
AdministrativeTemplate: 'Administrative Vorlage',
};
function openPolAssignModal(assignable) {
PolAssignState.policies = assignable.map(r => ({
id: r.newId,
name: r.policyName || '(ohne Name)',
exportType: r.exportType || '',
include: [],
exclude: [],
}));
renderPolAssignList();
openModal('modalPolAssign');
}
function renderPolAssignList() {
const list = document.getElementById('polAssignList');
if (!list) return;
list.innerHTML = PolAssignState.policies.map((p, i) => `
${escapeHtml(p.name)}
${escapeHtml(POL_TYPE_LABELS[p.exportType] || p.exportType || 'Policy')}
${['include', 'exclude'].map(kind => `
${kind === 'include' ? 'Include-Gruppen' : 'Exclude-Gruppen'}
`).join('')}
`).join('');
PolAssignState.policies.forEach((_, i) => {
renderPolAssignChips(i, 'include');
renderPolAssignChips(i, 'exclude');
});
updatePolAssignInfo();
}
function renderPolAssignChips(idx, kind) {
const box = document.querySelector(`.pol-assign-chips[data-idx="${idx}"][data-kind="${kind}"]`);
if (!box) return;
const arr = PolAssignState.policies[idx][kind];
box.innerHTML = arr.map((g, j) => `
${escapeHtml(g.name)}
×
`).join('');
updatePolAssignInfo();
}
function updatePolAssignInfo() {
const info = document.getElementById('polAssignInfo');
if (!info) return;
const n = PolAssignState.policies.filter(p => p.include.length || p.exclude.length).length;
info.textContent = n ? `${n} Policy(s) mit Zuweisung` : 'Keine Zuweisung gewählt';
}
let _polAssignSearchTimer = null;
function polAssignHideDropdowns() {
document.querySelectorAll('.pol-assign-dropdown').forEach(d => { d.classList.add('hidden'); d.innerHTML = ''; });
}
// Suche (debounced) — Event-Delegation auf der Liste.
document.getElementById('polAssignList')?.addEventListener('input', e => {
const input = e.target.closest('.pol-assign-search');
if (!input) return;
const idx = input.dataset.idx, kind = input.dataset.kind;
const dd = document.querySelector(`.pol-assign-dropdown[data-idx="${idx}"][data-kind="${kind}"]`);
const q = input.value.trim();
clearTimeout(_polAssignSearchTimer);
if (q.length < 2) { if (dd) { dd.classList.add('hidden'); dd.innerHTML = ''; } return; }
if (dd) { dd.classList.remove('hidden'); dd.innerHTML = 'Suche…
'; }
_polAssignSearchTimer = setTimeout(async () => {
try {
const data = await api(`/api/groups/search?q=${encodeURIComponent(q)}`);
const items = (data.items || []).slice(0, 25);
if (!dd) return;
if (!items.length) { dd.innerHTML = 'Keine Gruppen gefunden.
'; return; }
dd.innerHTML = items.map(g => `
${escapeHtml(g.DisplayName)}
${g.Description ? `
${escapeHtml(g.Description)}
` : ''}
`).join('');
} catch (err) {
if (dd) dd.innerHTML = `Suche fehlgeschlagen: ${escapeHtml(err.message)}
`;
}
}, 300);
});
// Klick auf ein Suchergebnis -> als Chip hinzufügen. Klick auf Chip-× -> entfernen.
document.getElementById('polAssignList')?.addEventListener('click', e => {
const opt = e.target.closest('.pol-assign-dropdown .opt');
if (opt) {
const dd = opt.closest('.pol-assign-dropdown');
const idx = +dd.dataset.idx, kind = dd.dataset.kind;
const id = opt.dataset.id, name = opt.dataset.name;
const p = PolAssignState.policies[idx];
const other = kind === 'include' ? 'exclude' : 'include';
// Nicht doppelt, und nicht gleichzeitig include+exclude derselben Gruppe.
if (!p[kind].some(g => g.id === id) && !p[other].some(g => g.id === id)) {
p[kind].push({ id, name });
renderPolAssignChips(idx, kind);
}
const input = document.querySelector(`.pol-assign-search[data-idx="${idx}"][data-kind="${kind}"]`);
if (input) input.value = '';
dd.classList.add('hidden'); dd.innerHTML = '';
return;
}
const rm = e.target.closest('.pol-assign-chip button');
if (rm) {
const idx = +rm.dataset.idx, kind = rm.dataset.kind, j = +rm.dataset.j;
PolAssignState.policies[idx][kind].splice(j, 1);
renderPolAssignChips(idx, kind);
}
});
document.addEventListener('click', e => {
if (!e.target.closest('.pol-assign-search-wrap')) polAssignHideDropdowns();
});
document.getElementById('polAssignApply')?.addEventListener('click', async () => {
const items = PolAssignState.policies
.filter(p => p.include.length || p.exclude.length)
.map(p => ({
exportType: p.exportType,
id: p.id,
policyName: p.name,
include: p.include.map(g => g.id),
exclude: p.exclude.map(g => g.id),
}));
if (!items.length) { closeModal('modalPolAssign'); return; }
setLoading('Weise Gruppen zu…');
try {
const res = await api('/api/policies/assign', { method: 'POST', body: { items }, timeoutMs: 120000 });
const results = res.results || [];
const fail = results.filter(r => !r.success);
if (fail.length) {
const first = fail[0];
toast(`${res.assignedCount || 0} zugewiesen, ${fail.length} fehlgeschlagen. z.B. "${first.policyName || '?'}": ${first.error || 'Fehler'}`, 'err', 'Zuweisung');
} else {
toast(`${res.assignedCount || 0} Policy(s) zugewiesen.`, 'ok', 'Zuweisung');
}
closeModal('modalPolAssign');
} catch (e) {
toast('Zuweisung fehlgeschlagen: ' + e.message, 'err');
} finally {
clearLoading();
}
});
// 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();
});
});