Policy-Import haerten, Admin-Vorlagen, Zuweisung nach Import, Git-Snapshot

Neu:
- Administrative Vorlagen (ADMX / groupPolicyConfigurations) als vierter
  Policy-Typ fuer Export/Import (GET /api/policies/administrativetemplate)
- Zuweisung direkt nach Import: pro Policy Include-/Exclude-Gruppen
  (POST /api/policies/assign, typ-spezifische /assign-Action)
- Git-Snapshot: versioniertes Policy-Backup in lokalen Git-Ordner
  (Settings-Sektion policyBackup)
- Import akzeptiert Git-Snapshot-Dateien (policyType neben exportType)

Behoben:
- Settings-Catalog-Import schema-konform: Collection-Properties immer als
  Array (repariert PS-5.1-Roundtrip), null -> [], Wrapper-@odata.type,
  read-only id entfernt
- Import-Phantom-Fehler (@($null)-Geisterdurchlauf im Archiv-Pfad)
- Frontend-Cache-Reset beim Tenant-Wechsel (resetClientState)
- WAM-Anmeldefenster zuverlaessig im Vordergrund (AttachThreadInput)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 11:25:19 +02:00
co-authored by Claude Opus 4.8
parent 450428e7f7
commit 4efc71376b
7 changed files with 790 additions and 31 deletions
+237 -15
View File
@@ -194,9 +194,11 @@ document.getElementById('tenantSwitch')?.addEventListener('change', async e => {
State.readOnly = !!s.readOnly;
applyReadOnlyMode();
renderConnection();
// Frontend-Caches anderer Tabs verwerfen (anderer Tenant = andere Daten).
if (typeof ReportState !== 'undefined') ReportState.items = [];
if (typeof PolState !== 'undefined') { PolState.items = []; PolState.selected?.clear?.(); }
// 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');
@@ -345,17 +347,8 @@ document.getElementById('btnDisconnect').addEventListener('click', async () => {
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();
Object.assign(State, { connected: false, account: null });
resetClientState();
renderConnection();
renderTargets();
renderPinnedTargets();
@@ -368,6 +361,25 @@ document.getElementById('btnDisconnect').addEventListener('click', async () => {
}
});
// 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 = [];
@@ -4119,6 +4131,11 @@ function fillSettingsForm(s) {
document.getElementById('setAvailSuffix').value = av.suffix || '';
updateAvailPreview();
// Policy-Backup (Git)
const pb = s.policyBackup || {};
document.getElementById('setPolicyGitPath').value = pb.gitRepoPath || '';
document.getElementById('setPolicyGitPush').checked = !!pb.push;
// Branding-Logo Preview
fillLogoPreview(s.branding);
@@ -4219,6 +4236,10 @@ function readSettingsForm() {
detailPanel: document.getElementById('setColDetailPanelHex').value.trim() || ThemeDefaults.detailPanel,
},
},
policyBackup: {
gitRepoPath: document.getElementById('setPolicyGitPath').value.trim(),
push: document.getElementById('setPolicyGitPush').checked,
},
};
}
@@ -5751,6 +5772,7 @@ async function loadPolicies() {
['/api/policies/settingscatalog', 'Settings Catalog'],
['/api/policies/compliance', 'Compliance'],
['/api/policies/configuration', 'Konfigurationsprofile'],
['/api/policies/administrativetemplate', 'Administrative Vorlagen'],
];
const settled = await Promise.allSettled(endpoints.map(([url]) => api(url)));
let items = [];
@@ -5891,7 +5913,8 @@ async function polExportSelected() {
// Datei-Inhalt -> Liste von Envelopes { exportType, policy, ... }
function polExtractEnvelopes(parsed, fileName) {
if (parsed && Array.isArray(parsed.policies)) return parsed.policies;
if (parsed && parsed.exportType && parsed.policy) return [parsed];
// Beide Typ-Felder akzeptieren: exportType (Export-Download) + policyType (Git-Snapshot).
if (parsed && (parsed.exportType || parsed.policyType) && parsed.policy) return [parsed];
if (parsed && parsed.policy) return [parsed]; // nachsichtig
toast(`${fileName}: unbekanntes Policy-Format.`, 'err');
return [];
@@ -5922,6 +5945,9 @@ async function polHandleImportFiles(fileList) {
toast(`${ok.length} Policy(s) importiert.`, 'ok', 'Import');
}
await loadPolicies();
// Nach erfolgreichem Import optional Gruppen zuweisen (pro Policy).
const assignable = ok.filter(r => r.newId);
if (assignable.length && !State.readOnly) openPolAssignModal(assignable);
} catch (e) {
toast('Import fehlgeschlagen: ' + e.message, 'err');
} finally {
@@ -5929,8 +5955,204 @@ async function polHandleImportFiles(fileList) {
}
}
// =============================================================
// Post-Import: Policies pro Stück Include-/Exclude-Gruppen zuweisen
// =============================================================
const PolAssignState = { policies: [] };
const POL_TYPE_LABELS = {
SettingsCatalog: 'Settings Catalog',
CompliancePolicy: 'Compliance',
ConfigurationProfile: 'Konfigurationsprofil',
AdministrativeTemplate: 'Administrative Vorlage',
};
function openPolAssignModal(assignable) {
PolAssignState.policies = assignable.map(r => ({
id: r.newId,
name: r.policyName || '(ohne Name)',
exportType: r.exportType || '',
include: [],
exclude: [],
}));
renderPolAssignList();
openModal('modalPolAssign');
}
function renderPolAssignList() {
const list = document.getElementById('polAssignList');
if (!list) return;
list.innerHTML = PolAssignState.policies.map((p, i) => `
<div class="pol-assign-row" data-idx="${i}">
<div class="pol-assign-head">
<span class="pol-name">${escapeHtml(p.name)}</span>
<span class="pol-type-badge">${escapeHtml(POL_TYPE_LABELS[p.exportType] || p.exportType || 'Policy')}</span>
</div>
<div class="pol-assign-pickers">
${['include', 'exclude'].map(kind => `
<div class="pol-assign-picker" data-kind="${kind}">
<label class="lbl">${kind === 'include' ? 'Include-Gruppen' : 'Exclude-Gruppen'}</label>
<div class="pol-assign-chips" data-idx="${i}" data-kind="${kind}"></div>
<div class="pol-assign-search-wrap">
<input type="text" class="input pol-assign-search" data-idx="${i}" data-kind="${kind}" placeholder="Gruppe suchen…" autocomplete="off">
<div class="pol-assign-dropdown hidden" data-idx="${i}" data-kind="${kind}"></div>
</div>
</div>
`).join('')}
</div>
</div>
`).join('');
PolAssignState.policies.forEach((_, i) => {
renderPolAssignChips(i, 'include');
renderPolAssignChips(i, 'exclude');
});
updatePolAssignInfo();
}
function renderPolAssignChips(idx, kind) {
const box = document.querySelector(`.pol-assign-chips[data-idx="${idx}"][data-kind="${kind}"]`);
if (!box) return;
const arr = PolAssignState.policies[idx][kind];
box.innerHTML = arr.map((g, j) => `
<span class="pol-assign-chip ${kind === 'exclude' ? 'exclude' : ''}">
${escapeHtml(g.name)}
<button type="button" data-idx="${idx}" data-kind="${kind}" data-j="${j}" title="Entfernen">×</button>
</span>
`).join('');
updatePolAssignInfo();
}
function updatePolAssignInfo() {
const info = document.getElementById('polAssignInfo');
if (!info) return;
const n = PolAssignState.policies.filter(p => p.include.length || p.exclude.length).length;
info.textContent = n ? `${n} Policy(s) mit Zuweisung` : 'Keine Zuweisung gewählt';
}
let _polAssignSearchTimer = null;
function polAssignHideDropdowns() {
document.querySelectorAll('.pol-assign-dropdown').forEach(d => { d.classList.add('hidden'); d.innerHTML = ''; });
}
// Suche (debounced) — Event-Delegation auf der Liste.
document.getElementById('polAssignList')?.addEventListener('input', e => {
const input = e.target.closest('.pol-assign-search');
if (!input) return;
const idx = input.dataset.idx, kind = input.dataset.kind;
const dd = document.querySelector(`.pol-assign-dropdown[data-idx="${idx}"][data-kind="${kind}"]`);
const q = input.value.trim();
clearTimeout(_polAssignSearchTimer);
if (q.length < 2) { if (dd) { dd.classList.add('hidden'); dd.innerHTML = ''; } return; }
if (dd) { dd.classList.remove('hidden'); dd.innerHTML = '<div class="msg">Suche…</div>'; }
_polAssignSearchTimer = setTimeout(async () => {
try {
const data = await api(`/api/groups/search?q=${encodeURIComponent(q)}`);
const items = (data.items || []).slice(0, 25);
if (!dd) return;
if (!items.length) { dd.innerHTML = '<div class="msg">Keine Gruppen gefunden.</div>'; return; }
dd.innerHTML = items.map(g => `
<div class="opt" data-id="${escapeHtml(g.Id)}" data-name="${escapeHtml(g.DisplayName)}">
<div>${escapeHtml(g.DisplayName)}</div>
${g.Description ? `<div class="desc">${escapeHtml(g.Description)}</div>` : ''}
</div>`).join('');
} catch (err) {
if (dd) dd.innerHTML = `<div class="msg">Suche fehlgeschlagen: ${escapeHtml(err.message)}</div>`;
}
}, 300);
});
// Klick auf ein Suchergebnis -> als Chip hinzufügen. Klick auf Chip-× -> entfernen.
document.getElementById('polAssignList')?.addEventListener('click', e => {
const opt = e.target.closest('.pol-assign-dropdown .opt');
if (opt) {
const dd = opt.closest('.pol-assign-dropdown');
const idx = +dd.dataset.idx, kind = dd.dataset.kind;
const id = opt.dataset.id, name = opt.dataset.name;
const p = PolAssignState.policies[idx];
const other = kind === 'include' ? 'exclude' : 'include';
// Nicht doppelt, und nicht gleichzeitig include+exclude derselben Gruppe.
if (!p[kind].some(g => g.id === id) && !p[other].some(g => g.id === id)) {
p[kind].push({ id, name });
renderPolAssignChips(idx, kind);
}
const input = document.querySelector(`.pol-assign-search[data-idx="${idx}"][data-kind="${kind}"]`);
if (input) input.value = '';
dd.classList.add('hidden'); dd.innerHTML = '';
return;
}
const rm = e.target.closest('.pol-assign-chip button');
if (rm) {
const idx = +rm.dataset.idx, kind = rm.dataset.kind, j = +rm.dataset.j;
PolAssignState.policies[idx][kind].splice(j, 1);
renderPolAssignChips(idx, kind);
}
});
document.addEventListener('click', e => {
if (!e.target.closest('.pol-assign-search-wrap')) polAssignHideDropdowns();
});
document.getElementById('polAssignApply')?.addEventListener('click', async () => {
const items = PolAssignState.policies
.filter(p => p.include.length || p.exclude.length)
.map(p => ({
exportType: p.exportType,
id: p.id,
policyName: p.name,
include: p.include.map(g => g.id),
exclude: p.exclude.map(g => g.id),
}));
if (!items.length) { closeModal('modalPolAssign'); return; }
setLoading('Weise Gruppen zu…');
try {
const res = await api('/api/policies/assign', { method: 'POST', body: { items }, timeoutMs: 120000 });
const results = res.results || [];
const fail = results.filter(r => !r.success);
if (fail.length) {
const first = fail[0];
toast(`${res.assignedCount || 0} zugewiesen, ${fail.length} fehlgeschlagen. z.B. "${first.policyName || '?'}": ${first.error || 'Fehler'}`, 'err', 'Zuweisung');
} else {
toast(`${res.assignedCount || 0} Policy(s) zugewiesen.`, 'ok', 'Zuweisung');
}
closeModal('modalPolAssign');
} catch (e) {
toast('Zuweisung fehlgeschlagen: ' + e.message, 'err');
} finally {
clearLoading();
}
});
// Alle Policies als Snapshot in den konfigurierten Git-Ordner schreiben + committen.
async function polGitSnapshot() {
setLoading('Erstelle Policy-Snapshot & committe…');
try {
const res = await api('/api/policies/git-snapshot', { method: 'POST', timeoutMs: 300000 });
if (res.changed === false) {
toast(res.message || 'Keine Änderungen seit dem letzten Snapshot.', 'info', 'Git-Snapshot');
} else {
let msg = res.message || `Snapshot committet (${res.total} Policies).`;
if (res.pushError) msg += ` — Push-Fehler: ${res.pushError}`;
toast(msg, res.pushError ? 'warn' : 'ok', 'Git-Snapshot');
}
} catch (e) {
toast('Snapshot fehlgeschlagen: ' + e.message, 'err');
} finally {
clearLoading();
}
}
// Ordner-Auswahl für den Git-Repo-Pfad (Einstellungen).
async function pickPolicyGitFolder() {
try {
const res = await api('/api/pickfolder', { method: 'POST', body: { title: 'Git-Repo-Ordner für Policy-Backup wählen' } });
if (res && res.path) document.getElementById('setPolicyGitPath').value = res.path;
} catch (e) {
toast('Ordner-Auswahl fehlgeschlagen: ' + e.message, 'err');
}
}
document.getElementById('btnPickPolicyGit')?.addEventListener('click', pickPolicyGitFolder);
// --- Policies: Event-Wiring ---
document.getElementById('btnPolRefresh')?.addEventListener('click', loadPolicies);
document.getElementById('btnPolGitSnapshot')?.addEventListener('click', polGitSnapshot);
document.getElementById('btnPolExport')?.addEventListener('click', polExportSelected);
document.getElementById('btnPolImport')?.addEventListener('click', () => document.getElementById('polImportFile').click());
document.getElementById('polImportFile')?.addEventListener('change', e => {