// =============================================================
// Intune Manager — Frontend
// Workflow: Connect → Select Targets → Pick Apps → Apply
// =============================================================
const State = {
connected: false,
readOnly: false,
account: null,
mode: 'dept', // 'dept' | 'rpa' | 'user'
targets: [],
selectedTargetIds: new Set(),
apps: [],
appFilter: { search: '', preset: 'all', platform: 'all', category: '', onlyMember: false, hideNative: false, modFrom: '', modTo: '' },
session: [],
membershipCache: new Map(),
loadedModes: new Set(),
appsLoaded: false,
sideTab: 'session',
// groupId → { members:Array|null, selected:Set, loading:bool, error:string|null, filter:string }
expandedGroups: new Map(),
// mode → { targets, selectedIds:Set, expandedGroups:Map } — verhindert Verlust beim Tab-Wechsel
modeCache: new Map(),
// appId → { details, loading, error }
appDetails: new Map(),
expandedApps: new Set(),
};
// =============================================================
// API wrapper
// =============================================================
async function api(path, options = {}) {
const opts = {
method: options.method || 'GET',
headers: { 'Content-Type': 'application/json' },
body: options.body ? JSON.stringify(options.body) : undefined,
};
// Optionaler Timeout (z.B. fuer lange Upload-Operationen). fetch hat sonst
// keinen Default-Timeout — lauft auf localhost ewig, was hier ok ist.
let timer = null;
if (options.timeoutMs) {
const ctrl = new AbortController();
opts.signal = ctrl.signal;
timer = setTimeout(() => ctrl.abort(), options.timeoutMs);
}
let res;
try {
res = await fetch(path, opts);
} catch (e) {
// "Failed to fetch" = der lokale Server hat nicht geantwortet (Prozess
// beendet/abgestürzt oder PowerShell-Fenster geschlossen). Klartext geben.
if (e && (e.name === 'AbortError')) throw e;
throw new Error('Server nicht erreichbar — läuft das PowerShell-Fenster (Run.cmd) noch? Bitte die App neu starten und erneut versuchen.');
} finally {
if (timer) clearTimeout(timer);
}
let data;
try { data = await res.json(); } catch { data = {}; }
if (!res.ok) {
const msg = data.error || `HTTP ${res.status}`;
throw new Error(msg);
}
return data;
}
// =============================================================
// Toasts
// =============================================================
function toast(msg, type = 'info', title = null) {
const ico = { ok: '✓', warn: '!', err: '×', info: 'i' }[type] || 'i';
const t = document.createElement('div');
t.className = `toast ${type}`;
t.innerHTML = `
${ico}
${title ? `
${escapeHtml(title)}
` : ''}
${escapeHtml(msg)}
`;
document.getElementById('toasts').appendChild(t);
setTimeout(() => {
t.style.animation = 'slide-in 200ms reverse';
setTimeout(() => t.remove(), 220);
}, type === 'err' ? 6000 : 3500);
}
function escapeHtml(s) {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, c => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[c]));
}
// Markdown -> HTML. Nutzt marked (gebundelt in /vendor/marked.min.js).
// breaks: true -> einzelne Newlines werden zu , intuitiver fuer User
// gfm: true -> GitHub-Flavored Markdown (Tabellen, Strikethrough, Autolinks)
// Wenn marked aus irgendeinem Grund nicht geladen ist, faellt der Helper
// auf escapeHtml zurueck — kein Crash, nur kein Markdown.
let markedConfigured = false;
function renderMarkdown(s) {
if (s == null) return '';
const text = String(s);
if (typeof window.marked === 'undefined') return escapeHtml(text);
if (!markedConfigured) {
window.marked.setOptions({ breaks: true, gfm: true });
markedConfigured = true;
}
try { return window.marked.parse(text); }
catch { return escapeHtml(text); }
}
// =============================================================
// Loading overlay
// =============================================================
let loadingDepth = 0;
function setLoading(text) {
loadingDepth++;
document.getElementById('loadingText').textContent = text || 'Bitte warten...';
document.getElementById('loading').classList.remove('hidden');
}
function clearLoading() {
loadingDepth = Math.max(0, loadingDepth - 1);
if (loadingDepth === 0) document.getElementById('loading').classList.add('hidden');
}
// =============================================================
// Workflow stepper
// =============================================================
// Stepper wurde entfernt — Funktion bleibt als No-Op, damit die ~12
// bestehenden Aufrufer keine Errors werfen. Kann irgendwann mit Sed
// gemeinsam mit den Aufrufstellen rausgesnipt werden.
function updateStepper() { /* no-op */ }
// =============================================================
// Connection
// =============================================================
function showOnboarding(show) {
document.getElementById('onboarding').classList.toggle('hidden', !show);
document.getElementById('layout').classList.toggle('hidden', show);
if (show) {
// Reset state-View nur, wenn gerade keine Anmeldung lief
if (!connectInFlight) showOnboardingState('idle');
} else {
stopDevicePolling();
}
}
async function refreshStatus() {
try {
const s = await api('/api/status');
State.connected = s.connected;
State.account = s.account;
State.readOnly = !!s.readOnly;
applyReadOnlyMode();
renderConnection();
} catch (e) {
toast('Status konnte nicht geladen werden: ' + e.message, 'err');
}
}
// Topbar-Mandantenumschalter: nur im Multi-Tenant-Modus sichtbar.
async function refreshTenantSwitcher() {
const sel = document.getElementById('tenantSwitch');
if (!sel) return;
try {
const t = await api('/api/tenants');
if (!t.multiTenant || !(t.items || []).length) {
sel.classList.add('hidden');
sel.innerHTML = '';
return;
}
sel.innerHTML = t.items.map(it =>
``
).join('');
sel.classList.remove('hidden');
} catch {
sel.classList.add('hidden');
}
}
document.getElementById('tenantSwitch')?.addEventListener('change', async e => {
const id = e.target.value;
setLoading('Wechsle Mandant…');
try {
// Löst server-seitig Disconnect + Reconnect mit dem neuen Tenant aus.
const s = await api('/api/tenants/switch', { method: 'POST', body: { id }, timeoutMs: 120000 });
State.connected = !!s.connected;
State.account = s.account;
State.readOnly = !!s.readOnly;
applyReadOnlyMode();
renderConnection();
// Anderer Tenant = andere Daten: ALLE datentragenden Frontend-Caches
// verwerfen, sonst ueberspringen die Load-Guards (appsLoaded/loadedModes)
// das Neuladen und die UI zeigt weiter den vorherigen Tenant.
if (typeof stopDevicePolling === 'function') stopDevicePolling();
resetClientState();
switchMainView('apps');
if (s.connected) {
toast('Verbunden mit ' + (s.tenantLabel || s.account || 'Mandant'), 'ok', 'Mandant gewechselt');
autoLoadAfterConnect();
} else {
toast('Umgestellt, aber nicht verbunden.', 'warn');
}
} catch (err) {
toast('Wechsel fehlgeschlagen: ' + err.message, 'err');
await refreshStatus();
} finally {
clearLoading();
refreshTenantSwitcher();
}
});
function applyReadOnlyMode() {
document.body.classList.toggle('read-only', State.readOnly);
// Falls ein Schreib-Tab aktiv ist beim Wechsel in Read-Only -> Export-Tab zeigen
if (State.readOnly) {
const activeTab = document.querySelector('.gex-tab.active');
if (activeTab && (activeTab.dataset.tab === 'add' || activeTab.dataset.tab === 'import')) {
if (typeof switchGexTab === 'function') switchGexTab('export');
}
}
}
// Bootstrap-Icons "person-circle" — Avatar mit eingebautem Kreis-Outline.
// 20px matches die visuelle Groesse der anderen Header-Icons (cog, logout).
const PERSON_ICON = ``;
function renderConnection() {
const pill = document.getElementById('connStatus');
const text = pill.querySelector('.conn-text');
const btnD = document.getElementById('btnDisconnect');
// Avatar ist der Person-Icon — Identifikation des aktiven Accounts laeuft
// ueber den Tooltip (title-Attribut, hover).
text.innerHTML = PERSON_ICON;
if (State.connected) {
pill.className = 'conn-pill conn-on';
pill.title = State.account || 'Verbunden';
btnD.classList.remove('hidden');
showOnboarding(false);
} else {
pill.className = 'conn-pill conn-off';
pill.title = 'Nicht verbunden';
btnD.classList.add('hidden');
showOnboarding(true);
}
updateStepper();
}
// ----------------------------------------------------------------
// Device-Code-Login: Anmeldung direkt auf der Seite, ohne Popup-
// Fenster, ohne WAM. Nutzer kann jeden Account angeben.
// ----------------------------------------------------------------
let connectInFlight = false;
let devicePollTimer = null;
function showOnboardingState(view) {
// view: 'idle' | 'requesting' | 'awaiting' | 'error'
document.querySelectorAll('[data-onboarding-view]').forEach(el => {
el.classList.toggle('hidden', el.dataset.onboardingView !== view);
});
}
function stopDevicePolling() {
if (devicePollTimer) { clearInterval(devicePollTimer); devicePollTimer = null; }
}
document.getElementById('btnConnect').addEventListener('click', async () => {
if (connectInFlight) return;
connectInFlight = true;
showOnboardingState('awaiting');
// Sekundenzaehler im UI
const startedAt = Date.now();
const waitText = document.querySelector('[data-onboarding-view="awaiting"] .onboarding-text');
const baseText = waitText ? waitText.textContent : '';
const tickTimer = setInterval(() => {
if (waitText) {
const sec = Math.floor((Date.now() - startedAt) / 1000);
waitText.textContent = `${baseText} (Wartet seit ${sec}s)`;
}
}, 1000);
// 90s Timeout — Connect-MgGraph blockt typisch 5-30s; 90s ist sicher
const ctrl = new AbortController();
const timeoutId = setTimeout(() => ctrl.abort(), 90000);
try {
const resp = await fetch('/api/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: ctrl.signal,
});
clearTimeout(timeoutId);
let s = {};
try { s = await resp.json(); } catch {}
if (!resp.ok) throw new Error(s.error || `HTTP ${resp.status}`);
if (!s.connected) throw new Error('Backend meldet nicht connected');
State.connected = true;
State.account = s.account;
State.readOnly = !!s.readOnly;
applyReadOnlyMode();
renderConnection();
const roHint = State.readOnly ? ' (Read Only)' : '';
toast('Verbunden als ' + s.account + roHint, 'ok', 'Erfolgreich');
autoLoadAfterConnect();
} catch (e) {
clearTimeout(timeoutId);
let msg = e.message;
if (e.name === 'AbortError') {
msg = 'Anmeldung dauert über 90s. Möglicherweise ist das Login-Fenster im Hintergrund — prüfe die Taskleiste. Sonst PowerShell-Konsole prüfen.';
}
document.getElementById('dcError').textContent = msg;
showOnboardingState('error');
toast(msg, 'err', 'Verbindung fehlgeschlagen');
} finally {
clearInterval(tickTimer);
if (waitText) waitText.textContent = baseText;
connectInFlight = false;
}
});
document.getElementById('btnCancelConnect')?.addEventListener('click', async () => {
stopDevicePolling();
try { await api('/api/connect/cancel', { method: 'POST' }); } catch {}
showOnboardingState('idle');
connectInFlight = false;
});
document.getElementById('btnRetryConnect')?.addEventListener('click', () => {
showOnboardingState('idle');
connectInFlight = false;
});
document.getElementById('btnDisconnect').addEventListener('click', async () => {
setLoading('Trenne...');
stopDevicePolling();
connectInFlight = false;
try {
await api('/api/disconnect', { method: 'POST' });
Object.assign(State, { connected: false, account: null });
resetClientState();
renderConnection();
renderTargets();
renderPinnedTargets();
renderApps();
renderSession();
renderExisting();
toast('Verbindung getrennt', 'info');
} finally {
clearLoading();
}
});
// Alle datentragenden Frontend-Caches verwerfen (Apps, Targets, Gruppen-
// Mitglieder, Report, Policies, Devices). Beruehrt NICHT connected/account —
// das setzt der jeweilige Aufrufer (Disconnect vs. Tenant-Wechsel) selbst.
// Wird beim Tenant-Wechsel benoetigt, sonst zeigen die Load-Guards
// (appsLoaded / loadedModes) noch die Daten des vorherigen Tenants.
function resetClientState() {
Object.assign(State, { targets: [], apps: [], session: [], appsLoaded: false });
State.selectedTargetIds.clear();
State.membershipCache.clear();
State.loadedModes.clear();
State.appDetails.clear();
State.expandedApps.clear();
State.modeCache.clear();
State.expandedGroups = new Map();
if (typeof ReportState !== 'undefined') ReportState.items = [];
if (typeof PolState !== 'undefined') { PolState.items = []; PolState.selected?.clear?.(); }
if (typeof DevState !== 'undefined') { DevState.items = []; DevState.filtered = []; DevState.selected = null; }
}
async function autoLoadAfterConnect() {
// Load default mode targets + apps in parallel
const tasks = [];
if (!State.loadedModes.has(State.mode)) tasks.push(loadTargets({ silent: true }));
if (!State.appsLoaded) tasks.push(loadApps(false, { silent: true }));
await Promise.allSettled(tasks);
}
// =============================================================
// Mode tabs (segmented control)
// =============================================================
document.querySelectorAll('.seg').forEach(seg => {
seg.addEventListener('click', () => {
if (seg.classList.contains('active')) return;
const oldMode = State.mode;
const newMode = seg.dataset.mode;
// Aktuellen Stand fuer alten Mode sichern
State.modeCache.set(oldMode, {
targets: State.targets.slice(),
selectedIds: new Set(State.selectedTargetIds),
expandedGroups: State.expandedGroups,
});
document.querySelectorAll('.seg').forEach(s => s.classList.remove('active'));
seg.classList.add('active');
State.mode = newMode;
// Stand fuer neuen Mode wiederherstellen — falls da
const cached = State.modeCache.get(newMode);
if (cached) {
State.targets = cached.targets;
State.selectedTargetIds = new Set(cached.selectedIds);
State.expandedGroups = cached.expandedGroups;
} else {
State.targets = [];
State.selectedTargetIds = new Set();
State.expandedGroups = new Map();
}
// Membership-Cache leeren da Targets sich aendern → andere Mitgliedschafts-Resultate
State.membershipCache.clear();
const search = document.getElementById('targetSearch');
const hint = document.getElementById('userSearchHint');
if (newMode === 'user') {
search.placeholder = 'Tippe Name oder UPN...';
hint.classList.remove('hidden');
// Suchwert bei User-Mode immer leer starten
search.value = '';
search.focus();
try { search.setSelectionRange(0, 0); } catch {}
} else if (newMode === 'dept') {
// Gruppen-Modus: Live-Suche ueber ALLE Gruppen (min. 2 Zeichen), kein Praefix-Filter
search.placeholder = 'Gruppe suchen...';
hint.classList.remove('hidden');
search.value = '';
search.focus();
try { search.setSelectionRange(0, 0); } catch {}
} else {
search.placeholder = 'Filtern...';
hint.classList.add('hidden');
search.value = '';
}
renderTargets();
onTargetSelectionChange();
// Auto-Load nur fuer Listen-Modi (RPA). 'dept' (Gruppensuche) und 'user'
// laden erst bei Sucheingabe.
if (State.connected && newMode === 'rpa' && !State.loadedModes.has(newMode) && State.targets.length === 0) {
loadTargets({ silent: false });
}
});
});
// =============================================================
// Targets
// =============================================================
document.getElementById('btnReloadTargets').addEventListener('click', () => {
if (State.mode === 'user') {
const term = document.getElementById('targetSearch').value;
if (!term || term.length < 2) {
document.getElementById('targetSearch').focus();
toast('Tippe mind. 2 Zeichen für Benutzersuche', 'warn');
return;
}
runUserSearch(term);
} else if (State.mode === 'dept') {
const term = document.getElementById('targetSearch').value;
if (!term || term.length < 2) {
document.getElementById('targetSearch').focus();
toast('Tippe mind. 2 Zeichen für die Gruppensuche', 'warn');
return;
}
runGroupSearch(term);
} else {
State.loadedModes.delete(State.mode);
loadTargets({ silent: false });
}
});
document.getElementById('targetSearch').addEventListener('input', e => {
if (State.mode === 'user') {
debouncedUserSearch(e.target.value);
} else if (State.mode === 'dept') {
debouncedGroupSearch(e.target.value);
} else {
renderTargets();
}
});
// Enter loest die Server-Suche sofort aus (Benutzer- und Gruppen-Modus)
document.getElementById('targetSearch').addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
const term = e.target.value;
if (term && term.length >= 2) {
if (State.mode === 'user') runUserSearch(term);
else if (State.mode === 'dept') runGroupSearch(term);
}
});
document.getElementById('btnTargetsSelectAll').addEventListener('click', () => {
getFilteredTargets().forEach(t => State.selectedTargetIds.add(t.Id));
renderTargets();
onTargetSelectionChange();
});
document.getElementById('btnTargetsClear').addEventListener('click', () => {
State.selectedTargetIds.clear();
renderTargets();
onTargetSelectionChange();
});
let userSearchTimer = null;
let userSearchAbort = null;
let userSearchCache = { query: '', results: [] };
function debouncedUserSearch(term) {
clearTimeout(userSearchTimer);
const t = (term || '').trim();
if (t.length < 2) {
State.targets = [];
userSearchCache = { query: '', results: [] };
renderTargets();
return;
}
// Optimistic: wenn neue Anfrage Verfeinerung der vorigen ist
// → sofort lokal filtern, kein Server-Request, NULL Latenz
const lower = t.toLowerCase();
const cachedLower = userSearchCache.query.toLowerCase();
if (cachedLower && lower.startsWith(cachedLower) && userSearchCache.results.length > 0) {
State.targets = userSearchCache.results.filter(u =>
(u.DisplayName || '').toLowerCase().includes(lower) ||
(u.UserPrincipalName || '').toLowerCase().includes(lower) ||
(u.Mail || '').toLowerCase().includes(lower));
renderTargets();
return;
}
userSearchTimer = setTimeout(() => runUserSearch(t), 220);
}
async function runUserSearch(term) {
if (!State.connected) { toast('Bitte zuerst verbinden', 'warn'); return; }
// Vorherige Anfrage abbrechen — sonst stapeln sich die Requests beim Tippen
if (userSearchAbort) {
try { userSearchAbort.abort(); } catch {}
}
userSearchAbort = new AbortController();
const signal = userSearchAbort.signal;
document.getElementById('targetList').innerHTML = `
`;
try {
const resp = await fetch('/api/users?q=' + encodeURIComponent(term), {
headers: { 'Content-Type': 'application/json' },
signal,
});
let data = {};
try { data = await resp.json(); } catch {}
if (signal.aborted) return;
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
State.targets = data.items || [];
userSearchCache = { query: term, results: State.targets.slice() };
// Selektion auf das neue Result-Set einschraenken — sonst bleibt der alte
// User im selectedTargetIds-Set haengen und verfaelscht alle Membership-
// Anfragen (status wird Partial/None weil der alte User nicht mehr matcht).
if (State.selectedTargetIds.size > 0) {
const visibleIds = new Set(State.targets.map(t => t.Id));
const before = State.selectedTargetIds.size;
for (const id of [...State.selectedTargetIds]) {
if (!visibleIds.has(id)) State.selectedTargetIds.delete(id);
}
if (State.selectedTargetIds.size !== before) {
State.membershipCache.clear();
}
}
renderTargets();
onTargetSelectionChange();
if (State.targets.length === 0) toast(`Keine Benutzer für "${term}" gefunden`, 'warn');
} catch (e) {
if (e.name === 'AbortError') return; // neue Suche laeuft schon
toast(e.message, 'err', 'Benutzersuche fehlgeschlagen');
renderTargets();
}
}
// ── Gruppen-Live-Suche: durchsucht ALLE Gruppen (nicht nur Praefix-Gruppen) ──
let groupSearchTimer = null;
let groupSearchAbort = null;
let groupSearchCache = { query: '', results: [] };
// Bereits ausgewaehlte Gruppen in ein neues Ergebnis mergen, damit die Auswahl
// (und die Namen in der Pinned-Leiste) ueber mehrere Suchen hinweg bestehen bleibt.
function mergeSelectedGroups(results) {
const ids = new Set(results.map(r => r.Id));
const keep = State.targets.filter(t => State.selectedTargetIds.has(t.Id) && !ids.has(t.Id));
return keep.length ? results.concat(keep) : results;
}
function debouncedGroupSearch(term) {
clearTimeout(groupSearchTimer);
const t = (term || '').trim();
if (t.length < 2) {
// Unter 2 Zeichen: nur die ausgewaehlten Gruppen stehen lassen (Auswahl behalten)
State.targets = State.targets.filter(x => State.selectedTargetIds.has(x.Id));
groupSearchCache = { query: '', results: [] };
renderTargets();
return;
}
// Optimistisch lokal verfeinern, wenn die neue Anfrage die vorige praezisiert
const lower = t.toLowerCase();
const cachedLower = groupSearchCache.query.toLowerCase();
if (cachedLower && lower.startsWith(cachedLower) && groupSearchCache.results.length > 0) {
const filtered = groupSearchCache.results.filter(g => (g.DisplayName || '').toLowerCase().includes(lower));
State.targets = mergeSelectedGroups(filtered);
renderTargets();
return;
}
groupSearchTimer = setTimeout(() => runGroupSearch(t), 220);
}
async function runGroupSearch(term) {
if (!State.connected) { toast('Bitte zuerst verbinden', 'warn'); return; }
if (groupSearchAbort) { try { groupSearchAbort.abort(); } catch {} }
groupSearchAbort = new AbortController();
const signal = groupSearchAbort.signal;
document.getElementById('targetList').innerHTML = `
`;
try {
const resp = await fetch('/api/groups/search?q=' + encodeURIComponent(term), {
headers: { 'Content-Type': 'application/json' },
signal,
});
let data = {};
try { data = await resp.json(); } catch {}
if (signal.aborted) return;
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
const results = data.items || [];
groupSearchCache = { query: term, results: results.slice() };
State.targets = mergeSelectedGroups(results);
renderTargets();
onTargetSelectionChange();
if (results.length === 0) toast(`Keine Gruppen für "${term}" gefunden`, 'warn');
} catch (e) {
if (e.name === 'AbortError') return; // neue Suche laeuft schon
toast(e.message, 'err', 'Gruppensuche fehlgeschlagen');
renderTargets();
}
}
let loadTargetsInFlight = null; // welcher Mode laeuft gerade
async function loadTargets(opts = {}) {
if (!State.connected) { toast('Bitte zuerst verbinden', 'warn'); return; }
if (State.mode === 'user') {
const term = document.getElementById('targetSearch').value;
if (!term || term.length < 2) {
document.getElementById('targetSearch').focus();
toast('Tippe mind. 2 Zeichen, um Benutzer zu suchen', 'warn');
return;
}
return runUserSearch(term);
}
// 'dept' (Gruppen) ist suchbasiert — kein Listen-Load. Bei vorhandener Eingabe
// die Suche ausloesen, sonst nichts tun (auch beim Auto-Load nach Connect).
if (State.mode === 'dept') {
const term = document.getElementById('targetSearch').value;
if (term && term.length >= 2) return runGroupSearch(term);
return;
}
const requestedMode = State.mode;
if (loadTargetsInFlight === requestedMode) return; // gleiche Anfrage laeuft bereits
loadTargetsInFlight = requestedMode;
if (!opts.silent) setLoading('Lade ' + (requestedMode === 'rpa' ? 'RPA-Gruppen' : 'Gruppen') + '...');
document.getElementById('targetList').innerHTML = `
`;
try {
const url = requestedMode === 'rpa' ? '/api/groups/rpa' : '/api/groups';
const res = await api(url);
// User koennte zwischenzeitlich den Tab gewechselt haben — Resultat verwerfen
if (State.mode !== requestedMode) return;
State.targets = res.items || [];
State.loadedModes.add(requestedMode);
State.selectedTargetIds.clear();
State.membershipCache.clear();
// Cache fuer diesen Mode aktualisieren
State.modeCache.set(requestedMode, {
targets: State.targets.slice(),
selectedIds: new Set(),
expandedGroups: new Map(),
});
renderTargets();
onTargetSelectionChange();
if (!opts.silent) toast(`${res.count} ${requestedMode === 'rpa' ? 'RPA-Gruppen' : 'Gruppen'} geladen`, 'ok');
} catch (e) {
if (State.mode === requestedMode) {
toast(e.message, 'err', 'Laden fehlgeschlagen');
renderTargets();
}
} finally {
if (loadTargetsInFlight === requestedMode) loadTargetsInFlight = null;
if (!opts.silent) clearLoading();
}
}
function getFilteredTargets() {
const q = document.getElementById('targetSearch').value.toLowerCase().trim();
// 'user' und 'dept' werden serverseitig gesucht -> State.targets ist bereits das Ergebnis.
if (!q || State.mode === 'user' || State.mode === 'dept') return State.targets;
return State.targets.filter(t =>
(t.DisplayName || '').toLowerCase().includes(q) ||
(t.UserPrincipalName || '').toLowerCase().includes(q)
);
}
function renderTargets() {
const list = document.getElementById('targetList');
const cnt = document.getElementById('targetCount');
cnt.textContent = State.targets.length;
const filtered = getFilteredTargets();
if (!State.connected) {
list.innerHTML = emptyState({
title: 'Nicht verbunden',
text: 'Verbinde dich zuerst mit Microsoft Graph.',
});
return;
}
if (filtered.length === 0) {
if (State.mode === 'user' || State.mode === 'dept') {
const term = document.getElementById('targetSearch').value;
const isUser = State.mode === 'user';
const has2 = term && term.length >= 2;
list.innerHTML = emptyState({
title: has2 ? 'Keine Treffer' : (isUser ? 'Benutzer suchen' : 'Gruppe suchen'),
text: has2
? `Keine ${isUser ? 'Benutzer' : 'Gruppen'} für "${escapeHtml(term)}" gefunden.`
: (isUser
? 'Tippe mind. 2 Zeichen — Name, UPN oder E-Mail.'
: 'Tippe mind. 2 Zeichen — durchsucht alle Gruppen im Tenant.'),
});
} else {
list.innerHTML = emptyState({
title: State.targets.length === 0 ? 'Wird geladen...' : 'Keine Treffer',
text: State.targets.length === 0
? 'Wenn nichts kommt, klicke auf das Refresh-Icon oben rechts.'
: 'Filter zurücksetzen oder neu laden.',
});
}
return;
}
const isGroupMode = State.mode === 'dept' || State.mode === 'rpa';
list.innerHTML = filtered.map(t => {
const checked = State.selectedTargetIds.has(t.Id) ? 'checked' : '';
const meta = t.UserPrincipalName
? `
`;
}
function renderAssignmentsSection(app) {
// Zwei Listen: Available + Required. Native (All Users / All Devices) werden
// mitgezeigt aber als nicht-loeschbar markiert.
const renderGroupList = (groups, type) => {
if (!groups || groups.length === 0) {
return `
Keine ${type === 'available' ? 'Available' : 'Required'}-Zuweisungen.
⚠ Kein Report-URL in der Response — siehe Debug unten
`;
}
// Debug-Sektion mit der RAW Response — damit wir sehen was tatsaechlich ankommt
const dbg = document.getElementById('resDebug');
if (dbg) {
const raw = JSON.stringify(res, null, 2);
dbg.innerHTML = `Raw Output${res.build ? ' (Backend-Build: ' + escapeHtml(res.build) + ')' : ' (kein build-Stamp — Backend wurde nicht neu gestartet!)'}
${escapeHtml(raw)}
`;
}
console.log('[APPLY] Response:', res);
openModal('modalResult');
}
// =============================================================
// Modals
// =============================================================
function openModal(id) { document.getElementById(id).classList.remove('hidden'); }
function closeModal(id) { document.getElementById(id).classList.add('hidden'); }
document.querySelectorAll('[data-close]').forEach(el => {
el.addEventListener('click', () => {
const modal = el.closest('.modal');
if (modal) modal.classList.add('hidden');
});
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden'));
}
});
// =============================================================
// Create Required Group Modal
// =============================================================
let createGroupContext = null;
let cgCheckTimer = null;
// Naming-Schemas aus Settings holen. Fallback auf Defaults wenn Settings noch
// nicht geladen sind (z.B. waehrend des allerersten Init-Renders).
// Aktives Tenant-Profil aus einem Settings-Objekt (oder null im Single-Modus).
function activeTenantProfileFromSettings(s) {
const c = s && s.connection;
if (!c || !c.multiTenant || !Array.isArray(c.tenants) || !c.tenants.length) return null;
return c.tenants.find(t => t.id === c.activeTenantId) || c.tenants[0];
}
function getNamingFor(intent) {
const s = SettingsState && SettingsState.current;
const isAvail = intent === 'available';
const defPrefix = 'intune-win-app-';
const defSuffix = isAvail ? '-available' : '-required';
const gBlock = s && (isAvail ? s.availableGroupNaming : s.requiredGroupNaming);
let prefix = (gBlock && gBlock.prefix) || defPrefix;
let suffix = (gBlock && gBlock.suffix) || defSuffix;
// Aktives Tenant-Profil kann pro Feld überschreiben (leer = globale Vorgabe).
const prof = activeTenantProfileFromSettings(s);
if (prof) {
const pBlock = isAvail ? prof.availableGroupNaming : prof.requiredGroupNaming;
if (pBlock) {
if (pBlock.prefix && String(pBlock.prefix).trim()) prefix = pBlock.prefix;
if (pBlock.suffix && String(pBlock.suffix).trim()) suffix = pBlock.suffix;
}
}
return { prefix, suffix };
}
function appSlug(appName) {
return appName.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
}
function suggestedGroupName(appName, intent) {
const { prefix, suffix } = getNamingFor(intent);
return `${prefix}${appSlug(appName)}${suffix}`;
}
function getCurrentCgMode() {
const el = document.querySelector('input[name="cgMode"]:checked');
return el ? el.value : 'req-group';
}
// Modus -> { intent, isGroup, isExisting, target } — single source of truth
function cgModeMeta(mode) {
switch (mode) {
case 'req-group': return { intent: 'required', isGroup: true, isExisting: false, target: null };
case 'req-existing': return { intent: 'required', isGroup: false, isExisting: true, target: null };
case 'req-allusers': return { intent: 'required', isGroup: false, isExisting: false, target: 'ALL_USERS' };
case 'req-alldevices': return { intent: 'required', isGroup: false, isExisting: false, target: 'ALL_DEVICES' };
case 'avail-group': return { intent: 'available', isGroup: true, isExisting: false, target: null };
case 'avail-existing': return { intent: 'available', isGroup: false, isExisting: true, target: null };
case 'avail-allusers': return { intent: 'available', isGroup: false, isExisting: false, target: 'ALL_USERS' };
case 'avail-alldevices':return { intent: 'available', isGroup: false, isExisting: false, target: 'ALL_DEVICES' };
}
return { intent: 'required', isGroup: true, isExisting: false, target: null };
}
function openCreateGroupModal(appId, appName) {
createGroupContext = { appId, appName };
document.getElementById('cgAppName').value = appName;
// Bereits zugewiesene Native-Targets aus dem App-Cache ermitteln.
// ALL_USERS und ALL_DEVICES koennen je Intent nur EINMAL existieren —
// wenn schon da, Option deaktivieren statt einen 409 vom Backend zu kassieren.
const app = State.apps.find(a => a.AppId === appId);
const has = (arr, gid) => !!(app && arr && arr.some(g => g.GroupId === gid));
const occupied = {
'req-allusers': has(app && app.RequiredGroups, 'ALL_USERS'),
'req-alldevices': has(app && app.RequiredGroups, 'ALL_DEVICES'),
'avail-allusers': has(app && app.AvailableGroups, 'ALL_USERS'),
'avail-alldevices': has(app && app.AvailableGroups, 'ALL_DEVICES'),
};
document.querySelectorAll('input[name="cgMode"]').forEach(radio => {
const isOccupied = !!occupied[radio.value];
radio.disabled = isOccupied;
radio.checked = false; // Reset jeder Auswahl, Default unten gesetzt
const card = radio.closest('.cg-mode');
if (!card) return;
card.classList.toggle('cg-mode--disabled', isOccupied);
const desc = card.querySelector('.cg-mode-desc');
if (desc) {
// Original-Description am ersten Aufruf merken, sonst wuerde wiederholtes
// Oeffnen den Text dauerhaft auf "bereits zugewiesen" pinnen.
if (!desc.dataset.origDesc) desc.dataset.origDesc = desc.textContent;
desc.textContent = isOccupied
? 'Bereits zugewiesen — keine zweite Zuweisung möglich.'
: desc.dataset.origDesc;
}
});
// Default-Modus: erste verfuegbare Option (in der Reihenfolge: req-group ist
// immer da, fallback auf naechstes). Garantiert konsistente Vorauswahl.
const order = ['req-group','req-allusers','req-alldevices','avail-group','avail-allusers','avail-alldevices'];
for (const val of order) {
if (!occupied[val]) {
const r = document.querySelector(`input[name="cgMode"][value="${val}"]`);
if (r) { r.checked = true; break; }
}
}
document.getElementById('cgAssignToApp').checked = true;
resetCgExisting();
applyCgModeUi();
openModal('modalCreateGroup');
}
// Zeigt/versteckt das Namens-Input + Vorschlag je nach Modus.
// All-Users/All-Devices brauchen weder Name noch Validierung.
function applyCgModeUi() {
const mode = getCurrentCgMode();
const meta = cgModeMeta(mode);
const nameGroup = document.getElementById('cgGroupNameGroup');
const assignWrap = document.getElementById('cgAssignToAppWrap');
const existingGroup = document.getElementById('cgExistingGroup');
const createBtn = document.getElementById('cgCreate');
const intentLabel = meta.intent === 'available' ? 'Available' : 'Required';
if (meta.isExisting) {
// Bestehende Gruppe(n) suchen + zuweisen (Mehrfachauswahl)
nameGroup.hidden = true;
assignWrap.hidden = true;
if (existingGroup) existingGroup.hidden = false;
renderCgSelected(); // setzt Button-Label + disabled anhand der Auswahl
} else if (meta.isGroup) {
nameGroup.hidden = false;
assignWrap.hidden = false;
if (existingGroup) existingGroup.hidden = true;
document.getElementById('cgGroupName').value = suggestedGroupName(createGroupContext.appName, meta.intent);
document.querySelector('#cgAssignToAppWrap span').textContent =
`Gruppe direkt der App als ${intentLabel} zuweisen`;
checkGroupName();
createBtn.textContent = `${intentLabel}-Gruppe erstellen`;
} else {
nameGroup.hidden = true;
assignWrap.hidden = true;
if (existingGroup) existingGroup.hidden = true;
createBtn.disabled = false;
const tgtLbl = meta.target === 'ALL_USERS' ? 'All Users' : 'All Devices';
createBtn.textContent = `Als ${intentLabel} für ${tgtLbl} zuweisen`;
}
}
document.querySelectorAll('input[name="cgMode"]').forEach(r => {
r.addEventListener('change', applyCgModeUi);
});
// ── "Bestehende Gruppe" — Mehrfach-Suche + -Auswahl im Zuweisungs-Dialog ──
let cgExistingTimer = null;
let cgExistingAbort = null;
function cgSelectedGroups() {
if (!createGroupContext) return [];
if (!Array.isArray(createGroupContext.selectedGroups)) createGroupContext.selectedGroups = [];
return createGroupContext.selectedGroups;
}
function resetCgExisting() {
if (createGroupContext) createGroupContext.selectedGroups = [];
const s = document.getElementById('cgExistingSearch');
const r = document.getElementById('cgExistingResults');
if (s) s.value = '';
if (r) r.innerHTML = '';
renderCgSelected();
}
// Ausgewaehlte Gruppen als entfernbare Chips zeigen + Button-Status/Label setzen.
function renderCgSelected() {
const sel = document.getElementById('cgExistingSelected');
const groups = cgSelectedGroups();
if (sel) {
sel.className = 'cg-existing-chips';
sel.innerHTML = groups.length === 0
? 'Noch keine Gruppe ausgewählt.'
: groups.map(g =>
`${escapeHtml(g.name)}`).join('');
}
// Nur im "Bestehende Gruppe"-Modus den Erstellen/Zuweisen-Button steuern.
const meta = cgModeMeta(getCurrentCgMode());
if (meta.isExisting) {
const btn = document.getElementById('cgCreate');
const intentLabel = meta.intent === 'available' ? 'Available' : 'Required';
btn.disabled = groups.length === 0;
btn.textContent = groups.length > 1
? `${groups.length} Gruppen als ${intentLabel} zuweisen`
: `Als ${intentLabel} zuweisen`;
}
}
document.getElementById('cgExistingSearch')?.addEventListener('input', e => {
clearTimeout(cgExistingTimer);
const term = e.target.value.trim();
const results = document.getElementById('cgExistingResults');
if (term.length < 2) { if (results) results.innerHTML = ''; return; }
cgExistingTimer = setTimeout(() => runCgExistingSearch(term), 250);
});
async function runCgExistingSearch(term) {
const results = document.getElementById('cgExistingResults');
if (!results) return;
if (cgExistingAbort) { try { cgExistingAbort.abort(); } catch {} }
cgExistingAbort = new AbortController();
results.innerHTML = '
Suche…
';
try {
const resp = await fetch('/api/groups/search?q=' + encodeURIComponent(term), { signal: cgExistingAbort.signal });
let data = {}; try { data = await resp.json(); } catch {}
if (cgExistingAbort.signal.aborted) return;
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
const items = data.items || [];
if (items.length === 0) { results.innerHTML = '