Files
wendeIT-scripting/Intune/Intune-App-Manager-Web/www/app.js
T
2026-06-19 09:03:49 +02:00

4610 lines
188 KiB
JavaScript
Raw Blame History

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