diff --git a/CHANGELOG.md b/CHANGELOG.md index 248a9c2..578d978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/) — solan --- +## [0.1.31] - 2026-09-11 + +Policy-Konsolidierung, Geräte-Export nach Gruppe, Gruppen-in-Gruppen, Geräte-Fixes. + +### Neu + +- **Policies zusammenführen** (Settings Catalog): Mehrere Settings-Catalog-Policies zu **einer neuen** Policy vereinen. Gleiche Einstellungen werden einmal übernommen, **Konflikte** (gleiche `settingDefinitionId`, anderer Wert) werden vor dem Anlegen angezeigt und pro Konflikt aufgelöst. Button „Konsolidieren" im Policies-Tab (ab 2 gewählten Settings-Catalog-Policies). Neuer Endpoint `POST /api/policies/consolidate`. +- **Geräte nach Gruppe exportieren**: Im **Geräte**-Tab neuer **Gruppen-Filter** (Live-Suche) — die Liste zeigt nur die Geräte der Gruppen-User (inkl. verschachtelter Gruppen) — plus **CSV-Export** der aktuell angezeigten Liste. Kombinierbar mit Suche/OS/Compliance. Neuer Endpoint `GET /api/groups/{id}/devices/export`. +- **Gruppen zu Gruppen hinzufügen**: Im Group-Management-Tab „Benutzer | Gruppen hinzufügen" ein Umschalter **Benutzer / Gruppen** — eine bestehende Gruppe als Mitglied (verschachtelte Gruppe) zur gewählten Gruppe hinzufügen. + +### Behoben + +- **Geräte „Invalid Date"**: `lastSyncDateTime`/`enrolledDateTime` werden jetzt server-seitig als ISO 8601 normalisiert (`ConvertTo-IsoDate`) — vorher kam unter PowerShell 5.1 ein nicht parsbares Format an. Anzeige zusätzlich robust (ungültig → „—"). +- **Geräte-Sortierung**: Die Spalten-Sortierung war komplett wirkungslos (Header-Feldnamen ≠ Item-Properties). Jetzt sortieren alle Spalten korrekt, **Datums-Spalten numerisch** nach Zeitstempel (Klick auf „Letzter Sync" / „Eingeschrieben"). + +--- + ## [0.1.30] - 2026-09-03 Policy-Bulk-Import gehärtet, macOS-Apps, Gruppen-Suche & Direkt-Zuweisung an Apps. diff --git a/README.md b/README.md index 90dd7d6..d677134 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Schlaegt diese fehl, wird automatisch auf die **Read-Only-App-ID** (`clientIdRo` - App-Zuweisung (Required / Available hinzufuegen und entfernen) - Gruppen erstellen und loeschen -- Tab „Benutzer hinzufuegen" im Group Management +- Tab „Benutzer | Gruppen hinzufuegen" im Group Management - Tab „CSV-Import" im Group Management - Session-Ausfuehren-Button - Alle Rename-/Delete-Buttons @@ -132,10 +132,10 @@ App-Registrierung automatisch anlegt/konfiguriert:** | Tab | Beschreibung | |--------------------|-------------------------------------------------------------| | App Management | Haupt-Ansicht: Apps laden, Gruppen zuweisen / entfernen | -| Group Management | Mitglieder anzeigen, exportieren, Benutzer hinzufuegen, CSV-Import | +| Group Management | Mitglieder anzeigen/exportieren, Benutzer **und Gruppen** hinzufuegen, CSV-Import | | App Report | Installationszaehler pro App, Geraete-Export als CSV | -| Geräte | Geraete suchen, Detail-Panel, Aktionen (Sync, Wipe, …) | -| Policies | Policies exportieren (JSON-Download) und importieren (Neuanlage) | +| Geräte | Geraete suchen, Filter (auch nach **Gruppe**), CSV-Export, Detail-Panel, Aktionen (Sync, Wipe, …) | +| Policies | Policies exportieren/importieren (Neuanlage) und **zusammenfuehren** (Settings Catalog) | --- @@ -148,10 +148,11 @@ App-Registrierung automatisch anlegt/konfiguriert:** - Filter zum Einschraenken der Anzeige - **Als CSV exportieren** laedt alle Mitglieder als `members--.csv` -### Benutzer hinzufuegen *(nur Read/Write)* +### Benutzer | Gruppen hinzufuegen *(nur Read/Write)* -- Benutzer per Name, UPN oder E-Mail suchen -- Mehrere Benutzer auswaehlen und gemeinsam hinzufuegen +- Umschalter **Benutzer / Gruppen** oben im Tab +- Benutzer per Name, UPN oder E-Mail suchen — oder Gruppen per Name (verschachtelte Gruppen) +- Mehrere Eintraege auswaehlen und gemeinsam zur gewaehlten Gruppe hinzufuegen ### CSV-Import *(nur Read/Write)* @@ -235,6 +236,16 @@ neu an. Unterstuetzte Typen: > bekommen bei Bedarf einen Default-`scheduledActionsForRule`-Block, den Graph > beim Anlegen zwingend verlangt. +### Konsolidieren *(nur Read/Write, nur Settings Catalog)* + +- Mindestens zwei **Settings-Catalog**-Policies per Checkbox waehlen → + **„Konsolidieren"** +- Die Einstellungen werden zu **einer neuen** Policy vereint (Neuanlage) — gleiche + `settingDefinitionId` mit gleichem Wert wird einmal uebernommen +- **Konflikte** (gleiche Einstellung, unterschiedlicher Wert) werden vor dem + Anlegen angezeigt; pro Konflikt waehlst du, welche Policy gewinnt +- Voraussetzung: gleiche Plattform. Zuweisungen werden nicht uebernommen. + ### Benoetigt Graph-Berechtigung `DeviceManagementConfiguration.ReadWrite.All` (Import) bzw. @@ -295,8 +306,9 @@ Intune Manager/ | POST | `/api/groups/resolve-upns` | Read | UPN-Liste → UserId + DisplayName | | GET | `/api/groups/{id}/members` | Read | Mitglieder einer Gruppe | | GET | `/api/groups/{id}/members/export` | Read | Mitglieder als CSV | -| POST | `/api/groups/{id}/members` | **Write** | Benutzer zur Gruppe hinzufuegen | -| DELETE | `/api/groups/{id}/members/{uid}` | **Write** | Benutzer aus Gruppe entfernen | +| POST | `/api/groups/{id}/members` | **Write** | Benutzer/Gruppen zur Gruppe hinzufuegen | +| DELETE | `/api/groups/{id}/members/{uid}` | **Write** | Benutzer/Gruppe aus Gruppe entfernen | +| GET | `/api/groups/{id}/devices/export` | Read+MDM | Geraete der Gruppen-User als CSV | | GET | `/api/users?q=` | Read | Benutzer suchen | | GET | `/api/apps` | Read | Apps laden (cached) | | POST | `/api/apps/refresh` | Read | Apps neu laden | @@ -311,6 +323,7 @@ Intune Manager/ | POST | `/api/policies/export` | Read+Cfg | Ausgewaehlte Policies als JSON exportieren | | GET | `/api/policies/exports` | — | Server-Archiv der Exporte auflisten | | POST | `/api/policies/import` | **Write** | Policies als Neuanlage importieren | +| POST | `/api/policies/consolidate` | **Write** | Settings-Catalog-Policies zusammenfuehren | > **Read+MDM** = benoetigt zusaetzlich `DeviceManagementManagedDevices.Read.All` > **Read+Cfg** = benoetigt `DeviceManagementConfiguration.Read.All` (Export) bzw. `.ReadWrite.All` (Import) diff --git a/Start.ps1 b/Start.ps1 index 0fc1ae1..e31b3e7 100644 --- a/Start.ps1 +++ b/Start.ps1 @@ -189,7 +189,7 @@ $script:State = [pscustomobject]@{ Session = @() # geplante Zuweisungen } -$script:ToolVersion = "0.1.30" +$script:ToolVersion = "0.1.31" $script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Write-Host "" diff --git a/installer/build-local.ps1 b/installer/build-local.ps1 index 76083ec..f44d34a 100644 --- a/installer/build-local.ps1 +++ b/installer/build-local.ps1 @@ -12,7 +12,7 @@ if (Test-Path "$x64Dotnet\dotnet.exe") { $env:DOTNET_ROOT = "C:\Program Files\dotnet" } -$version = "0.1.30" +$version = "0.1.31" $src = "\\Mac\Home\ClaudePRJ\Intune Manager" $stage = "$env:TEMP\IntuneManagerStage" $installerDir = "$src\installer" diff --git a/src/Api.ps1 b/src/Api.ps1 index e1162f1..6d2dd9f 100644 --- a/src/Api.ps1 +++ b/src/Api.ps1 @@ -58,6 +58,7 @@ function Invoke-ApiHandler { "GET /api/policies/exports" { return Get-PolicyExportsEndpoint } "POST /api/policies/import" { return Import-PoliciesEndpoint -Body $Body } "POST /api/policies/assign" { return Invoke-PolicyAssignEndpoint -Body $Body } + "POST /api/policies/consolidate" { return Invoke-PolicyConsolidateEndpoint -Body $Body } "POST /api/policies/git-snapshot" { return Invoke-PolicyGitSnapshotEndpoint } "POST /api/pickfolder" { return Invoke-FolderPickerEndpoint -Body $Body } @@ -71,6 +72,9 @@ function Invoke-ApiHandler { if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/members/export$") { return Get-GroupMembersExportEndpoint -GroupId $matches[1] } + if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/devices/export$") { + return Get-GroupDevicesExportEndpoint -GroupId $matches[1] + } if ($Method -eq "GET" -and $Path -match "^/api/groups/([^/]+)/members$") { return Get-GroupMembersEndpoint -GroupId $matches[1] @@ -1608,6 +1612,117 @@ function Get-GroupMembersExportEndpoint { } } +function Get-GroupDevicesExportEndpoint { + # Loest die User einer Gruppe (inkl. verschachtelter Untergruppen) auf und liefert + # deren Intune-Geraete (Geraete, deren PRIMAERER Benutzer in der Gruppe ist) fuer + # einen CSV-Export. Geraete werden per $batch ueber userId eq '' geholt. + param([string]$GroupId) + $err = Test-Connected + if ($err) { return $err } + if ([string]::IsNullOrWhiteSpace($GroupId)) { return @{ __status = 400; error = "GroupId fehlt" } } + + $groupName = $GroupId + try { + $g = Get-GraphGroupById -Id $GroupId -Property @("id","displayName") + if ($g.displayName) { $groupName = [string]$g.displayName } + } catch { + return @{ __status = 404; error = "Gruppe nicht gefunden: $($_.Exception.Message)" } + } + + Write-Host "[GRP-DEV-EXPORT] Geraete-Export fuer Gruppe '$groupName' ($GroupId)" -ForegroundColor Cyan + $sw = [System.Diagnostics.Stopwatch]::StartNew() + + # User aufloesen + deduplizieren (nur eindeutige Entra-User-Ids) + $allUsers = @(Resolve-GroupMembersWithNesting -GroupId $GroupId -GroupName $groupName) + $seen = @{} + $userIds = [System.Collections.Generic.List[string]]::new() + $userMap = @{} # userId -> @{ Upn; DisplayName } (aus den Gruppen-Mitgliedern) + foreach ($u in $allUsers) { + $uid = [string]$u.Id + if ($uid -and -not $seen.ContainsKey($uid)) { + $seen[$uid] = $true + $userIds.Add($uid) + $userMap[$uid] = @{ Upn = [string]$u.UserPrincipalName; DisplayName = [string]$u.DisplayName } + } + } + if ($userIds.Count -eq 0) { + return @{ ok = $true; groupId = $GroupId; groupName = $groupName; userCount = 0; items = @(); count = 0; resolvedMs = [int]$sw.ElapsedMilliseconds } + } + + $select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,serialNumber,model,manufacturer,lastSyncDateTime,enrolledDateTime' + $batchSize = 20 + $devSeen = @{} + $items = [System.Collections.Generic.List[object]]::new() + $ids = $userIds.ToArray() + $script:GrpDevErrors = @() + + for ($i = 0; $i -lt $ids.Count; $i += $batchSize) { + $chunk = $ids[$i .. [Math]::Min($i + $batchSize - 1, $ids.Count - 1)] + $requests = @() + for ($j = 0; $j -lt $chunk.Count; $j++) { + # Kanonischer Weg: die managedDevices-Navigation des Users. Zuverlaessiger + # als $filter=userId eq '..' auf /deviceManagement/managedDevices. + $requests += @{ id = [string]($i + $j); method = 'GET'; url = "/users/$($chunk[$j])/managedDevices?`$select=$select" } + } + $body = @{ requests = $requests } | ConvertTo-Json -Depth 5 -Compress + try { + $resp = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/$batch' -Method POST -Body $body -ContentType 'application/json' + foreach ($r in @($resp.responses)) { + if ([int]$r.status -ne 200) { + if (@($script:GrpDevErrors).Count -lt 3) { + $em = $null + try { $em = [string]$r.body.error.message } catch {} + $script:GrpDevErrors += "HTTP $($r.status)$(if ($em) { ": $em" })" + } + continue + } + # Batch-Request-Id -> Index in $ids -> Gruppen-User (fuer UPN/Name-Fallback, + # da managedDevice.userPrincipalName bei Graph oft leer ist). + $owner = $null + $reqIdx = -1; if ([int]::TryParse([string]$r.id, [ref]$reqIdx)) { + if ($reqIdx -ge 0 -and $reqIdx -lt $ids.Count) { $owner = $userMap[$ids[$reqIdx]] } + } + foreach ($d in @($r.body.value)) { + $did = [string]$d.id + if (-not $did -or $devSeen.ContainsKey($did)) { continue } + $devSeen[$did] = $true + $upn = if ($d.userPrincipalName) { [string]$d.userPrincipalName } elseif ($owner) { $owner.Upn } else { '' } + $udn = if ($d.userDisplayName) { [string]$d.userDisplayName } elseif ($owner) { $owner.DisplayName } else { '' } + $items.Add([pscustomobject]@{ + Id = $did + DeviceName = [string]$d.deviceName + UserDisplayName = $udn + UserPrincipalName = $upn + OS = [string]$d.operatingSystem + OSVersion = [string]$d.osVersion + ComplianceState = [string]$d.complianceState + SerialNumber = [string]$d.serialNumber + Model = [string]$d.model + Manufacturer = [string]$d.manufacturer + LastSync = ConvertTo-IsoDate $d.lastSyncDateTime + EnrolledDateTime = ConvertTo-IsoDate $d.enrolledDateTime + }) + } + } + } catch { + Write-Host " [GRP-DEV-EXPORT] Batch-Fehler: $($_.Exception.Message)" -ForegroundColor DarkYellow + } + } + + $sw.Stop() + Write-Host " -> $($items.Count) Geraete fuer $($userIds.Count) User, $($sw.ElapsedMilliseconds)ms" -ForegroundColor Green + return @{ + ok = $true + groupId = $GroupId + groupName = $groupName + userCount = $userIds.Count + items = @($items.ToArray()) + count = $items.Count + resolvedMs = [int]$sw.ElapsedMilliseconds + errors = @($script:GrpDevErrors) + } +} + # ============================================================ # Users # ============================================================ @@ -1728,12 +1843,12 @@ function Search-DevicesEndpoint { OS = $d.operatingSystem OSVersion = $d.osVersion ComplianceState = $d.complianceState - LastSync = $d.lastSyncDateTime + LastSync = ConvertTo-IsoDate $d.lastSyncDateTime ManagementState = $d.managementState SerialNumber = $d.serialNumber Model = $d.model Manufacturer = $d.manufacturer - EnrolledDateTime = $d.enrolledDateTime + EnrolledDateTime = ConvertTo-IsoDate $d.enrolledDateTime } } return @{ items = $items; count = $items.Count } @@ -1758,12 +1873,12 @@ function Get-DeviceEndpoint { OS = $d.operatingSystem OSVersion = $d.osVersion ComplianceState = $d.complianceState - LastSync = $d.lastSyncDateTime + LastSync = ConvertTo-IsoDate $d.lastSyncDateTime ManagementState = $d.managementState SerialNumber = $d.serialNumber Model = $d.model Manufacturer = $d.manufacturer - EnrolledDateTime = $d.enrolledDateTime + EnrolledDateTime = ConvertTo-IsoDate $d.enrolledDateTime Imei = $d.imei WiFiMac = $d.wiFiMacAddress AzureADDeviceId = $d.azureADDeviceId diff --git a/src/PolicyIO.ps1 b/src/PolicyIO.ps1 index afc421f..54df449 100644 --- a/src/PolicyIO.ps1 +++ b/src/PolicyIO.ps1 @@ -738,6 +738,161 @@ function Invoke-PolicyAssignEndpoint { return @{ ok = $true; assignedCount = $ok; results = @($results) } } +# ============================================================ +# Settings-Catalog-Policies zu EINER neuen Policy zusammenfuehren +# ============================================================ + +# settingDefinitionId eines Settings-Elements (Wrapper { settingInstance, id }). +function Get-SettingDefinitionId { + param($SettingElement) + $si = Get-PolicyProp $SettingElement 'settingInstance' + if (-not $si) { $si = $SettingElement } + return [string](Get-PolicyProp $si 'settingDefinitionId') +} + +# Kanonische, stabil sortierte JSON-Darstellung der settingInstance -> Wert-Vergleich +# fuer die Konflikt-Erkennung (gleiche Definition, unterschiedlicher Wert = Konflikt). +function Get-SettingCanonicalJson { + param($SettingElement) + $si = Get-PolicyProp $SettingElement 'settingInstance' + if (-not $si) { $si = $SettingElement } + return ((ConvertTo-StableObject $si) | ConvertTo-Json -Depth 50 -Compress) +} + +# Body: { ids:[...], mode:"preview"|"create", name?, description?, resolutions?:{ : } } +# Nur Settings Catalog. Fuehrt die 'settings' mehrerer Policies zu einer neuen zusammen. +# Gleiche settingDefinitionId + gleicher Wert -> einmal uebernommen. Gleiche Definition, +# anderer Wert -> Konflikt: Default gewinnt die zuerst gewaehlte Policy, per 'resolutions' +# ueberschreibbar. Import erfolgt als Neuanlage (nie Ueberschreiben). +function Invoke-PolicyConsolidateEndpoint { + param($Body) + try { Assert-GraphConnected } catch { return @{ __status = 401; error = 'Nicht mit Microsoft Graph verbunden' } } + + $ids = @(Get-PolicyProp $Body 'ids') | Where-Object { $_ } + if (@($ids).Count -lt 2) { return @{ __status = 400; error = 'Bitte mindestens zwei Settings-Catalog-Policies auswaehlen.' } } + + $mode = [string](Get-PolicyProp $Body 'mode'); if (-not $mode) { $mode = 'preview' } + $resolutions = Get-PolicyProp $Body 'resolutions' + + # Details laden (Reihenfolge = Auswahlreihenfolge = Default-Konfliktgewinner) + $sources = @() + foreach ($id in $ids) { + $detail = $null + try { $detail = Get-GraphPolicyDetail -Type 'settingscatalog' -Id ([string]$id) } catch {} + if (-not $detail) { return @{ __status = 400; error = "Policy $id konnte nicht geladen werden." } } + $sources += @{ + id = [string]$id + name = [string](Get-PolicyProp $detail 'name') + platforms = [string](Get-PolicyProp $detail 'platforms') + technologies = [string](Get-PolicyProp $detail 'technologies') + settings = @(Get-PolicyProp $detail 'settings') + } + } + + # Plattform muss uebereinstimmen - sonst kein sinnvoller Merge. + $platforms = @($sources | ForEach-Object { $_.platforms } | Where-Object { $_ } | Sort-Object -Unique) + if ($platforms.Count -gt 1) { + return @{ __status = 400; error = "Unterschiedliche Plattformen ($($platforms -join ', ')) - Zusammenfuehren nicht moeglich." } + } + $mergedPlatform = if ($platforms.Count -ge 1) { $platforms[0] } else { 'windows10' } + # Technologies vereinen (Union). + $techSet = [ordered]@{} + foreach ($s in $sources) { foreach ($t in ($s.technologies -split ',')) { $tt = $t.Trim(); if ($tt) { $techSet[$tt] = $true } } } + $mergedTech = (@($techSet.Keys) -join ','); if (-not $mergedTech) { $mergedTech = 'mdm' } + + # Nach settingDefinitionId gruppieren (Reihenfolge der Definitionen beibehalten). + $groups = [ordered]@{} + foreach ($s in $sources) { + foreach ($el in @($s.settings)) { + if (-not $el) { continue } + $defId = Get-SettingDefinitionId $el + if (-not $defId) { continue } + if (-not $groups.Contains($defId)) { $groups[$defId] = @() } + $groups[$defId] += @{ sourceId = $s.id; sourceName = $s.name; element = $el; canon = (Get-SettingCanonicalJson $el) } + } + } + + $mergedSettings = @() + $conflicts = @() + foreach ($defId in @($groups.Keys)) { + $entries = @($groups[$defId]) + $distinct = @($entries | Group-Object -Property { $_.canon }) + if ($distinct.Count -eq 1) { + $mergedSettings += $entries[0].element + continue + } + # Konflikt: Gewinner bestimmen (resolutions[defId] = sourceId, sonst erste Quelle). + $resSource = if ($resolutions) { [string](Get-PolicyProp $resolutions $defId) } else { '' } + $chosen = $null + if ($resSource) { $chosen = @($entries | Where-Object { $_.sourceId -eq $resSource })[0] } + if (-not $chosen) { $chosen = $entries[0] } + $mergedSettings += $chosen.element + $conflicts += @{ + settingDefinitionId = $defId + chosenSourceId = $chosen.sourceId + variants = @($distinct | ForEach-Object { + @{ + sourceIds = @($_.Group | ForEach-Object { $_.sourceId }) + sourceNames = @($_.Group | ForEach-Object { $_.sourceName } | Select-Object -Unique) + } + }) + } + } + + if ($mode -ne 'create') { + return @{ + ok = $true + mode = 'preview' + platform = $mergedPlatform + technologies = $mergedTech + totalSettings = @($mergedSettings).Count + conflictCount = @($conflicts).Count + conflicts = @($conflicts) + sources = @($sources | ForEach-Object { @{ id = $_.id; name = $_.name; settingCount = @($_.settings).Count } }) + } + } + + # --- create --- + if ($script:State.ReadOnly) { return @{ __status = 403; error = 'Read-Only-Modus: Zusammenfuehren ist deaktiviert.' } } + $name = [string](Get-PolicyProp $Body 'name') + if ([string]::IsNullOrWhiteSpace($name)) { return @{ __status = 400; error = 'Name fuer die neue Policy fehlt.' } } + $desc = [string](Get-PolicyProp $Body 'description') + + # settings fuer den POST vorbereiten: Wrapper mit @odata.type, read-only 'id' weg, + # Arrays reparieren (gleiche Behandlung wie beim Import). + $outSettings = @() + foreach ($el in $mergedSettings) { + $si = Get-PolicyProp $el 'settingInstance' + if (-not $si) { $si = $el } + $outSettings += [ordered]@{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationSetting' + settingInstance = $si + } + } + $outSettings = @(Repair-SettingsCatalogArrays $outSettings) + + $newBody = [ordered]@{ + name = $name + description = $desc + platforms = $mergedPlatform + technologies = $mergedTech + templateReference = @{ templateFamily = 'none'; templateId = '' } + settings = $outSettings + } + $json = $newBody | ConvertTo-Json -Depth 50 + if ($json -match 'System\.Collections\.Hashtable|System\.Object\[\]') { + return @{ __status = 500; error = 'Interner Serialisierungsfehler beim Zusammenfuehren (stringifizierte Objekte).' } + } + try { + $created = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' -Method POST -Body $json -ContentType 'application/json' + return @{ ok = $true; mode = 'create'; newId = [string](Get-PolicyProp $created 'id'); name = $name; settingsCount = @($outSettings).Count; conflictCount = @($conflicts).Count } + } catch { + $msg = $_.Exception.Message + try { if ($_.ErrorDetails.Message) { $msg = $_.ErrorDetails.Message } } catch {} + return @{ __status = 500; error = $msg } + } +} + # ============================================================ # Policy-Snapshot nach Git (voller Export, stabile Dateinamen) # ============================================================ diff --git a/www/app.js b/www/app.js index f0ccdb2..9b1109f 100644 --- a/www/app.js +++ b/www/app.js @@ -3317,6 +3317,7 @@ function membersTabLink(id, name) { 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 @@ -4825,6 +4826,7 @@ const GexState = { activeTab: 'export', userSearchResults: [], selectedUsers: new Map(), + addMode: 'user', // 'user' | 'group' — was im "Hinzufuegen"-Pane gesucht/addiert wird }; function openGroupExportModal() { @@ -4843,7 +4845,13 @@ function openGroupExportModal() { document.getElementById('gexMembersTitle').textContent = 'Mitglieder'; document.getElementById('gexStats').textContent = ''; document.getElementById('gexExportBtn').disabled = true; - // Add-Tab zurücksetzen + // Add-Tab zurücksetzen (Modus auf Benutzer) + GexState.addMode = 'user'; + document.querySelectorAll('.gex-addmode-btn').forEach(b => b.classList.toggle('active', b.dataset.addmode === 'user')); + const gexAddTitle = document.getElementById('gexAddSearchTitle'); + if (gexAddTitle) gexAddTitle.textContent = 'Benutzer suchen'; + const gexUS = document.getElementById('gexUserSearch'); + if (gexUS) gexUS.placeholder = 'Name, UPN oder E-Mail…'; document.getElementById('gexUserSearch').value = ''; document.getElementById('gexUserResults').innerHTML = '
Mindestens 2 Zeichen eingeben.
'; document.getElementById('gexSelectedUsers').innerHTML = '
Noch keine Benutzer ausgewählt.
'; @@ -5221,14 +5229,49 @@ document.getElementById('gexUserSearch')?.addEventListener('input', e => { async function _gexDoUserSearch(q) { try { - const data = await api(`/api/users?q=${encodeURIComponent(q)}`); - GexState.userSearchResults = data.items || []; + if (GexState.addMode === 'group') { + // Gruppen suchen; die Zielgruppe selbst rausfiltern. UPN-Feld traegt zur + // Anzeige die Beschreibung bzw. "Gruppe" (nur fuers Rendering, Add nutzt Id). + const data = await api(`/api/groups/search?q=${encodeURIComponent(q)}`); + const targetId = GexState.selectedGroup ? GexState.selectedGroup.Id : null; + GexState.userSearchResults = (data.items || []) + .filter(g => g.Id !== targetId) + .map(g => ({ Id: g.Id, DisplayName: g.DisplayName, UserPrincipalName: g.Description || 'Gruppe', _isGroup: true })); + } else { + const data = await api(`/api/users?q=${encodeURIComponent(q)}`); + GexState.userSearchResults = data.items || []; + } _gexRenderUserResults(); } catch (e) { document.getElementById('gexUserResults').innerHTML = `
Suche fehlgeschlagen: ${escapeHtml(e.message)}
`; } } +// Umschalter Benutzer/Gruppen im "Hinzufuegen"-Pane. +document.querySelectorAll('.gex-addmode-btn').forEach(btn => { + btn.addEventListener('click', () => { + const mode = btn.dataset.addmode; + if (GexState.addMode === mode) return; + GexState.addMode = mode; + document.querySelectorAll('.gex-addmode-btn').forEach(b => b.classList.toggle('active', b === btn)); + // Auswahl + Suche zuruecksetzen + GexState.selectedUsers.clear(); + GexState.userSearchResults = []; + _gexRenderSelectedUsers(); + _gexUpdateAddBtn(); + const search = document.getElementById('gexUserSearch'); + if (search) search.value = ''; + const isGroup = mode === 'group'; + const title = document.getElementById('gexAddSearchTitle'); + if (title) title.textContent = isGroup ? 'Gruppen suchen' : 'Benutzer suchen'; + if (search) search.placeholder = isGroup ? 'Gruppenname…' : 'Name, UPN oder E-Mail…'; + document.getElementById('gexUserResults').innerHTML = '
Mindestens 2 Zeichen eingeben.
'; + const selHint = document.getElementById('gexSelectedUsers'); + if (selHint) selHint.innerHTML = `
Noch keine ${isGroup ? 'Gruppen' : 'Benutzer'} ausgewählt.
`; + if (search) search.focus(); + }); +}); + function _gexRenderUserResults() { const el = document.getElementById('gexUserResults'); if (!el) return; @@ -5309,10 +5352,11 @@ document.getElementById('gexAddBtn')?.addEventListener('click', async () => { method: 'POST', body: { userIds }, }); + const kindLbl = GexState.addMode === 'group' ? 'Gruppe(n)' : 'Benutzer'; if (data.errors > 0) { toast(`${data.success} von ${data.total} hinzugefügt, ${data.errors} Fehler.`, 'warn'); } else { - toast(`${data.success} Benutzer zu „${GexState.selectedGroup.DisplayName}" hinzugefügt.`, 'ok'); + toast(`${data.success} ${kindLbl} zu „${GexState.selectedGroup.DisplayName}" hinzugefügt.`, 'ok'); GexState.selectedUsers.clear(); _gexRenderSelectedUsers(); document.getElementById('gexUserSearch').value = ''; @@ -5725,12 +5769,14 @@ function notifyCsvImportGroupSelected(groupId, groupName) { // Geräte-Tab // ============================================================ const DevState = { - items: [], + items: [], // aktuell geladener Satz (bei Gruppe: alle Geraete der Gruppe) filtered: [], selected: null, sortCol: 'deviceName', sortAsc: true, - loaded: false + loaded: false, + groupId: null, // aktiver Gruppen-Filter (null = alle Geraete) + groupName: '' }; async function devLoadAll() { @@ -5743,38 +5789,97 @@ function devShowLoading(on) { } async function devFetch() { - const q = document.getElementById('devSearch').value.trim(); - const os = document.getElementById('devOsFilter').value; - const cmp = document.getElementById('devComplianceFilter').value; - devShowLoading(true); try { - const params = new URLSearchParams(); - if (q) params.set('q', q); - if (os) params.set('os', os); - if (cmp) params.set('compliance', cmp); - const data = await api('/api/devices?' + params.toString()); - DevState.items = data.items || []; - DevState.filtered = DevState.items; - devSort(); + if (DevState.groupId) { + // Gruppen-Modus: alle Geraete der Gruppen-User holen, dann clientseitig filtern. + const data = await api(`/api/groups/${encodeURIComponent(DevState.groupId)}/devices/export`, { timeoutMs: 180000 }); + DevState.items = data.items || []; + if (DevState.items.length === 0 && (data.errors || []).length) { + toast(`Graph-Fehler: ${data.errors[0]}`, 'err', 'Geräte'); + } + devApplyClientFilters(); // sortiert + rendert + } else { + const q = document.getElementById('devSearch').value.trim(); + const os = document.getElementById('devOsFilter').value; + const cmp = document.getElementById('devComplianceFilter').value; + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (os) params.set('os', os); + if (cmp) params.set('compliance', cmp); + const data = await api('/api/devices?' + params.toString()); + DevState.items = data.items || []; + DevState.filtered = DevState.items; + devSort(); + } } catch (e) { - toast('Geräte konnten nicht geladen werden: ' + e, 'error'); + toast('Geräte konnten nicht geladen werden: ' + (e.message || e), 'error'); } finally { devShowLoading(false); } } +// Ein Geraet gegen die aktuellen UI-Filter pruefen (fuer den Gruppen-Modus, +// wo serverseitig nicht gefiltert wird). +function devPassesFilters(d, q, os, cmp) { + if (os && !(d.OS || '').toLowerCase().includes(os.toLowerCase())) return false; + if (cmp && (d.ComplianceState || '') !== cmp) return false; + if (q) { + const hay = `${d.DeviceName || ''} ${d.UserDisplayName || ''} ${d.UserPrincipalName || ''} ${d.SerialNumber || ''}`.toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; +} + +function devApplyClientFilters() { + const q = document.getElementById('devSearch').value.trim().toLowerCase(); + const os = document.getElementById('devOsFilter').value; + const cmp = document.getElementById('devComplianceFilter').value; + DevState.filtered = DevState.items.filter(d => devPassesFilters(d, q, os, cmp)); + devSort(); +} + +// Filteraenderung: bei aktiver Gruppe nur clientseitig neu filtern (kein Re-Fetch), +// sonst serverseitig neu laden. function devApplyFilters() { - devFetch(); + if (DevState.groupId) devApplyClientFilters(); + else devFetch(); +} + +// Header-data-col (Graph-Feldnamen) -> tatsaechliche Item-Property. +const DEV_COL_MAP = { + deviceName: 'DeviceName', + userDisplayName: 'UserDisplayName', + operatingSystem: 'OS', + complianceState: 'ComplianceState', + lastSyncDateTime: 'LastSync', + enrolledDateTime: 'EnrolledDateTime', +}; +const DEV_DATE_COLS = new Set(['LastSync', 'EnrolledDateTime']); + +function devFmtDate(iso) { + if (!iso) return '—'; + const d = new Date(iso); + if (isNaN(d.getTime())) return '—'; + return d.toLocaleDateString('de-DE') + ' ' + + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); } function devSort() { - const col = DevState.sortCol; - const asc = DevState.sortAsc; + const col = DEV_COL_MAP[DevState.sortCol] || DevState.sortCol; + const asc = DevState.sortAsc ? 1 : -1; + const isDate = DEV_DATE_COLS.has(col); DevState.filtered.sort((a, b) => { - const av = (a[col] || '').toString().toLowerCase(); - const bv = (b[col] || '').toString().toLowerCase(); - return asc ? av.localeCompare(bv) : bv.localeCompare(av); + if (isDate) { + // Nach Zeitstempel sortieren; ungueltige/fehlende Daten ans Ende. + const at = Date.parse(a[col]); const bt = Date.parse(b[col]); + const av = isNaN(at) ? -Infinity : at; + const bv = isNaN(bt) ? -Infinity : bt; + return (av - bv) * asc; + } + const sa = (a[col] ?? '').toString().toLowerCase(); + const sb = (b[col] ?? '').toString().toLowerCase(); + return sa.localeCompare(sb, 'de', { numeric: true }) * asc; }); devRender(); } @@ -5783,6 +5888,8 @@ function devRender() { const tbody = document.getElementById('devTableBody'); const empty = document.getElementById('devEmpty'); document.getElementById('devCount').textContent = DevState.filtered.length; + const expBtn = document.getElementById('btnDevExport'); + if (expBtn) expBtn.disabled = DevState.filtered.length === 0; if (DevState.filtered.length === 0) { tbody.innerHTML = ''; @@ -5803,7 +5910,7 @@ function devRender() { let html = ''; for (const d of DevState.filtered) { - const sync = d.LastSync ? new Date(d.LastSync).toLocaleDateString('de-DE') : '—'; + const sync = devFmtDate(d.LastSync); const cmpCls = d.ComplianceState === 'compliant' ? 'dev-badge-ok' : d.ComplianceState === 'noncompliant' ? 'dev-badge-err' : 'dev-badge-unk'; const sel = DevState.selected && DevState.selected.Id === d.Id ? ' class="dev-row-selected"' : ''; @@ -5851,8 +5958,8 @@ function devRenderDetail(d) { document.getElementById('devDetailName').textContent = d.DeviceName || '—'; document.getElementById('devDetailSub').textContent = `${d.Manufacturer || ''} ${d.Model || ''}`.trim() || '—'; - const sync = d.LastSync ? new Date(d.LastSync).toLocaleString('de-DE') : '—'; - const enrolled = d.EnrolledDateTime ? new Date(d.EnrolledDateTime).toLocaleDateString('de-DE') : '—'; + const sync = devFmtDate(d.LastSync); + const enrolled = devFmtDate(d.EnrolledDateTime); const storage = (d.TotalStorageGB != null) ? `${d.FreeStorageGB} GB frei / ${d.TotalStorageGB} GB gesamt` : null; @@ -5987,10 +6094,104 @@ document.getElementById('devActions')?.addEventListener('click', async (e) => { let devSearchDebounce = null; document.getElementById('devSearch').addEventListener('input', () => { clearTimeout(devSearchDebounce); - devSearchDebounce = setTimeout(devFetch, 400); + devSearchDebounce = setTimeout(devApplyFilters, 400); }); -document.getElementById('devOsFilter').addEventListener('change', devFetch); -document.getElementById('devComplianceFilter').addEventListener('change', devFetch); +document.getElementById('devOsFilter').addEventListener('change', devApplyFilters); +document.getElementById('devComplianceFilter').addEventListener('change', devApplyFilters); + +// Gruppen-Filter (Live-Suche) + CSV-Export +let _devGroupTimer = null, _devGroupAbort = null; + +document.getElementById('devGroupSearch')?.addEventListener('input', e => { + clearTimeout(_devGroupTimer); + const term = e.target.value.trim(); + const results = document.getElementById('devGroupResults'); + // Text geaendert -> aktiven Gruppen-Filter loesen (bis eine Gruppe gewaehlt wird) + if (DevState.groupId) { + DevState.groupId = null; DevState.groupName = ''; + document.getElementById('devGroupClear').hidden = true; + devFetch(); + } + if (term.length < 2) { if (results) { results.classList.add('hidden'); results.innerHTML = ''; } return; } + _devGroupTimer = setTimeout(() => devGroupSearch(term), 250); +}); + +async function devGroupSearch(term) { + const results = document.getElementById('devGroupResults'); + if (!results) return; + if (_devGroupAbort) { try { _devGroupAbort.abort(); } catch {} } + _devGroupAbort = new AbortController(); + results.classList.remove('hidden'); + results.innerHTML = '
Suche…
'; + try { + const resp = await fetch('/api/groups/search?q=' + encodeURIComponent(term), { signal: _devGroupAbort.signal }); + let data = {}; try { data = await resp.json(); } catch {} + if (_devGroupAbort.signal.aborted) return; + if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); + const gs = data.items || []; + if (gs.length === 0) { results.innerHTML = '
Keine Treffer
'; return; } + results.innerHTML = gs.map(g => + ``).join(''); + } catch (e) { + if (e.name === 'AbortError') return; + results.innerHTML = `
Fehler: ${escapeHtml(e.message)}
`; + } +} + +document.getElementById('devGroupResults')?.addEventListener('click', e => { + const btn = e.target.closest('.cg-existing-item'); + if (!btn) return; + DevState.groupId = btn.dataset.gid; + DevState.groupName = btn.dataset.gname; + document.getElementById('devGroupSearch').value = btn.dataset.gname; + document.getElementById('devGroupClear').hidden = false; + document.getElementById('devGroupResults').classList.add('hidden'); + devFetch(); +}); + +document.getElementById('devGroupClear')?.addEventListener('click', () => { + DevState.groupId = null; DevState.groupName = ''; + document.getElementById('devGroupSearch').value = ''; + document.getElementById('devGroupClear').hidden = true; + document.getElementById('devGroupResults').classList.add('hidden'); + devFetch(); +}); + +// Klick ausserhalb schliesst die Ergebnisliste +document.addEventListener('click', e => { + const wrap = document.getElementById('devGroupWrap'); + if (wrap && !wrap.contains(e.target)) { + document.getElementById('devGroupResults')?.classList.add('hidden'); + } +}); + +function devExportCsv() { + const items = DevState.filtered || []; + if (items.length === 0) { toast('Keine Geräte zum Exportieren.', 'warn'); return; } + const header = ['DeviceName','UserDisplayName','UserPrincipalName','OS','OSVersion','ComplianceState','SerialNumber','Model','Manufacturer','LastSync','EnrolledDateTime']; + const rows = [header]; + for (const d of items) { + rows.push([ + d.DeviceName || '', d.UserDisplayName || '', d.UserPrincipalName || '', + d.OS || '', d.OSVersion || '', d.ComplianceState || '', + d.SerialNumber || '', d.Model || '', d.Manufacturer || '', + d.LastSync ? devFmtDate(d.LastSync) : '', d.EnrolledDateTime ? devFmtDate(d.EnrolledDateTime) : '', + ]); + } + const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\r\n'); + const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const scope = DevState.groupName ? DevState.groupName.replace(/[^a-zA-Z0-9_-]/g, '_') : 'alle'; + const a = Object.assign(document.createElement('a'), { href: url, download: `devices-${scope}-${new Date().toISOString().slice(0,10)}.csv` }); + a.click(); + URL.revokeObjectURL(url); + toast(`${items.length} Geräte exportiert${DevState.groupName ? ` (Gruppe „${DevState.groupName}")` : ''}.`, 'ok'); +} + +document.getElementById('btnDevExport')?.addEventListener('click', devExportCsv); // Sortierung document.querySelectorAll('#devTable th.sortable').forEach(th => { @@ -6112,9 +6313,19 @@ function renderPolicies() { } function polUpdateSelCount() { - const n = PolState.selected.size; + const keys = [...PolState.selected]; + const n = keys.length; document.getElementById('polSelCount').textContent = n; document.getElementById('btnPolExport').disabled = n === 0; + // Konsolidieren nur bei >=2 Policies UND alle vom Typ Settings Catalog. + const btnC = document.getElementById('btnPolConsolidate'); + if (btnC) { + const allSc = n >= 2 && keys.every(k => k.startsWith('settingscatalog::')); + btnC.disabled = !allSc; + btnC.title = allSc + ? 'Ausgewählte Settings-Catalog-Policies zu einer neuen Policy zusammenführen' + : 'Mindestens zwei Policies vom Typ „Settings Catalog" auswählen'; + } } function polSyncCheckAll() { @@ -6173,6 +6384,109 @@ async function polExportSelected() { } } +// ============================================================= +// Policies konsolidieren (Settings Catalog -> eine neue Policy) +// ============================================================= +let _polConsSelIds = []; // ausgewaehlte settingscatalog-Ids (Reihenfolge = Default-Gewinner) +let _polConsPreview = null; // Preview-Response +const _polConsResolutions = {}; // settingDefinitionId -> gewaehlte sourceId + +async function openPolConsolidate() { + const keys = [...PolState.selected].filter(k => k.startsWith('settingscatalog::')); + if (keys.length < 2) { toast('Mindestens zwei Settings-Catalog-Policies auswählen.', 'warn'); return; } + _polConsSelIds = keys.map(k => k.slice('settingscatalog::'.length)); + _polConsPreview = null; + Object.keys(_polConsResolutions).forEach(k => delete _polConsResolutions[k]); + + document.getElementById('polConsSummary').innerHTML = ' Analysiere…'; + document.getElementById('polConsConflicts').innerHTML = ''; + document.getElementById('polConsName').value = ''; + document.getElementById('polConsDesc').value = ''; + document.getElementById('polConsConfirm').disabled = true; + document.getElementById('polConsInfo').textContent = ''; + openModal('modalPolConsolidate'); + + try { + const res = await api('/api/policies/consolidate', { method: 'POST', body: { ids: _polConsSelIds, mode: 'preview' }, timeoutMs: 120000 }); + _polConsPreview = res; + renderPolConsPreview(res); + } catch (e) { + document.getElementById('polConsSummary').innerHTML = `Analyse fehlgeschlagen: ${escapeHtml(e.message)}`; + } +} + +function polConsSourceName(id) { + const s = ((_polConsPreview && _polConsPreview.sources) || []).find(x => x.id === id); + return s ? s.name : id; +} + +function renderPolConsPreview(res) { + const sources = res.sources || []; + document.getElementById('polConsSummary').innerHTML = ` +
${sources.length} Policies · Plattform ${escapeHtml(res.platform || '—')}
+
${res.totalSettings} Einstellungen gesamt · ${res.conflictCount} Konflikt(e)
+
    ${sources.map(s => `
  • ${escapeHtml(s.name)} (${s.settingCount})
  • `).join('')}
