diff --git a/src/PolicyIO.ps1 b/src/PolicyIO.ps1 index eda598c..618cc12 100644 --- a/src/PolicyIO.ps1 +++ b/src/PolicyIO.ps1 @@ -706,27 +706,82 @@ function Invoke-PolicyAssignEndpoint { $name = [string](Get-PolicyProp $it 'policyName') $include = @(Get-PolicyProp $it 'include') | Where-Object { $_ } $exclude = @(Get-PolicyProp $it 'exclude') | Where-Object { $_ } + # Integrierte (virtuelle) Include-Ziele: Alle Geraete / Alle Benutzer. + $allDevices = [bool](Get-PolicyProp $it 'allDevices') + $allUsers = [bool](Get-PolicyProp $it 'allUsers') + # Modus: 'replace' (Default, Full-Replace wie bisher) oder 'add' (bestehende + # Zuweisungen erhalten und die neuen Gruppen dazu mergen). Die Graph-/assign- + # Action ersetzt IMMER die komplette Liste -> fuer 'add' muessen die + # bestehenden Zuweisungen vorher gelesen und mitgeschickt werden. + $mode = ([string](Get-PolicyProp $it 'mode')).ToLower() + if ($mode -ne 'add') { $mode = 'replace' } $cfg = Get-PolicyTypeConfigByExportType $exportType if (-not $cfg) { $results += @{ id = $id; policyName = $name; success = $false; error = "Unbekannter exportType: $exportType" }; continue } if (-not $id) { $results += @{ policyName = $name; success = $false; error = 'Policy-Id fehlt' }; continue } - if (@($include).Count -eq 0 -and @($exclude).Count -eq 0) { + if (@($include).Count -eq 0 -and @($exclude).Count -eq 0 -and -not $allDevices -and -not $allUsers) { $results += @{ id = $id; policyName = $name; success = $true; skipped = $true } continue } $assignments = @() + $seen = @{} # Dedup-Key "odataType|groupId" -> $true + + if ($mode -eq 'add') { + # Bestehende Zuweisungen lesen und 1:1 uebernehmen (inkl. evtl. Filter/ + # allDevices/allLicensedUsers). Schlaegt das Lesen fehl, brechen wir fuer + # diese Policy ab, statt versehentlich bestehende Zuweisungen zu loeschen. + try { + $existing = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$id/assignments" -Method GET + foreach ($a in @($existing.value)) { + $t = $a.target + if (-not $t) { continue } + $ot = [string]$t.'@odata.type' + $gid = [string]$t.groupId + $key = "$ot|$gid" + if ($seen[$key]) { continue } + $seen[$key] = $true + $tgt = @{ '@odata.type' = $ot } + if ($gid) { $tgt['groupId'] = $gid } + foreach ($fld in @('deviceAndAppManagementAssignmentFilterId','deviceAndAppManagementAssignmentFilterType')) { + $v = $t.$fld + if ($null -ne $v -and [string]$v -ne '') { $tgt[$fld] = $v } + } + $assignments += @{ target = $tgt } + } + } catch { + $em = $_.Exception.Message + try { if ($_.ErrorDetails.Message) { $em = $_.ErrorDetails.Message } } catch {} + $results += @{ id = $id; policyName = $name; success = $false; error = "Bestehende Zuweisungen nicht lesbar (Hinzufuegen-Modus): $em" } + continue + } + } + foreach ($g in $include) { + $key = "#microsoft.graph.groupAssignmentTarget|$g" + if ($seen[$key]) { continue } + $seen[$key] = $true $assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = [string]$g } } } foreach ($g in $exclude) { + $key = "#microsoft.graph.exclusionGroupAssignmentTarget|$g" + if ($seen[$key]) { continue } + $seen[$key] = $true $assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.exclusionGroupAssignmentTarget'; groupId = [string]$g } } } + if ($allDevices) { + $key = '#microsoft.graph.allDevicesAssignmentTarget|' + if (-not $seen[$key]) { $seen[$key] = $true; $assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' } } } + } + if ($allUsers) { + $key = '#microsoft.graph.allLicensedUsersAssignmentTarget|' + if (-not $seen[$key]) { $seen[$key] = $true; $assignments += @{ target = @{ '@odata.type' = '#microsoft.graph.allLicensedUsersAssignmentTarget' } } } + } $json = @{ assignments = @($assignments) } | ConvertTo-Json -Depth 10 $uri = "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$id/assign" try { Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body $json -ContentType 'application/json' | Out-Null - $results += @{ id = $id; policyName = $name; success = $true; includeCount = @($include).Count; excludeCount = @($exclude).Count } + $results += @{ id = $id; policyName = $name; success = $true; mode = $mode; includeCount = @($include).Count; excludeCount = @($exclude).Count } } catch { $m = $_.Exception.Message try { if ($_.ErrorDetails.Message) { $m = $_.ErrorDetails.Message } } catch {} diff --git a/www/app.js b/www/app.js index 69f7c28..df80017 100644 --- a/www/app.js +++ b/www/app.js @@ -6409,6 +6409,12 @@ function polUpdateSelCount() { const n = keys.length; document.getElementById('polSelCount').textContent = n; document.getElementById('btnPolExport').disabled = n === 0; + const btnA = document.getElementById('btnPolAssignGroups'); + if (btnA) { + btnA.disabled = n === 0; + const c = document.getElementById('polAssignSelCount'); + if (c) c.textContent = n; + } // Konsolidieren nur bei >=2 Policies UND alle vom Typ Settings Catalog. const btnC = document.getElementById('btnPolConsolidate'); if (btnC) { @@ -6843,6 +6849,168 @@ document.getElementById('polAssignApply')?.addEventListener('click', async () => } }); +// ============================================================= +// Bulk: mehreren ausgewählten Policies dieselben Gruppen zuweisen +// ============================================================= +const POL_TYPE_TO_EXPORT = { + settingscatalog: 'SettingsCatalog', + compliance: 'CompliancePolicy', + configuration: 'ConfigurationProfile', + administrativetemplate: 'AdministrativeTemplate', +}; +const PolBulkAssign = { policies: [], include: [], exclude: [] }; +let _polBulkSearchTimer = null; + +function openPolBulkAssign() { + const sel = PolState.items.filter(p => PolState.selected.has(polKey(p))); + if (!sel.length) return; + PolBulkAssign.policies = sel.map(p => ({ + id: p.id, + name: p.name || '(ohne Name)', + exportType: POL_TYPE_TO_EXPORT[p.type] || '', + })); + PolBulkAssign.include = []; + PolBulkAssign.exclude = []; + document.getElementById('polBulkCount').textContent = sel.length; + document.getElementById('polBulkNames').innerHTML = PolBulkAssign.policies.map(p => + `${escapeHtml(p.name)} ${escapeHtml(POL_TYPE_LABELS[p.exportType] || 'Policy')}` + ).join(''); + const addRadio = document.querySelector('input[name="polBulkMode"][value="add"]'); + if (addRadio) addRadio.checked = true; + const ad = document.getElementById('polBulkAllDevices'); if (ad) ad.checked = false; + const au = document.getElementById('polBulkAllUsers'); if (au) au.checked = false; + renderPolBulkChips('include'); + renderPolBulkChips('exclude'); + updatePolBulkApply(); + openModal('modalPolBulkAssign'); +} + +function renderPolBulkChips(kind) { + const box = document.querySelector(`.pol-assign-chips[data-scope="bulk"][data-kind="${kind}"]`); + if (!box) return; + box.innerHTML = PolBulkAssign[kind].map((g, j) => ` + + ${escapeHtml(g.name)} + + `).join(''); + updatePolBulkApply(); +} + +function updatePolBulkApply() { + const inc = PolBulkAssign.include.length, exc = PolBulkAssign.exclude.length; + const allDev = document.getElementById('polBulkAllDevices')?.checked || false; + const allUsr = document.getElementById('polBulkAllUsers')?.checked || false; + const has = inc + exc > 0 || allDev || allUsr; + const btn = document.getElementById('polBulkApply'); + if (btn) btn.disabled = !has; + const info = document.getElementById('polBulkInfo'); + if (info) { + if (!has) { info.textContent = 'Mindestens eine Gruppe oder ein integriertes Ziel wählen.'; } + else { + const parts = []; + if (allDev) parts.push('Alle Geräte'); + if (allUsr) parts.push('Alle Benutzer'); + if (inc) parts.push(`${inc} Include`); + if (exc) parts.push(`${exc} Exclude`); + info.textContent = parts.join(' · '); + } + } +} + +function polBulkHideDropdowns() { + document.querySelectorAll('.pol-bulk-dropdown').forEach(d => { d.classList.add('hidden'); d.innerHTML = ''; }); +} + +// Gruppensuche (debounced) im Bulk-Modal +document.getElementById('modalPolBulkAssign')?.addEventListener('input', e => { + const input = e.target.closest('.pol-bulk-search'); + if (!input) return; + const kind = input.dataset.kind; + const dd = document.querySelector(`.pol-bulk-dropdown[data-kind="${kind}"]`); + const q = input.value.trim(); + clearTimeout(_polBulkSearchTimer); + if (q.length < 2) { if (dd) { dd.classList.add('hidden'); dd.innerHTML = ''; } return; } + if (dd) { dd.classList.remove('hidden'); dd.innerHTML = '
Suche…
'; } + _polBulkSearchTimer = 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 = '
Keine Gruppen gefunden.
'; return; } + dd.innerHTML = items.map(g => ` +
+
${escapeHtml(g.DisplayName)}
+ ${g.Description ? `
${escapeHtml(g.Description)}
` : ''} +
`).join(''); + } catch (err) { + if (dd) dd.innerHTML = `
Suche fehlgeschlagen: ${escapeHtml(err.message)}
`; + } + }, 300); +}); + +// Option wählen / Chip entfernen im Bulk-Modal +document.getElementById('modalPolBulkAssign')?.addEventListener('click', e => { + const opt = e.target.closest('.pol-bulk-dropdown .opt'); + if (opt) { + const dd = opt.closest('.pol-bulk-dropdown'); + const kind = dd.dataset.kind; + const id = opt.dataset.id, name = opt.dataset.name; + const other = kind === 'include' ? 'exclude' : 'include'; + if (!PolBulkAssign[kind].some(g => g.id === id) && !PolBulkAssign[other].some(g => g.id === id)) { + PolBulkAssign[kind].push({ id, name }); + renderPolBulkChips(kind); + } + const input = document.querySelector(`.pol-bulk-search[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 kind = rm.dataset.kind, j = +rm.dataset.j; + PolBulkAssign[kind].splice(j, 1); + renderPolBulkChips(kind); + } +}); +document.addEventListener('click', e => { + if (!e.target.closest('#modalPolBulkAssign .pol-assign-search-wrap')) polBulkHideDropdowns(); +}); + +document.getElementById('polBulkAllDevices')?.addEventListener('change', updatePolBulkApply); +document.getElementById('polBulkAllUsers')?.addEventListener('change', updatePolBulkApply); + +document.getElementById('polBulkApply')?.addEventListener('click', async () => { + const inc = PolBulkAssign.include.map(g => g.id); + const exc = PolBulkAssign.exclude.map(g => g.id); + const allDevices = document.getElementById('polBulkAllDevices')?.checked || false; + const allUsers = document.getElementById('polBulkAllUsers')?.checked || false; + if (!inc.length && !exc.length && !allDevices && !allUsers) return; + const mode = (document.querySelector('input[name="polBulkMode"]:checked')?.value) === 'replace' ? 'replace' : 'add'; + const items = PolBulkAssign.policies.map(p => ({ + exportType: p.exportType, id: p.id, policyName: p.name, + include: inc, exclude: exc, allDevices, allUsers, mode, + })); + setLoading('Weise Gruppen zu…'); + try { + const res = await api('/api/policies/assign', { method: 'POST', body: { items }, timeoutMs: 180000 }); + 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(`Gruppen ${mode === 'add' ? 'hinzugefügt' : 'ersetzt'}: ${res.assignedCount || 0} Policy(s).`, 'ok', 'Zuweisung'); + } + closeModal('modalPolBulkAssign'); + } catch (e) { + toast('Zuweisung fehlgeschlagen: ' + e.message, 'err'); + } finally { + clearLoading(); + } +}); + +document.getElementById('btnPolAssignGroups')?.addEventListener('click', openPolBulkAssign); + // Alle Policies als Snapshot in den konfigurierten Git-Ordner schreiben + committen. async function polGitSnapshot() { setLoading('Erstelle Policy-Snapshot & committe…'); diff --git a/www/index.html b/www/index.html index e527d0c..f5566e8 100644 --- a/www/index.html +++ b/www/index.html @@ -619,6 +619,10 @@ Git-Snapshot + + + + + + +