Wipe-Button mit eigenem Modal: Checkbox ob Benutzerdaten behalten werden (keepUserData). Suchfeld auf max-width 600px vergrößert. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5515 lines
223 KiB
JavaScript
5515 lines
223 KiB
JavaScript
// =============================================================
|
||
// Intune Manager — Frontend
|
||
// Workflow: Connect → Select Targets → Pick Apps → Apply
|
||
// =============================================================
|
||
|
||
const State = {
|
||
connected: false,
|
||
readOnly: false,
|
||
account: null,
|
||
mode: 'dept', // 'dept' | 'rpa' | 'user'
|
||
targets: [],
|
||
selectedTargetIds: new Set(),
|
||
apps: [],
|
||
appFilter: { search: '', preset: 'all', 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 => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
}[c]));
|
||
}
|
||
|
||
// Markdown -> HTML. Nutzt marked (gebundelt in /vendor/marked.min.js).
|
||
// breaks: true -> einzelne Newlines werden zu <br>, intuitiver fuer User
|
||
// gfm: true -> GitHub-Flavored Markdown (Tabellen, Strikethrough, Autolinks)
|
||
// Wenn marked aus irgendeinem Grund nicht geladen ist, faellt der Helper
|
||
// auf escapeHtml zurueck — kein Crash, nur kein Markdown.
|
||
let markedConfigured = false;
|
||
function renderMarkdown(s) {
|
||
if (s == null) return '';
|
||
const text = String(s);
|
||
if (typeof window.marked === 'undefined') return escapeHtml(text);
|
||
if (!markedConfigured) {
|
||
window.marked.setOptions({ breaks: true, gfm: true });
|
||
markedConfigured = true;
|
||
}
|
||
try { return window.marked.parse(text); }
|
||
catch { return escapeHtml(text); }
|
||
}
|
||
|
||
// =============================================================
|
||
// Loading overlay
|
||
// =============================================================
|
||
|
||
let loadingDepth = 0;
|
||
function setLoading(text) {
|
||
loadingDepth++;
|
||
document.getElementById('loadingText').textContent = text || 'Bitte warten...';
|
||
document.getElementById('loading').classList.remove('hidden');
|
||
}
|
||
function clearLoading() {
|
||
loadingDepth = Math.max(0, loadingDepth - 1);
|
||
if (loadingDepth === 0) document.getElementById('loading').classList.add('hidden');
|
||
}
|
||
|
||
// =============================================================
|
||
// Workflow stepper
|
||
// =============================================================
|
||
|
||
// Stepper wurde entfernt — Funktion bleibt als No-Op, damit die ~12
|
||
// bestehenden Aufrufer keine Errors werfen. Kann irgendwann mit Sed
|
||
// gemeinsam mit den Aufrufstellen rausgesnipt werden.
|
||
function updateStepper() { /* no-op */ }
|
||
|
||
// =============================================================
|
||
// Connection
|
||
// =============================================================
|
||
|
||
function showOnboarding(show) {
|
||
document.getElementById('onboarding').classList.toggle('hidden', !show);
|
||
document.getElementById('layout').classList.toggle('hidden', show);
|
||
if (show) {
|
||
// Reset state-View nur, wenn gerade keine Anmeldung lief
|
||
if (!connectInFlight) showOnboardingState('idle');
|
||
} else {
|
||
stopDevicePolling();
|
||
}
|
||
}
|
||
|
||
async function refreshStatus() {
|
||
try {
|
||
const s = await api('/api/status');
|
||
State.connected = s.connected;
|
||
State.account = s.account;
|
||
State.readOnly = !!s.readOnly;
|
||
applyReadOnlyMode();
|
||
renderConnection();
|
||
} catch (e) {
|
||
toast('Status konnte nicht geladen werden: ' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
function applyReadOnlyMode() {
|
||
document.body.classList.toggle('read-only', State.readOnly);
|
||
// Falls ein Schreib-Tab aktiv ist beim Wechsel in Read-Only -> Export-Tab zeigen
|
||
if (State.readOnly) {
|
||
const activeTab = document.querySelector('.gex-tab.active');
|
||
if (activeTab && (activeTab.dataset.tab === 'add' || activeTab.dataset.tab === 'import')) {
|
||
if (typeof switchGexTab === 'function') switchGexTab('export');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Bootstrap-Icons "person-circle" — Avatar mit eingebautem Kreis-Outline.
|
||
// 20px matches die visuelle Groesse der anderen Header-Icons (cog, logout).
|
||
const PERSON_ICON = `<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0"/><path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1"/></svg>`;
|
||
|
||
function renderConnection() {
|
||
const pill = document.getElementById('connStatus');
|
||
const text = pill.querySelector('.conn-text');
|
||
const btnD = document.getElementById('btnDisconnect');
|
||
|
||
// Avatar ist der Person-Icon — Identifikation des aktiven Accounts laeuft
|
||
// ueber den Tooltip (title-Attribut, hover).
|
||
text.innerHTML = PERSON_ICON;
|
||
|
||
if (State.connected) {
|
||
pill.className = 'conn-pill conn-on';
|
||
pill.title = State.account || 'Verbunden';
|
||
btnD.classList.remove('hidden');
|
||
showOnboarding(false);
|
||
} else {
|
||
pill.className = 'conn-pill conn-off';
|
||
pill.title = 'Nicht verbunden';
|
||
btnD.classList.add('hidden');
|
||
showOnboarding(true);
|
||
}
|
||
updateStepper();
|
||
}
|
||
|
||
// ----------------------------------------------------------------
|
||
// Device-Code-Login: Anmeldung direkt auf der Seite, ohne Popup-
|
||
// Fenster, ohne WAM. Nutzer kann jeden Account angeben.
|
||
// ----------------------------------------------------------------
|
||
|
||
let connectInFlight = false;
|
||
let devicePollTimer = null;
|
||
|
||
function showOnboardingState(view) {
|
||
// view: 'idle' | 'requesting' | 'awaiting' | 'error'
|
||
document.querySelectorAll('[data-onboarding-view]').forEach(el => {
|
||
el.classList.toggle('hidden', el.dataset.onboardingView !== view);
|
||
});
|
||
}
|
||
|
||
function stopDevicePolling() {
|
||
if (devicePollTimer) { clearInterval(devicePollTimer); devicePollTimer = null; }
|
||
}
|
||
|
||
document.getElementById('btnConnect').addEventListener('click', async () => {
|
||
if (connectInFlight) return;
|
||
connectInFlight = true;
|
||
|
||
showOnboardingState('awaiting');
|
||
|
||
// Sekundenzaehler im UI
|
||
const startedAt = Date.now();
|
||
const waitText = document.querySelector('[data-onboarding-view="awaiting"] .onboarding-text');
|
||
const baseText = waitText ? waitText.textContent : '';
|
||
const tickTimer = setInterval(() => {
|
||
if (waitText) {
|
||
const sec = Math.floor((Date.now() - startedAt) / 1000);
|
||
waitText.textContent = `${baseText} (Wartet seit ${sec}s)`;
|
||
}
|
||
}, 1000);
|
||
|
||
// 90s Timeout — Connect-MgGraph blockt typisch 5-30s; 90s ist sicher
|
||
const ctrl = new AbortController();
|
||
const timeoutId = setTimeout(() => ctrl.abort(), 90000);
|
||
|
||
try {
|
||
const resp = await fetch('/api/connect', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
signal: ctrl.signal,
|
||
});
|
||
clearTimeout(timeoutId);
|
||
let s = {};
|
||
try { s = await resp.json(); } catch {}
|
||
if (!resp.ok) throw new Error(s.error || `HTTP ${resp.status}`);
|
||
if (!s.connected) throw new Error('Backend meldet nicht connected');
|
||
|
||
State.connected = true;
|
||
State.account = s.account;
|
||
State.readOnly = !!s.readOnly;
|
||
applyReadOnlyMode();
|
||
renderConnection();
|
||
const roHint = State.readOnly ? ' (Read Only)' : '';
|
||
toast('Verbunden als ' + s.account + roHint, 'ok', 'Erfolgreich');
|
||
autoLoadAfterConnect();
|
||
} catch (e) {
|
||
clearTimeout(timeoutId);
|
||
let msg = e.message;
|
||
if (e.name === 'AbortError') {
|
||
msg = 'Anmeldung dauert über 90s. Möglicherweise ist das Login-Fenster im Hintergrund — prüfe die Taskleiste. Sonst PowerShell-Konsole prüfen.';
|
||
}
|
||
document.getElementById('dcError').textContent = msg;
|
||
showOnboardingState('error');
|
||
toast(msg, 'err', 'Verbindung fehlgeschlagen');
|
||
} finally {
|
||
clearInterval(tickTimer);
|
||
if (waitText) waitText.textContent = baseText;
|
||
connectInFlight = false;
|
||
}
|
||
});
|
||
|
||
document.getElementById('btnCancelConnect')?.addEventListener('click', async () => {
|
||
stopDevicePolling();
|
||
try { await api('/api/connect/cancel', { method: 'POST' }); } catch {}
|
||
showOnboardingState('idle');
|
||
connectInFlight = false;
|
||
});
|
||
|
||
document.getElementById('btnRetryConnect')?.addEventListener('click', () => {
|
||
showOnboardingState('idle');
|
||
connectInFlight = false;
|
||
});
|
||
|
||
document.getElementById('btnDisconnect').addEventListener('click', async () => {
|
||
setLoading('Trenne...');
|
||
stopDevicePolling();
|
||
connectInFlight = false;
|
||
try {
|
||
await api('/api/disconnect', { method: 'POST' });
|
||
Object.assign(State, {
|
||
connected: false, account: null,
|
||
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} Gruppe${deptN === 1 ? '' : 'n'}</span>`);
|
||
if (userN > 0) tags.push(`<span class="scope-tag scope-user">${userN} 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);
|
||
const n = mmContext.selectedIds.size;
|
||
document.getElementById('mmInfo').textContent = n === 0 ? 'Keine ausgewählt' : `${n} ausgewählt`;
|
||
document.getElementById('mmRemove').disabled = n === 0;
|
||
});
|
||
});
|
||
}
|
||
|
||
document.getElementById('mmSearch')?.addEventListener('input', renderMembersList);
|
||
|
||
document.getElementById('mmSelectAll')?.addEventListener('click', () => {
|
||
const q = (document.getElementById('mmSearch').value || '').toLowerCase().trim();
|
||
const items = q
|
||
? mmContext.members.filter(m =>
|
||
(m.DisplayName || '').toLowerCase().includes(q) ||
|
||
(m.UserPrincipalName || '').toLowerCase().includes(q))
|
||
: mmContext.members;
|
||
items.forEach(m => mmContext.selectedIds.add(m.Id));
|
||
renderMembersList();
|
||
document.getElementById('mmInfo').textContent = `${mmContext.selectedIds.size} ausgewählt`;
|
||
});
|
||
|
||
document.getElementById('mmClear')?.addEventListener('click', () => {
|
||
mmContext.selectedIds.clear();
|
||
renderMembersList();
|
||
document.getElementById('mmInfo').textContent = 'Keine ausgewählt';
|
||
document.getElementById('mmRemove').disabled = true;
|
||
});
|
||
|
||
document.getElementById('mmRemove')?.addEventListener('click', async () => {
|
||
const n = mmContext.selectedIds.size;
|
||
if (n === 0) return;
|
||
const names = mmContext.members
|
||
.filter(m => mmContext.selectedIds.has(m.Id))
|
||
.map(m => m.DisplayName || m.UserPrincipalName)
|
||
.slice(0, 5).join('\n');
|
||
const more = n > 5 ? `\n… und ${n - 5} weitere` : '';
|
||
if (!confirm(`${n} Mitglied${n > 1 ? 'er' : ''} aus "${mmContext.groupName}" entfernen?\n\n${names}${more}`)) return;
|
||
|
||
const btn = document.getElementById('mmRemove');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Entferne…';
|
||
|
||
const ids = [...mmContext.selectedIds];
|
||
let ok = 0, fail = 0;
|
||
for (const userId of ids) {
|
||
try {
|
||
await api(`/api/groups/${encodeURIComponent(mmContext.groupId)}/members/${encodeURIComponent(userId)}`, { method: 'DELETE' });
|
||
mmContext.members = mmContext.members.filter(m => m.Id !== userId);
|
||
mmContext.selectedIds.delete(userId);
|
||
ok++;
|
||
} catch (e) {
|
||
fail++;
|
||
console.error('Remove member failed:', userId, e);
|
||
}
|
||
}
|
||
|
||
btn.textContent = 'Aus Gruppe entfernen';
|
||
document.getElementById('mmInfo').textContent = 'Keine ausgewählt';
|
||
renderMembersList();
|
||
|
||
if (fail === 0) toast(`${ok} Mitglied${ok > 1 ? 'er' : ''} entfernt`, 'ok');
|
||
else toast(`${ok} entfernt, ${fail} fehlgeschlagen`, 'warn');
|
||
});
|
||
|
||
document.getElementById('mmApply')?.addEventListener('click', () => {
|
||
if (mmContext.selectedIds.size === 0) {
|
||
toast('Keine Mitglieder ausgewählt', 'warn');
|
||
return;
|
||
}
|
||
|
||
// In den User-Modus wechseln und die ausgewählten Mitglieder als Targets übernehmen
|
||
State.mode = 'user';
|
||
document.querySelectorAll('.seg').forEach(s => s.classList.toggle('active', s.dataset.mode === 'user'));
|
||
|
||
const picked = mmContext.members.filter(m => mmContext.selectedIds.has(m.Id));
|
||
State.targets = picked;
|
||
State.selectedTargetIds = new Set(picked.map(m => m.Id));
|
||
State.membershipCache.clear();
|
||
|
||
const search = document.getElementById('targetSearch');
|
||
search.placeholder = 'Tippe Name oder UPN...';
|
||
document.getElementById('userSearchHint').classList.remove('hidden');
|
||
search.value = '';
|
||
|
||
renderTargets();
|
||
onTargetSelectionChange();
|
||
closeModal('modalMembers');
|
||
toast(`${picked.length} Benutzer aus "${mmContext.groupName}" übernommen`, 'ok');
|
||
});
|
||
|
||
// =============================================================
|
||
// Helpers
|
||
// =============================================================
|
||
|
||
function emptyState({ title, text, small = false, ico = null }) {
|
||
return `<div class="empty-state ${small ? 'small' : ''}">
|
||
${ico ? `<div class="empty-ico">${ico}</div>` : ''}
|
||
${title ? `<div class="empty-title">${escapeHtml(title)}</div>` : ''}
|
||
${text ? `<div class="empty-text">${escapeHtml(text)}</div>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
// =============================================================
|
||
// Hilfe-Modal: laedt www/help.md und rendert es mit marked.
|
||
// TOC wird aus den H2-Headings auto-generiert (mit Anker-IDs).
|
||
// =============================================================
|
||
|
||
let helpLoaded = false;
|
||
|
||
async function openHelpModal() {
|
||
openModal('modalHelp');
|
||
// Version-Badge im Footer setzen (immer aktuell, auch wenn helpLoaded == true)
|
||
try {
|
||
const v = await api('/api/version');
|
||
const el = document.getElementById('helpVersion');
|
||
if (el) el.textContent = `Version ${v.version} · Build ${v.build}`;
|
||
} 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('setClientIdRo').value = c.clientIdRo || '';
|
||
document.getElementById('setScopes').value = (c.scopes || []).join('\n');
|
||
document.getElementById('setScopesRo').value = (c.scopesRo || []).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(),
|
||
clientIdRo: document.getElementById('setClientIdRo').value.trim(),
|
||
scopes: splitLines(document.getElementById('setScopes').value),
|
||
scopesRo: splitLines(document.getElementById('setScopesRo').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');
|
||
switchMainView('groups');
|
||
setTimeout(() => document.getElementById('gexSearch').focus(), 80);
|
||
}
|
||
|
||
function _gexSwitchTab(tab) {
|
||
GexState.activeTab = tab;
|
||
document.querySelectorAll('.gex-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
|
||
document.getElementById('gexPaneExport').classList.toggle('hidden', tab !== 'export');
|
||
document.getElementById('gexPaneAdd').classList.toggle('hidden', tab !== 'add');
|
||
document.getElementById('gexPaneImport').classList.toggle('hidden', tab !== 'import');
|
||
document.getElementById('gexPaneUser').classList.toggle('hidden', tab !== 'user');
|
||
document.getElementById('gexLeft').classList.toggle('hidden', tab === 'user');
|
||
document.getElementById('gexExportBtn').classList.toggle('hidden', tab !== 'export');
|
||
document.getElementById('gexAddBtn').classList.toggle('hidden', tab !== 'add');
|
||
document.getElementById('gexStats').textContent = '';
|
||
if (tab === 'add') setTimeout(() => document.getElementById('gexUserSearch').focus(), 80);
|
||
if (tab === 'user') setTimeout(() => document.getElementById('gexUmSearch').focus(), 80);
|
||
}
|
||
|
||
document.querySelectorAll('.gex-tab').forEach(btn => {
|
||
btn.addEventListener('click', () => _gexSwitchTab(btn.dataset.tab));
|
||
});
|
||
|
||
// Nav-Tabs: App Management / Group Management / App Report
|
||
function switchMainView(view) {
|
||
const layout = document.getElementById('layout');
|
||
const groupView = document.getElementById('groupMgmtView');
|
||
const reportView = document.getElementById('appReportView');
|
||
const devView = document.getElementById('devView');
|
||
document.querySelectorAll('#topbarNav .nav-tab').forEach(t => {
|
||
t.classList.toggle('active', t.dataset.view === view);
|
||
t.setAttribute('aria-selected', t.dataset.view === view ? 'true' : 'false');
|
||
});
|
||
layout .classList.toggle('hidden', view !== 'apps');
|
||
groupView .classList.toggle('hidden', view !== 'groups');
|
||
reportView.classList.toggle('hidden', view !== 'report');
|
||
devView .classList.toggle('hidden', view !== 'devices');
|
||
}
|
||
|
||
document.querySelectorAll('#topbarNav .nav-tab').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const view = btn.dataset.view;
|
||
if ((view === 'groups' || view === 'report' || view === 'devices') && !State.connected) {
|
||
toast('Bitte zuerst verbinden.', 'warn');
|
||
return;
|
||
}
|
||
switchMainView(view);
|
||
if (view === 'groups') openGroupExportModal();
|
||
if (view === 'report' && ReportState.items.length === 0) loadAppReport();
|
||
if (view === 'devices') { devLoadAll(); setTimeout(() => document.getElementById('devSearch').focus(), 80); }
|
||
});
|
||
});
|
||
|
||
// Suche mit Debounce
|
||
let _gexSearchTimer = null;
|
||
document.getElementById('gexSearch')?.addEventListener('input', e => {
|
||
clearTimeout(_gexSearchTimer);
|
||
const q = e.target.value.trim();
|
||
if (q.length < 2) {
|
||
GexState.searchResults = [];
|
||
document.getElementById('gexSearchResults').innerHTML = '<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>';
|
||
return;
|
||
}
|
||
document.getElementById('gexSearchResults').innerHTML = '<div class="gex-loading"><div class="spinner" style="width:14px;height:14px;border-width:2px;"></div><span>Suche…</span></div>';
|
||
_gexSearchTimer = setTimeout(() => _gexDoSearch(q), 350);
|
||
});
|
||
|
||
async function _gexDoSearch(q) {
|
||
try {
|
||
const data = await api(`/api/groups/search?q=${encodeURIComponent(q)}`);
|
||
GexState.searchResults = data.items || [];
|
||
_gexRenderResults();
|
||
} catch (e) {
|
||
document.getElementById('gexSearchResults').innerHTML = `<div class="gex-error">Suche fehlgeschlagen: ${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function _gexRenderResults() {
|
||
const el = document.getElementById('gexSearchResults');
|
||
if (!el) return;
|
||
const items = GexState.searchResults;
|
||
if (items.length === 0) {
|
||
el.innerHTML = '<div class="gex-empty">Keine Gruppen gefunden.</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = items.map(g => `
|
||
<div class="gex-group-item ${GexState.selectedGroup?.Id === g.Id ? 'selected' : ''}"
|
||
data-id="${escapeHtml(g.Id)}" data-name="${escapeHtml(g.DisplayName)}">
|
||
<div class="gex-group-name">${escapeHtml(g.DisplayName)}</div>
|
||
${g.Description ? `<div class="gex-group-desc">${escapeHtml(g.Description)}</div>` : ''}
|
||
</div>
|
||
`).join('');
|
||
el.querySelectorAll('.gex-group-item').forEach(item => {
|
||
item.addEventListener('click', () => _gexSelectGroup(item.dataset.id, item.dataset.name));
|
||
});
|
||
}
|
||
|
||
async function _gexSelectGroup(id, name) {
|
||
GexState.selectedGroup = { Id: id, DisplayName: name };
|
||
GexState.members = null;
|
||
GexState.memberFilter = '';
|
||
_gexRenderResults(); // aktualisiert selected-Highlight
|
||
_gexUpdateAddBtn();
|
||
notifyCsvImportGroupSelected(id, name);
|
||
|
||
if (GexState.activeTab !== 'export') return;
|
||
|
||
document.getElementById('gexMemberFilter').value = '';
|
||
document.getElementById('gexMembersTitle').textContent = `Mitglieder — ${name}`;
|
||
document.getElementById('gexStats').textContent = '';
|
||
document.getElementById('gexExportBtn').disabled = true;
|
||
document.getElementById('gexMembersArea').innerHTML =
|
||
'<div class="gex-loading"><div class="spinner" style="width:16px;height:16px;border-width:2px;"></div><span>Mitglieder werden aufgelöst (inkl. verschachtelte Gruppen)…</span></div>';
|
||
|
||
try {
|
||
const data = await api(`/api/groups/${encodeURIComponent(id)}/members/export`);
|
||
GexState.members = data;
|
||
_gexRenderMembers();
|
||
document.getElementById('gexExportBtn').disabled = data.count === 0;
|
||
let stats = `${data.count} Benutzer`;
|
||
if (data.duplicates > 0) stats += ` · ${data.duplicates} Duplikate aus Verschachtelung entfernt`;
|
||
stats += ` · ${data.resolvedMs}ms`;
|
||
document.getElementById('gexStats').textContent = stats;
|
||
} catch (e) {
|
||
document.getElementById('gexMembersArea').innerHTML =
|
||
`<div class="gex-error">Fehler beim Laden: ${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
// Live-Filter für die Mitgliederliste
|
||
document.getElementById('gexMemberFilter')?.addEventListener('input', e => {
|
||
GexState.memberFilter = e.target.value.trim().toLowerCase();
|
||
_gexApplyFilter();
|
||
});
|
||
|
||
function _gexApplyFilter() {
|
||
const q = GexState.memberFilter;
|
||
document.querySelectorAll('#gexMembersArea .gex-user-item').forEach(row => {
|
||
const text = row.dataset.search || '';
|
||
row.classList.toggle('hidden-filter', q !== '' && !text.includes(q));
|
||
});
|
||
// Pfad-Gruppen ohne sichtbare Zeilen ausblenden
|
||
document.querySelectorAll('#gexMembersArea .gex-path-group').forEach(pg => {
|
||
const visible = pg.querySelectorAll('.gex-user-item:not(.hidden-filter)').length > 0;
|
||
pg.style.display = visible ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function _gexRenderMembers() {
|
||
const area = document.getElementById('gexMembersArea');
|
||
if (!area || !GexState.members) return;
|
||
const m = GexState.members;
|
||
|
||
if (m.count === 0) {
|
||
area.innerHTML = '<div class="gex-empty">Keine Benutzer in dieser Gruppe gefunden.</div>';
|
||
return;
|
||
}
|
||
|
||
// Nach SourcePath gruppieren
|
||
const byPath = new Map();
|
||
for (const u of m.users) {
|
||
const p = u.SourcePath || m.groupName;
|
||
if (!byPath.has(p)) byPath.set(p, []);
|
||
byPath.get(p).push(u);
|
||
}
|
||
|
||
let html = `<div class="gex-summary">${m.count} Benutzer`;
|
||
if (m.duplicates > 0) html += ` (${m.duplicates} Duplikate aus verschachtelten Gruppen zusammengeführt)`;
|
||
html += '</div>';
|
||
|
||
const directGroupName = m.groupName || '';
|
||
for (const [path, users] of byPath.entries()) {
|
||
const isDirect = path === directGroupName;
|
||
html += `<div class="gex-path-group">
|
||
<div class="gex-path-head">${escapeHtml(path)} <span class="count-badge">${users.length}</span></div>
|
||
<div class="gex-user-list">`;
|
||
for (const u of users) {
|
||
const upn = u.UserPrincipalName || u.Mail || u.Id;
|
||
const searchKey = `${(u.DisplayName || '').toLowerCase()} ${upn.toLowerCase()}`;
|
||
const cbx = isDirect
|
||
? `<input type="checkbox" class="gex-member-chk" data-uid="${escapeHtml(u.Id)}" data-name="${escapeHtml(u.DisplayName || upn)}">`
|
||
: `<span style="width:14px;flex-shrink:0;"></span>`;
|
||
html += `<label class="gex-user-item" data-search="${escapeHtml(searchKey)}">
|
||
${cbx}
|
||
<span class="gex-user-name">${escapeHtml(u.DisplayName || '—')}</span>
|
||
<span class="gex-user-upn">${escapeHtml(upn)}</span>
|
||
</label>`;
|
||
}
|
||
html += '</div></div>';
|
||
}
|
||
|
||
area.innerHTML = html;
|
||
if (GexState.memberFilter) _gexApplyFilter();
|
||
|
||
// Checkboxen verdrahten
|
||
const removeBtn = document.getElementById('gexRemoveBtn');
|
||
if (removeBtn) removeBtn.style.display = '';
|
||
area.querySelectorAll('.gex-member-chk').forEach(chk => {
|
||
chk.addEventListener('change', () => {
|
||
const n = area.querySelectorAll('.gex-member-chk:checked').length;
|
||
if (removeBtn) {
|
||
removeBtn.disabled = n === 0;
|
||
removeBtn.textContent = n > 0 ? `${n} Mitglied${n > 1 ? 'er' : ''} entfernen` : 'Aus Gruppe entfernen';
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---- Mitglieder entfernen ----
|
||
document.getElementById('gexRemoveBtn')?.addEventListener('click', async () => {
|
||
const area = document.getElementById('gexMembersArea');
|
||
const checked = [...(area?.querySelectorAll('.gex-member-chk:checked') || [])];
|
||
if (!checked.length || !GexState.selectedGroup) return;
|
||
|
||
const names = checked.slice(0, 5).map(c => c.dataset.name).join('\n');
|
||
const more = checked.length > 5 ? `\n… und ${checked.length - 5} weitere` : '';
|
||
if (!confirm(`${checked.length} Mitglied${checked.length > 1 ? 'er' : ''} aus "${GexState.selectedGroup.DisplayName}" entfernen?\n\n${names}${more}`)) return;
|
||
|
||
const btn = document.getElementById('gexRemoveBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Entferne…';
|
||
const groupId = GexState.selectedGroup.Id;
|
||
|
||
let ok = 0, fail = 0;
|
||
for (const chk of checked) {
|
||
try {
|
||
await api(`/api/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(chk.dataset.uid)}`, { method: 'DELETE' });
|
||
chk.closest('label').remove();
|
||
ok++;
|
||
} catch (e) {
|
||
fail++;
|
||
console.error('Remove failed:', chk.dataset.uid, e);
|
||
}
|
||
}
|
||
|
||
btn.textContent = 'Aus Gruppe entfernen';
|
||
btn.disabled = true;
|
||
if (fail === 0) toast(`${ok} Mitglied${ok > 1 ? 'er' : ''} entfernt`, 'ok');
|
||
else toast(`${ok} entfernt, ${fail} fehlgeschlagen`, 'warn');
|
||
|
||
// Mitgliederzahl aktualisieren
|
||
if (GexState.members) {
|
||
GexState.members.count = Math.max(0, GexState.members.count - ok);
|
||
document.getElementById('gexStats').textContent = `${GexState.members.count} Benutzer`;
|
||
}
|
||
});
|
||
|
||
// ---- Benutzer → Gruppen (User-Tab) ----
|
||
(function () {
|
||
let umDebounce = null;
|
||
let umSelected = null; // { Id, DisplayName, UserPrincipalName }
|
||
|
||
const searchInput = document.getElementById('gexUmSearch');
|
||
const resultsDiv = document.getElementById('gexUmResults');
|
||
const groupsList = document.getElementById('gexUmGroupsList');
|
||
const groupsTitle = document.getElementById('gexUmGroupsTitle');
|
||
const groupCount = document.getElementById('gexUmGroupCount');
|
||
const filterWrap = document.getElementById('gexUmFilterWrap');
|
||
const filterInput = document.getElementById('gexUmFilter');
|
||
|
||
searchInput.addEventListener('input', () => {
|
||
clearTimeout(umDebounce);
|
||
const q = searchInput.value.trim();
|
||
if (q.length < 2) {
|
||
resultsDiv.innerHTML = '<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>';
|
||
return;
|
||
}
|
||
umDebounce = setTimeout(() => _umSearchUser(q), 320);
|
||
});
|
||
|
||
async function _umSearchUser(q) {
|
||
resultsDiv.innerHTML = '<div class="gex-hint">Suche…</div>';
|
||
try {
|
||
const data = await api(`/api/users?q=${encodeURIComponent(q)}`);
|
||
if (!data.items || data.items.length === 0) {
|
||
resultsDiv.innerHTML = '<div class="gex-hint">Keine Benutzer gefunden.</div>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
for (const u of data.items) {
|
||
const upn = u.UserPrincipalName || u.Mail || u.Id;
|
||
html += `<div class="gex-result-item" data-uid="${escapeHtml(u.Id)}"
|
||
data-name="${escapeHtml(u.DisplayName || upn)}"
|
||
data-upn="${escapeHtml(upn)}">
|
||
<span class="gex-user-name">${escapeHtml(u.DisplayName || '—')}</span>
|
||
<span class="gex-user-upn">${escapeHtml(upn)}</span>
|
||
</div>`;
|
||
}
|
||
resultsDiv.innerHTML = html;
|
||
resultsDiv.querySelectorAll('.gex-result-item').forEach(el => {
|
||
el.addEventListener('click', () => _umSelectUser({
|
||
Id: el.dataset.uid,
|
||
DisplayName: el.dataset.name,
|
||
UserPrincipalName: el.dataset.upn
|
||
}));
|
||
});
|
||
} catch (e) {
|
||
resultsDiv.innerHTML = `<div class="gex-hint error">Fehler: ${escapeHtml(String(e))}</div>`;
|
||
}
|
||
}
|
||
|
||
async function _umSelectUser(user) {
|
||
umSelected = user;
|
||
searchInput.value = user.DisplayName || user.UserPrincipalName;
|
||
resultsDiv.innerHTML = '';
|
||
groupsList.innerHTML = '<div class="gex-hint">Lade Gruppen…</div>';
|
||
groupsTitle.style.display = '';
|
||
filterWrap.style.display = '';
|
||
filterInput.value = '';
|
||
groupCount.textContent = '…';
|
||
|
||
try {
|
||
const data = await api(`/api/users/${encodeURIComponent(user.Id)}/memberof`);
|
||
groupCount.textContent = data.count;
|
||
if (!data.groups || data.groups.length === 0) {
|
||
groupsList.innerHTML = '<div class="gex-hint">Keine Gruppen gefunden.</div>';
|
||
filterWrap.style.display = 'none';
|
||
return;
|
||
}
|
||
_umRenderGroups(data.groups);
|
||
} catch (e) {
|
||
groupsList.innerHTML = `<div class="gex-hint error">Fehler: ${escapeHtml(String(e))}</div>`;
|
||
}
|
||
}
|
||
|
||
function _umRenderGroups(groups) {
|
||
let html = '';
|
||
for (const g of groups) {
|
||
const desc = g.Description ? `<span class="gex-user-upn">${escapeHtml(g.Description)}</span>` : '';
|
||
const badge = `<span class="gex-type-badge">${escapeHtml(g.Type)}</span>`;
|
||
html += `<div class="gex-group-row" data-search="${escapeHtml((g.DisplayName || '').toLowerCase())} ${escapeHtml((g.Description || '').toLowerCase())}">
|
||
<span class="gex-user-name">${escapeHtml(g.DisplayName || '—')}</span>
|
||
${desc}
|
||
${badge}
|
||
</div>`;
|
||
}
|
||
groupsList.innerHTML = html;
|
||
}
|
||
|
||
filterInput.addEventListener('input', () => {
|
||
const q = filterInput.value.toLowerCase();
|
||
groupsList.querySelectorAll('.gex-group-row').forEach(row => {
|
||
row.classList.toggle('hidden-filter', q.length > 0 && !row.dataset.search.includes(q));
|
||
});
|
||
});
|
||
})();
|
||
|
||
// ---- Benutzer-Suche im Add-Tab ----
|
||
|
||
let _gexUserSearchTimer = null;
|
||
document.getElementById('gexUserSearch')?.addEventListener('input', e => {
|
||
clearTimeout(_gexUserSearchTimer);
|
||
const q = e.target.value.trim();
|
||
if (q.length < 2) {
|
||
GexState.userSearchResults = [];
|
||
document.getElementById('gexUserResults').innerHTML = '<div class="gex-hint">Mindestens 2 Zeichen eingeben.</div>';
|
||
return;
|
||
}
|
||
document.getElementById('gexUserResults').innerHTML = '<div class="gex-loading"><div class="spinner" style="width:14px;height:14px;border-width:2px;"></div><span>Suche…</span></div>';
|
||
_gexUserSearchTimer = setTimeout(() => _gexDoUserSearch(q), 350);
|
||
});
|
||
|
||
async function _gexDoUserSearch(q) {
|
||
try {
|
||
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');
|
||
});
|
||
|
||
// =============================================================
|
||
// APP REPORT VIEW
|
||
// =============================================================
|
||
|
||
const ReportState = {
|
||
items: [], // alle geladenen Items
|
||
filtered: [], // nach Suche gefiltert
|
||
sortCol: 'AppName',
|
||
sortDir: 1, // 1 = asc, -1 = desc
|
||
};
|
||
|
||
async function loadAppReport() {
|
||
if (!State.connected) { toast('Bitte zuerst verbinden.', 'warn'); return; }
|
||
const tbody = document.getElementById('reportBody');
|
||
tbody.innerHTML = `<tr><td colspan="9" class="report-hint"><div class="spinner spinner-sm" style="display:inline-block;margin-right:6px"></div>Lade Install-Statistiken… (kann 30–60 s dauern)</td></tr>`;
|
||
document.getElementById('btnReportExport').disabled = true;
|
||
document.getElementById('btnReportRefresh').disabled = true;
|
||
try {
|
||
const res = await api('/api/apps/report');
|
||
ReportState.items = res.items || [];
|
||
applyReportFilter();
|
||
toast(`${ReportState.items.length} Apps geladen`, 'ok');
|
||
} catch (e) {
|
||
tbody.innerHTML = `<tr><td colspan="9" class="report-hint" style="color:var(--error)">${escapeHtml(e.message)}</td></tr>`;
|
||
toast(e.message, 'err', 'Report fehlgeschlagen');
|
||
} finally {
|
||
document.getElementById('btnReportRefresh').disabled = false;
|
||
}
|
||
}
|
||
|
||
function applyReportFilter() {
|
||
const q = (document.getElementById('reportSearch')?.value || '').toLowerCase().trim();
|
||
ReportState.filtered = q
|
||
? ReportState.items.filter(r =>
|
||
(r.AppName || '').toLowerCase().includes(q) ||
|
||
(r.Publisher || '').toLowerCase().includes(q) ||
|
||
(r.AppType || '').toLowerCase().includes(q))
|
||
: ReportState.items.slice();
|
||
sortReport();
|
||
}
|
||
|
||
function sortReport() {
|
||
const col = ReportState.sortCol;
|
||
const dir = ReportState.sortDir;
|
||
const numCols = new Set(['InstalledDeviceCount','PendingInstallDeviceCount','FailedDeviceCount','NotApplicableDeviceCount']);
|
||
ReportState.filtered.sort((a, b) => {
|
||
let av = a[col] ?? '', bv = b[col] ?? '';
|
||
if (numCols.has(col)) return dir * (Number(av) - Number(bv));
|
||
return dir * String(av).localeCompare(String(bv), 'de', { sensitivity: 'base' });
|
||
});
|
||
renderReportTable();
|
||
}
|
||
|
||
function renderReportTable() {
|
||
const tbody = document.getElementById('reportBody');
|
||
const count = document.getElementById('reportCount');
|
||
count.textContent = ReportState.filtered.length;
|
||
document.getElementById('btnReportExport').disabled = ReportState.filtered.length === 0;
|
||
|
||
// Sort-Icons aktualisieren
|
||
document.querySelectorAll('#reportTable th.sortable').forEach(th => {
|
||
const ico = th.querySelector('.sort-ico');
|
||
if (!ico) return;
|
||
if (th.dataset.col === ReportState.sortCol) {
|
||
ico.textContent = ReportState.sortDir === 1 ? ' ▲' : ' ▼';
|
||
} else {
|
||
ico.textContent = '';
|
||
}
|
||
});
|
||
|
||
if (ReportState.filtered.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="9" class="report-hint">Keine Apps gefunden.</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = ReportState.filtered.map(r => {
|
||
const failCls = r.FailedDeviceCount > 0 ? 'report-fail' : '';
|
||
return `<tr>
|
||
<td class="report-name" title="${escapeHtml(r.AppName)}">${escapeHtml(r.AppName)}</td>
|
||
<td class="report-pub">${escapeHtml(r.Publisher || '—')}</td>
|
||
<td>${escapeHtml(r.AppType || '—')}</td>
|
||
<td>${escapeHtml(r.Version || '—')}</td>
|
||
<td class="num report-ok">${r.InstalledDeviceCount}</td>
|
||
<td class="num report-pending">${r.PendingInstallDeviceCount}</td>
|
||
<td class="num ${failCls}">${r.FailedDeviceCount}</td>
|
||
<td class="num">${r.NotApplicableDeviceCount}</td>
|
||
<td class="report-export-cell">
|
||
<button class="btn-report-device-export" data-appid="${escapeHtml(r.AppId)}" data-appname="${escapeHtml(r.AppName)}" title="Installationsstatus aller Geräte als CSV herunterladen">Geräte-Export</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
// Delegierter Click-Handler für alle Export-Buttons
|
||
tbody.querySelectorAll('.btn-report-device-export').forEach(btn => {
|
||
btn.addEventListener('click', () => exportDeviceStatusCsv(btn.dataset.appid, btn.dataset.appname, btn));
|
||
});
|
||
}
|
||
|
||
async function exportDeviceStatusCsv(appId, appName, btn) {
|
||
const orig = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = 'Lade…';
|
||
try {
|
||
const res = await api(`/api/apps/devicestatus?appId=${encodeURIComponent(appId)}`);
|
||
const items = res.items || [];
|
||
if (items.length === 0) { toast('Keine Gerätedaten gefunden.', 'warn'); return; }
|
||
const cols = ['DeviceName','UserName','InstallState','InstallStateDetail','ErrorCode','OsVersion','LastSyncDateTime'];
|
||
const headers = ['Gerätename','Benutzer','Status','Status-Detail','Fehlercode','OS-Version','Letzte Synchronisierung'];
|
||
const rows = [headers, ...items.map(r => cols.map(c => r[c] ?? ''))];
|
||
const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\r\n');
|
||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const safe = appName.replace(/[\\/:*?"<>|]/g, '_').slice(0, 60);
|
||
const a = Object.assign(document.createElement('a'), { href: url, download: `devices-${safe}-${new Date().toISOString().slice(0,10)}.csv` });
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
toast(`${items.length} Geräte exportiert`, 'ok');
|
||
} catch (e) {
|
||
toast(e.message, 'err', 'Export fehlgeschlagen');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = orig;
|
||
}
|
||
}
|
||
|
||
function exportReportCsv() {
|
||
const cols = ['AppName','Publisher','AppType','Version','InstalledDeviceCount','PendingInstallDeviceCount','FailedDeviceCount','NotApplicableDeviceCount'];
|
||
const headers = ['App-Name','Publisher','Typ','Version','Installiert','Ausstehend','Fehlgeschlagen','Nicht anwendbar'];
|
||
const rows = [headers, ...ReportState.filtered.map(r => cols.map(c => r[c] ?? ''))];
|
||
const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\r\n');
|
||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = Object.assign(document.createElement('a'), { href: url, download: `app-report-${new Date().toISOString().slice(0,10)}.csv` });
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
toast(`${ReportState.filtered.length} Apps exportiert`, 'ok');
|
||
}
|
||
|
||
document.getElementById('btnReportRefresh')?.addEventListener('click', loadAppReport);
|
||
document.getElementById('btnReportExport')?.addEventListener('click', exportReportCsv);
|
||
|
||
document.getElementById('reportSearch')?.addEventListener('input', applyReportFilter);
|
||
|
||
document.querySelectorAll('#reportTable th.sortable').forEach(th => {
|
||
th.addEventListener('click', () => {
|
||
const col = th.dataset.col;
|
||
if (ReportState.sortCol === col) {
|
||
ReportState.sortDir *= -1;
|
||
} else {
|
||
ReportState.sortCol = col;
|
||
ReportState.sortDir = col.includes('Count') ? -1 : 1;
|
||
}
|
||
sortReport();
|
||
});
|
||
});
|
||
|
||
// =============================================================
|
||
// CSV-IMPORT: Benutzer per UPN/CSV in Gruppe importieren
|
||
// =============================================================
|
||
|
||
const CsvImport = {
|
||
resolved: [], // Ergebnis des letzten Resolve-Calls
|
||
groupId: '',
|
||
groupName: '',
|
||
};
|
||
|
||
// Datei-Upload -> Textarea füllen
|
||
document.getElementById('csvImportFile')?.addEventListener('change', function () {
|
||
const file = this.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = e => {
|
||
document.getElementById('csvImportText').value = e.target.result;
|
||
this.value = '';
|
||
};
|
||
reader.readAsText(file, 'utf-8');
|
||
});
|
||
|
||
// UPNs aus Textarea extrahieren (1. Spalte, Header erkennen)
|
||
function splitCsvLine(line) {
|
||
// Einfacher CSV-Splitter der quoted fields unterstützt
|
||
const fields = [];
|
||
let cur = '', inQ = false;
|
||
for (let i = 0; i < line.length; i++) {
|
||
const ch = line[i];
|
||
if (ch === '"') { inQ = !inQ; continue; }
|
||
if (!inQ && (ch === ',' || ch === ';' || ch === '\t')) { fields.push(cur); cur = ''; continue; }
|
||
cur += ch;
|
||
}
|
||
fields.push(cur);
|
||
return fields.map(f => f.trim());
|
||
}
|
||
|
||
function parseCsvUpns(raw) {
|
||
const lines = raw.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||
if (lines.length === 0) return [];
|
||
|
||
// Header-Zeile: prüfen ob erste Zeile Spaltennamen enthält
|
||
const UPN_HEADERS = ['userprincipalname', 'upn', 'mail', 'email', 'e-mail', 'emailaddress'];
|
||
const firstFields = splitCsvLine(lines[0]).map(f => f.toLowerCase());
|
||
let colIdx = -1;
|
||
|
||
// Suche in Header nach bekannten UPN-Spaltenbezeichnungen
|
||
for (const h of UPN_HEADERS) {
|
||
const idx = firstFields.indexOf(h);
|
||
if (idx !== -1) { colIdx = idx; break; }
|
||
}
|
||
|
||
const startLine = colIdx !== -1 ? 1 : 0; // Header überspringen wenn gefunden
|
||
if (colIdx === -1) {
|
||
// Kein Header erkannt: erste Spalte die ein @ enthält verwenden
|
||
// oder Spalte 0 als Fallback
|
||
const firstDataFields = splitCsvLine(lines[0]);
|
||
colIdx = firstDataFields.findIndex(f => f.includes('@'));
|
||
if (colIdx === -1) colIdx = 0;
|
||
}
|
||
|
||
const upns = [];
|
||
for (let i = startLine; i < lines.length; i++) {
|
||
const fields = splitCsvLine(lines[i]);
|
||
const val = (fields[colIdx] || '').trim();
|
||
if (!val) continue;
|
||
if (!val.includes('@')) continue; // muss @ enthalten (UPN/E-Mail)
|
||
upns.push(val);
|
||
}
|
||
return [...new Set(upns)];
|
||
}
|
||
|
||
// Resolve-Button
|
||
document.getElementById('csvResolveBtn')?.addEventListener('click', async () => {
|
||
const raw = document.getElementById('csvImportText').value;
|
||
const upns = parseCsvUpns(raw);
|
||
if (upns.length === 0) { toast('Keine gültigen UPNs/E-Mails gefunden.', 'warn'); return; }
|
||
|
||
const btn = document.getElementById('csvResolveBtn');
|
||
const stats = document.getElementById('csvResolveStats');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Löse auf…';
|
||
stats.textContent = `${upns.length} Einträge werden aufgelöst…`;
|
||
|
||
try {
|
||
const res = await api('/api/groups/resolve-upns', { method: 'POST', body: { upns } });
|
||
CsvImport.resolved = res.items || [];
|
||
renderCsvPreview();
|
||
stats.textContent = `${res.found} gefunden, ${res.notFound} nicht gefunden`;
|
||
} catch (e) {
|
||
toast(e.message, 'err', 'Auflösung fehlgeschlagen');
|
||
stats.textContent = '';
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Benutzer auflösen';
|
||
}
|
||
});
|
||
|
||
function renderCsvPreview() {
|
||
const wrap = document.getElementById('csvPreviewWrap');
|
||
const tbody = document.getElementById('csvPreviewBody');
|
||
const foundBadge = document.getElementById('csvFoundBadge');
|
||
const notFoundBadge = document.getElementById('csvNotFoundBadge');
|
||
const importBtn = document.getElementById('csvImportBtn');
|
||
|
||
const found = CsvImport.resolved.filter(r => r.Found);
|
||
const notFound = CsvImport.resolved.filter(r => !r.Found);
|
||
|
||
foundBadge.textContent = `${found.length} gefunden`;
|
||
notFoundBadge.textContent = `${notFound.length} nicht gefunden`;
|
||
wrap.classList.remove('hidden');
|
||
|
||
tbody.innerHTML = CsvImport.resolved.map(r => {
|
||
const cls = r.Found ? 'csv-row-ok' : 'csv-row-err';
|
||
const ico = r.Found ? '✓' : '✗';
|
||
const icoCls = r.Found ? 'csv-ico-ok' : 'csv-ico-err';
|
||
return `<tr class="${cls}">
|
||
<td class="${icoCls}">${ico}</td>
|
||
<td class="csv-upn">${escapeHtml(r.Upn)}</td>
|
||
<td class="csv-name">${escapeHtml(r.DisplayName || '—')}</td>
|
||
<td class="csv-status">${r.Found ? escapeHtml(r.Mail || r.Upn) : 'Nicht gefunden'}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
importBtn.disabled = found.length === 0 || !CsvImport.groupId;
|
||
updateCsvImportNote();
|
||
}
|
||
|
||
function updateCsvImportNote() {
|
||
const note = document.getElementById('csvImportNote');
|
||
const importBtn = document.getElementById('csvImportBtn');
|
||
const found = CsvImport.resolved.filter(r => r.Found).length;
|
||
if (!CsvImport.groupId) {
|
||
note.textContent = 'Gruppe links auswählen, dann Benutzer auflösen.';
|
||
importBtn.disabled = true;
|
||
} else if (found === 0) {
|
||
note.textContent = `Gruppe: ${CsvImport.groupName} — keine aufgelösten Benutzer.`;
|
||
importBtn.disabled = true;
|
||
} else {
|
||
note.textContent = `Gruppe: ${CsvImport.groupName}`;
|
||
importBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// Import-Button: gefundene User zur Gruppe hinzufügen
|
||
document.getElementById('csvImportBtn')?.addEventListener('click', async () => {
|
||
const found = CsvImport.resolved.filter(r => r.Found);
|
||
if (!CsvImport.groupId || found.length === 0) return;
|
||
|
||
const btn = document.getElementById('csvImportBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Importiere…';
|
||
|
||
try {
|
||
const userIds = found.map(r => r.UserId);
|
||
const res = await api(`/api/groups/${encodeURIComponent(CsvImport.groupId)}/members`, {
|
||
method: 'POST',
|
||
body: { userIds }
|
||
});
|
||
let cntAdded = 0, cntExist = 0, cntErr = 0;
|
||
const notFound = CsvImport.resolved.filter(r => !r.Found).length;
|
||
|
||
// Status in Vorschau aktualisieren
|
||
if (res.results) {
|
||
const map = {};
|
||
for (const r of res.results) map[r.userId] = r;
|
||
document.querySelectorAll('#csvPreviewBody tr').forEach((tr, idx) => {
|
||
const item = CsvImport.resolved[idx];
|
||
if (!item?.Found) return;
|
||
const entry = map[item.UserId] || {};
|
||
const st = entry.status || 'Error';
|
||
const errMsg = entry.message || '';
|
||
let icoCls, ico, label;
|
||
if (st === 'Success') { icoCls = 'csv-ico-done'; ico = '✔'; label = 'Hinzugefügt'; cntAdded++; }
|
||
else if (st === 'AlreadyMember') { icoCls = 'csv-ico-warn'; ico = '≈'; label = 'Bereits Mitglied'; cntExist++; }
|
||
else { icoCls = 'csv-ico-err'; ico = '✗'; label = errMsg || 'Fehler'; cntErr++; }
|
||
tr.querySelector('td:first-child').className = icoCls;
|
||
tr.querySelector('td:first-child').textContent = ico;
|
||
tr.querySelector('.csv-status').textContent = label;
|
||
tr.className = st === 'Success' ? 'csv-row-done' : st === 'AlreadyMember' ? 'csv-row-warn' : 'csv-row-err';
|
||
});
|
||
}
|
||
|
||
// Ergebnis-Banner anzeigen
|
||
const banner = document.getElementById('csvResultBanner');
|
||
banner.classList.remove('hidden');
|
||
document.getElementById('csvResAdded').textContent = `${cntAdded} hinzugefügt`;
|
||
document.getElementById('csvResExist').textContent = `${cntExist} bereits Mitglied`;
|
||
document.getElementById('csvResError').textContent = `${cntErr} Fehler`;
|
||
document.getElementById('csvResSkipped').textContent = `${notFound} nicht gefunden`;
|
||
document.getElementById('csvResAdded').classList.toggle('hidden', cntAdded === 0);
|
||
document.getElementById('csvResExist').classList.toggle('hidden', cntExist === 0);
|
||
document.getElementById('csvResError').classList.toggle('hidden', cntErr === 0);
|
||
document.getElementById('csvResSkipped').classList.toggle('hidden', notFound === 0);
|
||
|
||
const tone = cntErr > 0 ? 'warn' : 'ok';
|
||
toast(`Import abgeschlossen: ${cntAdded} hinzugefügt, ${cntExist} bereits Mitglied${cntErr > 0 ? `, ${cntErr} Fehler` : ''}`, tone);
|
||
} catch (e) {
|
||
toast(e.message, 'err', 'Import fehlgeschlagen');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Gefundene importieren';
|
||
}
|
||
});
|
||
|
||
function notifyCsvImportGroupSelected(groupId, groupName) {
|
||
CsvImport.groupId = groupId;
|
||
CsvImport.groupName = groupName;
|
||
if (CsvImport.resolved.length > 0) renderCsvPreview();
|
||
else updateCsvImportNote();
|
||
}
|
||
|
||
// ============================================================
|
||
// Geräte-Tab
|
||
// ============================================================
|
||
const DevState = {
|
||
items: [],
|
||
filtered: [],
|
||
selected: null,
|
||
sortCol: 'deviceName',
|
||
sortAsc: true,
|
||
loaded: false
|
||
};
|
||
|
||
async function devLoadAll() {
|
||
if (DevState.loaded) return;
|
||
devShowLoading(true);
|
||
try {
|
||
const data = await api('/api/devices');
|
||
DevState.items = data.items || [];
|
||
DevState.loaded = true;
|
||
devApplyFilters();
|
||
} catch (e) {
|
||
toast('Geräte konnten nicht geladen werden: ' + e, 'error');
|
||
} finally {
|
||
devShowLoading(false);
|
||
}
|
||
}
|
||
|
||
function devShowLoading(on) {
|
||
document.getElementById('devLoading').classList.toggle('hidden', !on);
|
||
document.getElementById('devEmpty').classList.add('hidden');
|
||
}
|
||
|
||
function devApplyFilters() {
|
||
const q = document.getElementById('devSearch').value.toLowerCase();
|
||
const os = document.getElementById('devOsFilter').value;
|
||
const cmp = document.getElementById('devComplianceFilter').value;
|
||
|
||
DevState.filtered = DevState.items.filter(d => {
|
||
if (os && d.OS !== os) return false;
|
||
if (cmp && d.ComplianceState !== cmp) return false;
|
||
if (q) {
|
||
const hay = `${d.DeviceName} ${d.UserDisplayName} ${d.UserPrincipalName} ${d.SerialNumber}`.toLowerCase();
|
||
if (!hay.includes(q)) return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
devSort();
|
||
}
|
||
|
||
function devSort() {
|
||
const col = DevState.sortCol;
|
||
const asc = DevState.sortAsc;
|
||
DevState.filtered.sort((a, b) => {
|
||
const av = (a[col] || '').toString().toLowerCase();
|
||
const bv = (b[col] || '').toString().toLowerCase();
|
||
return asc ? av.localeCompare(bv) : bv.localeCompare(av);
|
||
});
|
||
devRender();
|
||
}
|
||
|
||
function devRender() {
|
||
const tbody = document.getElementById('devTableBody');
|
||
const empty = document.getElementById('devEmpty');
|
||
document.getElementById('devCount').textContent = DevState.filtered.length;
|
||
|
||
if (DevState.filtered.length === 0) {
|
||
tbody.innerHTML = '';
|
||
empty.classList.remove('hidden');
|
||
return;
|
||
}
|
||
empty.classList.add('hidden');
|
||
|
||
// Sort-Icons
|
||
document.querySelectorAll('#devTable th.sortable').forEach(th => {
|
||
const ico = th.querySelector('.sort-ico');
|
||
if (th.dataset.col === DevState.sortCol) {
|
||
ico.textContent = DevState.sortAsc ? ' ▲' : ' ▼';
|
||
} else {
|
||
ico.textContent = '';
|
||
}
|
||
});
|
||
|
||
let html = '';
|
||
for (const d of DevState.filtered) {
|
||
const sync = d.LastSync ? new Date(d.LastSync).toLocaleDateString('de-DE') : '—';
|
||
const cmpCls = d.ComplianceState === 'compliant' ? 'dev-badge-ok'
|
||
: d.ComplianceState === 'noncompliant' ? 'dev-badge-err' : 'dev-badge-unk';
|
||
const sel = DevState.selected && DevState.selected.Id === d.Id ? ' class="dev-row-selected"' : '';
|
||
html += `<tr data-id="${escapeHtml(d.Id)}"${sel}>
|
||
<td>${escapeHtml(d.DeviceName || '—')}</td>
|
||
<td>${escapeHtml(d.UserDisplayName || '—')}</td>
|
||
<td>${escapeHtml(d.OS || '—')}</td>
|
||
<td><span class="dev-badge ${cmpCls}">${escapeHtml(d.ComplianceState || '—')}</span></td>
|
||
<td>${sync}</td>
|
||
</tr>`;
|
||
}
|
||
tbody.innerHTML = html;
|
||
|
||
tbody.querySelectorAll('tr').forEach(tr => {
|
||
tr.addEventListener('click', () => {
|
||
const id = tr.dataset.id;
|
||
const device = DevState.filtered.find(d => d.Id === id);
|
||
if (device) devSelectDevice(device);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function devSelectDevice(device) {
|
||
DevState.selected = device;
|
||
// Auswahl visuell
|
||
document.querySelectorAll('#devTableBody tr').forEach(tr =>
|
||
tr.classList.toggle('dev-row-selected', tr.dataset.id === device.Id));
|
||
|
||
// Detail-Panel anzeigen mit Basis-Infos sofort
|
||
devRenderDetail(device);
|
||
|
||
// Vollständige Details nachladen
|
||
try {
|
||
const full = await api(`/api/devices/${encodeURIComponent(device.Id)}`);
|
||
devRenderDetail(full);
|
||
} catch (e) {
|
||
// Basis-Infos bleiben
|
||
}
|
||
}
|
||
|
||
function devRenderDetail(d) {
|
||
const panel = document.getElementById('devDetail');
|
||
panel.classList.remove('hidden');
|
||
|
||
document.getElementById('devDetailName').textContent = d.DeviceName || '—';
|
||
document.getElementById('devDetailSub').textContent = `${d.Manufacturer || ''} ${d.Model || ''}`.trim() || '—';
|
||
|
||
const sync = d.LastSync ? new Date(d.LastSync).toLocaleString('de-DE') : '—';
|
||
const enrolled = d.EnrolledDateTime ? new Date(d.EnrolledDateTime).toLocaleDateString('de-DE') : '—';
|
||
const storage = (d.TotalStorageGB != null)
|
||
? `${d.FreeStorageGB} GB frei / ${d.TotalStorageGB} GB gesamt` : null;
|
||
|
||
const rows = [
|
||
['Benutzer', d.UserDisplayName],
|
||
['UPN', d.UserPrincipalName],
|
||
['OS', d.OS ? `${d.OS} ${d.OSVersion || ''}`.trim() : null],
|
||
['Compliance', d.ComplianceState],
|
||
['Seriennummer', d.SerialNumber],
|
||
['IMEI', d.Imei],
|
||
['Wi-Fi MAC', d.WiFiMac],
|
||
['Speicher', storage],
|
||
['Eingeschrieben', enrolled],
|
||
['Letzter Sync', sync],
|
||
['Join-Typ', d.JoinType],
|
||
['Besitzertyp', d.OwnerType],
|
||
['Azure AD ID', d.AzureADDeviceId],
|
||
];
|
||
|
||
let html = '';
|
||
for (const [label, val] of rows) {
|
||
if (!val) continue;
|
||
html += `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(String(val))}</dd>`;
|
||
}
|
||
document.getElementById('devInfoGrid').innerHTML = html;
|
||
|
||
// Aktionen: BitLocker und Autopilot Reset nur für Windows
|
||
const isWin = (d.OS || '').toLowerCase() === 'windows';
|
||
const bitlockerBtn = document.getElementById('devBtnBitlocker');
|
||
if (bitlockerBtn) bitlockerBtn.style.display = isWin ? '' : 'none';
|
||
const autopilotBtn = document.getElementById('devBtnAutopilot');
|
||
if (autopilotBtn) autopilotBtn.style.display = isWin ? '' : 'none';
|
||
}
|
||
|
||
// Wipe-Dialog (Promise: true=keepUserData, false=löschen, null=abgebrochen)
|
||
function devWipeDialog(deviceName) {
|
||
return new Promise(resolve => {
|
||
document.getElementById('wipeDeviceName').textContent = deviceName;
|
||
document.getElementById('wipeKeepUserData').checked = false;
|
||
const backdrop = document.getElementById('wipeModalBackdrop');
|
||
backdrop.classList.remove('hidden');
|
||
|
||
const close = (result) => {
|
||
backdrop.classList.add('hidden');
|
||
resolve(result);
|
||
};
|
||
document.getElementById('wipeCancel').onclick = () => close(null);
|
||
document.getElementById('wipeModalClose').onclick = () => close(null);
|
||
document.getElementById('wipeConfirm').onclick = () => close(document.getElementById('wipeKeepUserData').checked);
|
||
backdrop.onclick = (e) => { if (e.target === backdrop) close(null); };
|
||
});
|
||
}
|
||
|
||
// Aktion ausführen
|
||
document.getElementById('devActions')?.addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('.dev-action-btn');
|
||
if (!btn || !DevState.selected) return;
|
||
const action = btn.dataset.action;
|
||
if (!action) return;
|
||
|
||
const labels = {
|
||
syncDevice: 'Sync',
|
||
rebootNow: 'Neustart',
|
||
remoteLock: 'Sperren',
|
||
collectDiagnostics: 'Diagnose sammeln',
|
||
rotateBitLockerKeys: 'BitLocker-Schlüssel rotieren',
|
||
autopilotReset: 'Autopilot Reset',
|
||
retire: 'Retire (MDM entfernen)',
|
||
wipe: 'Wipe'
|
||
};
|
||
const devName = DevState.selected.DeviceName || DevState.selected.Id;
|
||
|
||
// Wipe: eigener Dialog mit Benutzerdaten-Option
|
||
if (action === 'wipe') {
|
||
const keepUserData = await devWipeDialog(devName);
|
||
if (keepUserData === null) return;
|
||
btn.disabled = true;
|
||
const origText2 = btn.textContent.trim();
|
||
btn.textContent = '…';
|
||
try {
|
||
await api(`/api/devices/${encodeURIComponent(DevState.selected.Id)}/action`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ action: 'wipe', keepUserData })
|
||
});
|
||
toast(`Wipe gesendet${keepUserData ? ' (Benutzerdaten behalten)' : ''}`, 'ok');
|
||
DevState.items = DevState.items.filter(d => d.Id !== DevState.selected.Id);
|
||
DevState.selected = null;
|
||
document.getElementById('devDetail').classList.add('hidden');
|
||
devApplyFilters();
|
||
} catch (e) {
|
||
toast(`Fehler bei Wipe: ${e}`, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = origText2;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const isDangerous = action === 'retire' || action === 'autopilotReset';
|
||
const dangerMsg = {
|
||
retire: `ACHTUNG: "Retire" auf "${devName}" ausführen?\n\nDas MDM-Profil wird entfernt — Gerät wird nicht mehr verwaltet.`,
|
||
autopilotReset: `ACHTUNG: "Autopilot Reset" auf "${devName}" ausführen?\n\nDas Gerät wird auf OOBE zurückgesetzt. Enrollment-Daten bleiben erhalten, Benutzerdaten werden gelöscht.`
|
||
};
|
||
const msg = dangerMsg[action] || `"${labels[action]}" auf "${devName}" ausführen?`;
|
||
|
||
if (!confirm(msg)) return;
|
||
|
||
btn.disabled = true;
|
||
const origText = btn.textContent.trim();
|
||
btn.textContent = '…';
|
||
try {
|
||
await api(`/api/devices/${encodeURIComponent(DevState.selected.Id)}/action`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ action })
|
||
});
|
||
toast(`${labels[action]}: Aktion wurde gesendet`, 'ok');
|
||
if (action === 'retire') {
|
||
DevState.items = DevState.items.filter(d => d.Id !== DevState.selected.Id);
|
||
DevState.selected = null;
|
||
document.getElementById('devDetail').classList.add('hidden');
|
||
devApplyFilters();
|
||
}
|
||
} catch (e) {
|
||
toast(`Fehler bei ${labels[action]}: ${e}`, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = origText;
|
||
}
|
||
});
|
||
|
||
// Such- und Filter-Events
|
||
let devSearchDebounce = null;
|
||
document.getElementById('devSearch').addEventListener('input', () => {
|
||
clearTimeout(devSearchDebounce);
|
||
devSearchDebounce = setTimeout(devApplyFilters, 250);
|
||
});
|
||
document.getElementById('devOsFilter').addEventListener('change', devApplyFilters);
|
||
document.getElementById('devComplianceFilter').addEventListener('change', devApplyFilters);
|
||
|
||
// Sortierung
|
||
document.querySelectorAll('#devTable th.sortable').forEach(th => {
|
||
th.addEventListener('click', () => {
|
||
if (DevState.sortCol === th.dataset.col) {
|
||
DevState.sortAsc = !DevState.sortAsc;
|
||
} else {
|
||
DevState.sortCol = th.dataset.col;
|
||
DevState.sortAsc = true;
|
||
}
|
||
devSort();
|
||
});
|
||
});
|