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 => {
+70
View File
@@ -572,6 +572,7 @@
<option value="settingscatalog">Settings Catalog</option>
<option value="compliance">Compliance</option>
<option value="configuration">Konfigurationsprofil</option>
<option value="administrativetemplate">Administrative Vorlage</option>
</select>
<span class="pol-toolbar-spacer" style="flex:1;"></span>
<button class="btn btn-secondary btn-sm" id="btnPolRefresh" title="Policies neu laden">
@@ -583,6 +584,10 @@
Import…
</button>
<input type="file" id="polImportFile" accept=".json,application/json" multiple style="display:none;">
<button class="btn btn-secondary btn-sm" id="btnPolGitSnapshot" title="Alle Policies als Snapshot in den konfigurierten Git-Ordner schreiben und committen">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><line x1="12" y1="3" x2="12" y2="9"/><line x1="12" y1="15" x2="12" y2="21"/><circle cx="12" cy="3" r="1"/><circle cx="12" cy="21" r="1"/></svg>
Git-Snapshot
</button>
<button class="btn btn-primary btn-sm" id="btnPolExport" disabled title="Ausgewählte Policies als JSON exportieren">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export (<span id="polSelCount">0</span>)
@@ -808,6 +813,52 @@
</div>
</div>
<!-- MODAL: Policy-Zuweisung nach Import -->
<div id="modalPolAssign" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box modal-lg">
<div class="modal-head">
<h3>Importierte Policies zuweisen</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<div class="form-hint" style="margin-bottom:12px;">
Optional: jeder importierten Policy Include- und/oder Exclude-Gruppen im aktuellen Tenant zuweisen.
Felder leer lassen = keine Zuweisung. Original-Zuweisungen des Quell-Tenants werden nicht übernommen.
</div>
<div id="polAssignList"></div>
</div>
<div class="modal-foot">
<span id="polAssignInfo" class="muted" style="margin-right:auto;"></span>
<button class="btn btn-secondary" data-close>Überspringen</button>
<button class="btn btn-primary" id="polAssignApply">Zuweisen</button>
</div>
</div>
</div>
<style>
.pol-assign-row { border:1px solid var(--border, #d9dce3); border-radius:8px; padding:12px 14px; margin-bottom:12px; }
.pol-assign-head { display:flex; align-items:center; gap:8px; margin-bottom:10px; }
.pol-assign-head .pol-name { font-weight:600; }
.pol-assign-pickers { display:flex; gap:16px; flex-wrap:wrap; }
.pol-assign-picker { flex:1; min-width:220px; }
.pol-assign-picker > .lbl { display:block; font-size:12px; margin-bottom:4px; opacity:.85; }
.pol-assign-picker[data-kind="exclude"] > .lbl { color:#cb2a7a; }
.pol-assign-chips { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:6px; }
.pol-assign-chip { display:inline-flex; align-items:center; gap:6px; padding:2px 8px; border-radius:12px;
background:var(--chip-bg, #eef0f5); font-size:12px; }
.pol-assign-chip.exclude { background:#fbe3ef; }
.pol-assign-chip button { border:none; background:none; cursor:pointer; font-size:14px; line-height:1; padding:0; opacity:.6; }
.pol-assign-chip button:hover { opacity:1; }
.pol-assign-search-wrap { position:relative; }
.pol-assign-dropdown { position:absolute; z-index:20; left:0; right:0; top:100%; margin-top:2px;
background:var(--panel, #fff); border:1px solid var(--border, #d9dce3); border-radius:6px;
max-height:200px; overflow-y:auto; box-shadow:0 6px 18px rgba(0,0,0,.12); }
.pol-assign-dropdown .opt { padding:6px 10px; cursor:pointer; font-size:13px; }
.pol-assign-dropdown .opt:hover { background:var(--chip-bg, #eef0f5); }
.pol-assign-dropdown .opt .desc { font-size:11px; opacity:.6; }
.pol-assign-dropdown .msg { padding:6px 10px; font-size:12px; opacity:.6; }
</style>
<!-- MODAL: Hilfe -->
<div id="modalHelp" class="modal hidden">
<div class="modal-backdrop" data-close></div>
@@ -1033,6 +1084,25 @@
<div class="form-hint">Vorschau: <code id="setAvailPreview" class="set-preview"></code></div>
</section>
<section class="settings-sec">
<div class="settings-sec-head">
<h4>Policy-Backup (Git)</h4>
<div class="settings-sec-sub">Lokaler Git-Ordner für Policy-Snapshots. Der „Git-Snapshot"-Button im Policies-Tab schreibt alle Policies als JSON dorthin und committet sie (optional Push).</div>
</div>
<div class="form-group">
<label class="lbl" for="setPolicyGitPath">Git-Repo-Ordner (lokal)</label>
<div class="form-row" style="gap:8px;align-items:center;">
<input type="text" id="setPolicyGitPath" class="input mono" placeholder="z. B. C:\Intune-Backup" spellcheck="false" autocomplete="off" style="flex:1;">
<button type="button" class="btn btn-secondary btn-sm" id="btnPickPolicyGit">Ordner…</button>
</div>
<div class="form-hint">Wird bei Bedarf angelegt und als Git-Repo initialisiert. Leer = Funktion deaktiviert.</div>
</div>
<div class="form-group">
<label class="check-chip"><input type="checkbox" id="setPolicyGitPush"><span>Nach dem Commit automatisch <code>git push</code></span></label>
<div class="form-hint">Nutzt deinen vorhandenen Git-Credential-Helper (Remote muss im Repo konfiguriert sein). Kein Token in der App.</div>
</div>
</section>
<section class="settings-sec">
<div class="settings-sec-head">
<h4>Branding — Logo</h4>