`;
}
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).
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 = ' 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 = ``;
const MEMBERS_ICON = ``;
const TRASH_ICON = ``;
const LOCK_ICON = ``;
const PENCIL_ICON = ``;
// Microsoft Teams Icon — original aus Bootstrap Icons (microsoft-teams)
const TEAMS_ICON = ``;
function entraUserLink(id, name) {
const url = `https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/${encodeURIComponent(id)}`;
return `${ENTRA_ICON}`;
}
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 `${ENTRA_ICON}`;
}
function intuneAppLink(id, name) {
const url = `https://intune.microsoft.com/#view/Microsoft_Intune_Apps/SettingsMenu/~/2/appId/${encodeURIComponent(id)}`;
return `${ENTRA_ICON}`;
}
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 `${MEMBERS_ICON}`;
}
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 `${TEAMS_ICON}`;
}
// =============================================================
// 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 `