`; + + const cWrap = document.getElementById('polConsConflicts'); + const conflicts = res.conflicts || []; + if (conflicts.length === 0) { + cWrap.innerHTML = '
Keine Konflikte — alle gemeinsamen Einstellungen sind identisch.
'; + } else { + cWrap.innerHTML = `
Konflikte — welcher Wert soll gewinnen?
` + + conflicts.map((c, ci) => { + _polConsResolutions[c.settingDefinitionId] = c.chosenSourceId; // Default merken + const opts = (c.variants || []).map(v => { + const sid = (v.sourceIds || [])[0]; + const label = (v.sourceNames || []).join(', ') || polConsSourceName(sid); + const checked = sid === c.chosenSourceId ? 'checked' : ''; + return ``; + }).join(''); + return `
+
${escapeHtml(c.settingDefinitionId)}
+
${opts}
+
`; + }).join(''); + cWrap.querySelectorAll('input[type="radio"][data-def]').forEach(r => { + r.addEventListener('change', () => { _polConsResolutions[r.dataset.def] = r.dataset.src; }); + }); + } + + document.getElementById('polConsName').value = `Konsolidiert – ${sources.length ? sources[0].name : 'Policy'}`; + document.getElementById('polConsInfo').textContent = `${res.totalSettings} Einstellungen → 1 Policy`; + document.getElementById('polConsConfirm').disabled = false; +} + +async function polDoConsolidate() { + if (!_polConsPreview) return; + const name = document.getElementById('polConsName').value.trim(); + if (!name) { toast('Bitte einen Namen angeben.', 'warn'); return; } + const desc = document.getElementById('polConsDesc').value.trim(); + const btn = document.getElementById('polConsConfirm'); + btn.disabled = true; + setLoading('Führe Policies zusammen…'); + try { + const res = await api('/api/policies/consolidate', { + method: 'POST', + body: { ids: _polConsSelIds, mode: 'create', name, description: desc, resolutions: _polConsResolutions }, + timeoutMs: 180000, + }); + toast(`Neue Policy „${res.name}" erstellt (${res.settingsCount} Einstellungen, ${res.conflictCount} Konflikt(e) aufgelöst).`, 'ok', 'Konsolidiert'); + closeModal('modalPolConsolidate'); + await loadPolicies(); + } catch (e) { + toast('Zusammenführen fehlgeschlagen: ' + e.message, 'err'); + btn.disabled = false; + } finally { + clearLoading(); + } +} + +document.getElementById('btnPolConsolidate')?.addEventListener('click', openPolConsolidate); +document.getElementById('polConsConfirm')?.addEventListener('click', polDoConsolidate); + // Datei-Inhalt -> Liste von Envelopes { exportType, policy, ... } function polExtractEnvelopes(parsed, fileName) { if (parsed && Array.isArray(parsed.policies)) return parsed.policies; diff --git a/www/index.html b/www/index.html index fc9e9d4..6e1b523 100644 --- a/www/index.html +++ b/www/index.html @@ -297,7 +297,7 @@
- +
@@ -386,7 +386,11 @@ + + +