diff --git a/CHANGELOG.md b/CHANGELOG.md index 8434746..248a9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/) — solan --- +## [0.1.30] - 2026-09-03 + +Policy-Bulk-Import gehärtet, macOS-Apps, Gruppen-Suche & Direkt-Zuweisung an Apps. + +### Neu + +- **Bulk-Import von Policies**: Mehrere Policy-Dateien (oder eine Bundle-Datei) auf einmal importieren. Läuft server-seitig in Batches à 5 mit Fortschrittsanzeige und Fehler-Isolierung pro Batch — umgeht das 180-s-Timeout einzelner Requests. +- **macOS-Apps** erscheinen jetzt in App-Liste und -Suche (DMG, PKG, LOB, Defender, Edge, M365, VPP) mit eigenen Typ-Labels. Neuer **Plattform-Filter** (Alle / Windows / macOS). +- **Gruppen-Suche** im Ziel-Panel: Der frühere „Abteilung"-Tab heißt jetzt **„Gruppe"** und durchsucht per Live-Suche **alle** Tenant-Gruppen (statt nur Präfix-Gruppen). Auswahl bleibt über mehrere Suchen erhalten. +- **Bestehende Gruppe(n) direkt einer App zuweisen**: Im Dialog „Zuweisung hinzufügen" neue Option „Bestehende Gruppe" (Required/Available) mit Gruppensuche und **Mehrfachauswahl** (Chips). + +### Behoben + +- **App-Zuweisung überschrieb bestehende Zuweisungen**: `Add-GraphAppAssignment` nutzte die `/assign`-Action mit nur einer Zuweisung — diese ersetzt aber die komplette Liste. Jetzt werden bestehende Zuweisungen geladen, neue gemergt (Duplikate übersprungen) und in **einem** Request geschickt. Behebt sowohl die Mehrfach-Zuweisung als auch stillen Verlust vorhandener Zuweisungen beim Einzel-Hinzufügen. +- **Policy-Export/-Import-Treue** (Windows PowerShell 5.1): Policy-Details werden als **rohes JSON** von Graph geholt (`-OutputType Json`), sonst skalarisierte der Hashtable-Modus Ein-Element-Collections (z. B. `printerNames`) → 400 beim Re-Import. +- **Configuration-Profile-Import**: `null`-Werte für nicht-nullbare Collections werden entfernt (400 `ModelValidationFailure`). +- **Settings-Catalog-Import**: Reparatur leerer/verstümmelter Collections (`[""]`, `{}`), Rettung stringifizierter `*TemplateReference`-Felder und **klarer Abbruch** bei irreparabel beschädigten Quelldateien (alte Exporte mit zu geringer JSON-Tiefe). +- **Git-Snapshot**: `git` wird jetzt auch über bekannte Installationsorte gefunden, falls es nicht im (veralteten) PATH des Server-Prozesses liegt. + +--- + ## [0.1.29] - 2026-08-24 Geräte-Offboarding (v1). diff --git a/Start.ps1 b/Start.ps1 index 976e370..0fc1ae1 100644 --- a/Start.ps1 +++ b/Start.ps1 @@ -189,7 +189,7 @@ $script:State = [pscustomobject]@{ Session = @() # geplante Zuweisungen } -$script:ToolVersion = "0.1.29" +$script:ToolVersion = "0.1.30" $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 cc875d3..76083ec 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.29" +$version = "0.1.30" $src = "\\Mac\Home\ClaudePRJ\Intune Manager" $stage = "$env:TEMP\IntuneManagerStage" $installerDir = "$src\installer" diff --git a/src/Api.ps1 b/src/Api.ps1 index 5071748..e1162f1 100644 --- a/src/Api.ps1 +++ b/src/Api.ps1 @@ -2805,23 +2805,29 @@ function Add-AppAssignmentEndpoint { if ($intent -notin @("required","available")) { return @{ __status = 400; error = "intent muss 'required' oder 'available' sein" } } - $target = [string]$Body.target - if ([string]::IsNullOrWhiteSpace($target)) { - return @{ __status = 400; error = "target fehlt (ALL_USERS / ALL_DEVICES / )" } + # Ein ODER mehrere Ziele akzeptieren: 'targets' (Array) hat Vorrang, sonst 'target'. + $targets = @() + if ($Body.targets) { $targets = @($Body.targets | ForEach-Object { [string]$_ }) } + elseif ($Body.target) { $targets = @([string]$Body.target) } + $targets = @($targets | Where-Object { $_ -and $_.Trim() }) + if ($targets.Count -eq 0) { + return @{ __status = 400; error = "target/targets fehlt (ALL_USERS / ALL_DEVICES / )" } } - # Bei Group-Target: zumindest grobe Hex/GUID-Form pruefen damit wir keine - # Garbage an Graph schicken. - if ($target -notin @("ALL_USERS","ALL_DEVICES") -and $target -notmatch '^[0-9a-fA-F-]{8,}$') { - return @{ __status = 400; error = "Ungueltige target-GUID: $target" } + # Jedes Group-Target grob auf Hex/GUID-Form pruefen (keine Garbage an Graph). + foreach ($t in $targets) { + if ($t -notin @("ALL_USERS","ALL_DEVICES") -and $t -notmatch '^[0-9a-fA-F-]{8,}$') { + return @{ __status = 400; error = "Ungueltige target-GUID: $t" } + } } # App-Cache fuer logging $cachedApp = $script:State.Apps | Where-Object { $_.AppId -eq $AppId } | Select-Object -First 1 $appName = if ($cachedApp) { $cachedApp.AppName } else { $AppId } - Write-Host "[ADD ASSIGN] $appName -> intent=$intent target=$target" -ForegroundColor Yellow + Write-Host "[ADD ASSIGN] $appName -> intent=$intent targets=$($targets -join ', ')" -ForegroundColor Yellow try { - Add-GraphAppAssignment -AppId $AppId -Intent $intent -GroupId $target + # EIN Request fuer alle Ziele: bestehende Zuweisungen werden gemergt. + Add-GraphAppAssignment -AppId $AppId -Intent $intent -GroupId $targets } catch { $graphBody = $null try { $graphBody = $_.ErrorDetails.Message } catch {} @@ -2836,38 +2842,46 @@ function Add-AppAssignmentEndpoint { return @{ __status = $status; error = $friendly; code = $details.Code; graph = $graphBody } } - # Cache aktualisieren — neue Zuweisung im App-Eintrag ergaenzen + # Cache aktualisieren — neue Zuweisung(en) im App-Eintrag ergaenzen. + # (Das Frontend laedt danach ohnehin neu; das haelt die UI aber sofort konsistent.) if ($cachedApp) { - $entry = if ($target -eq "ALL_USERS") { - @{ GroupId = "ALL_USERS"; GroupName = "All Users"; IsNative = $true } - } elseif ($target -eq "ALL_DEVICES") { - @{ GroupId = "ALL_DEVICES"; GroupName = "All Devices"; IsNative = $true } - } else { - # Group-Namen aus den geladenen Gruppen oder Graph nachschlagen - $groupName = $target - $lookup = @{} - foreach ($g in $script:State.Groups) { $lookup[$g.Id] = $g.DisplayName } - foreach ($g in $script:State.RpaGroups) { $lookup[$g.Id] = $g.DisplayName } - if ($lookup.ContainsKey($target)) { $groupName = $lookup[$target] } - else { - try { - $g = Get-GraphGroupById -Id $target -Property @("id","displayName") - if ($g.displayName) { $groupName = [string]$g.displayName } - } catch {} + $lookup = @{} + foreach ($g in $script:State.Groups) { $lookup[$g.Id] = $g.DisplayName } + foreach ($g in $script:State.RpaGroups) { $lookup[$g.Id] = $g.DisplayName } + foreach ($target in $targets) { + $entry = if ($target -eq "ALL_USERS") { + @{ GroupId = "ALL_USERS"; GroupName = "All Users"; IsNative = $true } + } elseif ($target -eq "ALL_DEVICES") { + @{ GroupId = "ALL_DEVICES"; GroupName = "All Devices"; IsNative = $true } + } else { + $groupName = $target + if ($lookup.ContainsKey($target)) { $groupName = $lookup[$target] } + else { + try { + $g = Get-GraphGroupById -Id $target -Property @("id","displayName") + if ($g.displayName) { $groupName = [string]$g.displayName } + } catch {} + } + @{ GroupId = $target; GroupName = $groupName; IsNative = $false } + } + $exists = if ($intent -eq "available") { + @($cachedApp.AvailableGroups | Where-Object { $_.GroupId -eq $entry.GroupId }).Count -gt 0 + } else { + @($cachedApp.RequiredGroups | Where-Object { $_.GroupId -eq $entry.GroupId }).Count -gt 0 + } + if ($exists) { continue } + if ($intent -eq "available") { + $cachedApp.AvailableGroups = @($cachedApp.AvailableGroups + $entry) + $cachedApp.AvailableCount = $cachedApp.AvailableGroups.Count + } else { + $cachedApp.RequiredGroups = @($cachedApp.RequiredGroups + $entry) + $cachedApp.RequiredCount = $cachedApp.RequiredGroups.Count } - @{ GroupId = $target; GroupName = $groupName; IsNative = $false } - } - if ($intent -eq "available") { - $cachedApp.AvailableGroups = @($cachedApp.AvailableGroups + $entry) - $cachedApp.AvailableCount = $cachedApp.AvailableGroups.Count - } else { - $cachedApp.RequiredGroups = @($cachedApp.RequiredGroups + $entry) - $cachedApp.RequiredCount = $cachedApp.RequiredGroups.Count } } - Write-Host "[ADD ASSIGN] OK" -ForegroundColor Green - return @{ ok = $true; appId = $AppId; intent = $intent; target = $target } + Write-Host "[ADD ASSIGN] OK ($($targets.Count) Ziel(e))" -ForegroundColor Green + return @{ ok = $true; appId = $AppId; intent = $intent; targets = @($targets); count = $targets.Count } } function Remove-AppAssignmentEndpoint { diff --git a/src/Graph.ps1 b/src/Graph.ps1 index 61ad68b..91445ee 100644 --- a/src/Graph.ps1 +++ b/src/Graph.ps1 @@ -33,7 +33,13 @@ function Invoke-MgGraphRequestRetry { $Body, [string]$ContentType, [hashtable]$Headers, - [int]$MaxRetries = 3 + [int]$MaxRetries = 3, + # Rohes JSON von Graph holen und selbst parsen. Noetig fuer Policy-Exporte: + # Invoke-MgGraphRequest liefert unter Windows PowerShell 5.1 im Hashtable- + # Modus Ein-Element-Collections als Skalar (z.B. printerNames), was beim + # Re-Import 400 "A 'StartArray' node was expected" ausloest. -OutputType Json + # umgeht das: ConvertFrom-Json bewahrt Arrays. + [switch]$AsRawJson ) $attempt = 0 while ($true) { @@ -42,6 +48,13 @@ function Invoke-MgGraphRequestRetry { if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) { $params.Body = $Body } if ($ContentType) { $params.ContentType = $ContentType } if ($Headers) { $params.Headers = $Headers } + if ($AsRawJson) { + $params.OutputType = 'Json' + $raw = Invoke-MgGraphRequest @params + if ([string]::IsNullOrWhiteSpace([string]$raw)) { return $null } + if ($PSVersionTable.PSVersion.Major -ge 6) { return ($raw | ConvertFrom-Json -AsHashtable) } + return ($raw | ConvertFrom-Json) + } return Invoke-MgGraphRequest @params } catch { $msg = $_.Exception.Message @@ -64,11 +77,15 @@ function Invoke-MgGraphRequestRetry { } function Get-GraphPaged { - param([Parameter(Mandatory=$true)][string]$Uri) + param( + [Parameter(Mandatory=$true)][string]$Uri, + # An Invoke-MgGraphRequestRetry durchreichen: bewahrt Arrays fuer Exporte. + [switch]$AsRawJson + ) $results = @() $next = $Uri do { - $response = Invoke-MgGraphRequestRetry -Uri $next -Method GET + $response = Invoke-MgGraphRequestRetry -Uri $next -Method GET -AsRawJson:$AsRawJson if ($response.value) { $results += $response.value } $next = $response.'@odata.nextLink' } while ($next) @@ -376,21 +393,51 @@ function Add-GraphAppAssignment { param( [string]$AppId, [string]$Intent, # "available" | "required" - [string]$GroupId # GUID oder "ALL_USERS" / "ALL_DEVICES" + [string[]]$GroupId # eine ODER mehrere: GUID(s) und/oder "ALL_USERS"/"ALL_DEVICES" ) - $target = switch ($GroupId) { - "ALL_USERS" { @{ "@odata.type" = "#microsoft.graph.allLicensedUsersAssignmentTarget" } } - "ALL_DEVICES" { @{ "@odata.type" = "#microsoft.graph.allDevicesAssignmentTarget" } } - default { @{ "@odata.type" = "#microsoft.graph.groupAssignmentTarget"; "groupId" = $GroupId } } + # WICHTIG: Die /assign-Action ERSETZT die komplette Zuweisungsliste der App. + # Ein einzelnes POST wuerde also alle bestehenden Zuweisungen loeschen. Daher: + # bestehende Zuweisungen laden, die neuen mergen (Duplikate ueberspringen) und + # den VOLLEN Satz in EINEM Request schicken. Das haelt mehrere neue Gruppen + # zusammen (kein Read-after-Write-Rennen) und bewahrt vorhandene Zuweisungen. + $newTargets = @() + foreach ($gid in @($GroupId)) { + if ([string]::IsNullOrWhiteSpace([string]$gid)) { continue } + $newTargets += switch ([string]$gid) { + "ALL_USERS" { @{ "@odata.type" = "#microsoft.graph.allLicensedUsersAssignmentTarget" } } + "ALL_DEVICES" { @{ "@odata.type" = "#microsoft.graph.allDevicesAssignmentTarget" } } + default { @{ "@odata.type" = "#microsoft.graph.groupAssignmentTarget"; "groupId" = [string]$gid } } + } } - $body = @{ - mobileAppAssignments = @(@{ - "@odata.type" = "#microsoft.graph.mobileAppAssignment" + + $existing = Get-GraphMobileAppAssignments -AppId $AppId + $assignments = @() + $seen = @{} # Schluessel intent|type|groupId -> Duplikate vermeiden + foreach ($a in $existing) { + $tgt = $a.target + if (-not $tgt) { continue } + $key = ([string]$a.intent) + '|' + ([string]$tgt.'@odata.type') + '|' + ([string]$tgt.groupId) + $seen[$key] = $true + $assignments += @{ + '@odata.type' = '#microsoft.graph.mobileAppAssignment' + intent = [string]$a.intent + target = $tgt # inkl. evtl. Filter -> unveraendert beibehalten + settings = $a.settings # bestehende Assignment-Settings bewahren + } + } + foreach ($t in $newTargets) { + $key = $Intent + '|' + [string]$t['@odata.type'] + '|' + [string]$t['groupId'] + if ($seen.ContainsKey($key)) { continue } # bereits (mit diesem Intent) zugewiesen + $seen[$key] = $true + $assignments += @{ + '@odata.type' = '#microsoft.graph.mobileAppAssignment' intent = $Intent - target = $target + target = $t settings = $null - }) + } } + + $body = @{ mobileAppAssignments = @($assignments) } Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/assign" -Method POST -Body $body } @@ -827,16 +874,15 @@ function Remove-GraphMobileApp { } } -# Excluded App-Typen aus dem Original-Script +# Ausgeschlossene App-Typen: iOS/Android bleiben ausgeblendet (dieses Tool ist auf +# Windows + macOS ausgelegt). macOS-Typen sind bewusst NICHT mehr ausgeschlossen, +# damit Mac-Apps in Liste/Suche erscheinen. $script:ExcludedAppTypes = @( '#microsoft.graph.iosLobApp', '#microsoft.graph.iosStoreApp', '#microsoft.graph.iosVppApp', '#microsoft.graph.managedIOSLobApp', '#microsoft.graph.managedIOSStoreApp', '#microsoft.graph.androidLobApp', '#microsoft.graph.androidStoreApp', '#microsoft.graph.androidForWorkApp', '#microsoft.graph.androidManagedStoreApp', '#microsoft.graph.androidManagedStoreWebApp', - '#microsoft.graph.managedAndroidLobApp', '#microsoft.graph.managedAndroidStoreApp', - '#microsoft.graph.macOSDmgApp', '#microsoft.graph.macOSLobApp', '#microsoft.graph.macOSMicrosoftDefenderApp', - '#microsoft.graph.macOSMicrosoftEdgeApp', '#microsoft.graph.macOSOfficeSuiteApp', '#microsoft.graph.macOSPkgApp', - '#microsoft.graph.macOsVppApp' + '#microsoft.graph.managedAndroidLobApp', '#microsoft.graph.managedAndroidStoreApp' ) function Test-AppTypeAllowed { @@ -858,6 +904,13 @@ function ConvertTo-AppFriendlyType { '#microsoft.graph.windowsAppX' { 'APPX' } '#microsoft.graph.windowsUniversalAppX' { 'APPX' } '#microsoft.graph.windowsMobileMSI' { 'MSI' } + '#microsoft.graph.macOSDmgApp' { 'macOS DMG' } + '#microsoft.graph.macOSPkgApp' { 'macOS PKG' } + '#microsoft.graph.macOSLobApp' { 'macOS LOB' } + '#microsoft.graph.macOSMicrosoftDefenderApp' { 'macOS Defender' } + '#microsoft.graph.macOSMicrosoftEdgeApp' { 'macOS Edge' } + '#microsoft.graph.macOSOfficeSuiteApp' { 'macOS M365' } + '#microsoft.graph.macOsVppApp' { 'macOS VPP' } default { ($Type -replace '#microsoft\.graph\.','') } diff --git a/src/PolicyIO.ps1 b/src/PolicyIO.ps1 index 924202d..afc421f 100644 --- a/src/PolicyIO.ps1 +++ b/src/PolicyIO.ps1 @@ -186,10 +186,12 @@ function Get-GraphPolicyDetail { # Administrative Vorlagen: Basis-Objekt holen und die konfigurierten Werte # (definitionValues inkl. Definition + Presentation-Werten) separat expandieren. + # -AsRawJson durchgaengig: bewahrt Ein-Element-Collections als Array (PS-5.1- + # Hashtable-Modus wuerde sie skalarisieren -> 400 beim Re-Import). if ($cfg.Key -eq 'administrativetemplate') { - $base = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations/$Id" -Method GET + $base = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations/$Id" -Method GET -AsRawJson $dvUri = "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations/$Id/definitionValues?`$expand=definition(`$select=id,classType,displayName,policyType,version),presentationValues(`$expand=presentation)" - $dvs = @(Get-GraphPaged -Uri $dvUri) + $dvs = @(Get-GraphPaged -Uri $dvUri -AsRawJson) if ($base -is [System.Collections.IDictionary]) { $base['definitionValues'] = $dvs } else { $base | Add-Member -NotePropertyName definitionValues -NotePropertyValue $dvs -Force } return $base @@ -197,7 +199,7 @@ function Get-GraphPolicyDetail { $uri = "https://graph.microsoft.com/beta/deviceManagement/$($cfg.Collection)/$Id" if ($cfg.ExportExpand) { $uri += "?`$expand=$($cfg.ExportExpand)" } - return Invoke-MgGraphRequestRetry -Uri $uri -Method GET + return Invoke-MgGraphRequestRetry -Uri $uri -Method GET -AsRawJson } # PSCustomObject/Hashtable -> bereinigte Hashtable ohne Read-Only-Felder. @@ -229,10 +231,23 @@ function ConvertTo-ImportBody { function Repair-SettingsCatalogArrays { param($Node) $arrayKeys = @('settings','children','groupSettingCollectionValue','simpleSettingCollectionValue','values') + # *TemplateReference-Keys tragen ein Objekt ODER null. Ein alter Export mit zu + # geringer ConvertTo-Json-Tiefe hat solche (tief liegenden) Objekte zu ".ToString()" + # stringifiziert -> "System.Collections.Hashtable". Graph lehnt das ab + # ('Property settingValueTemplateReference ... does not match schema'). Der bloße + # String ist eindeutig korrupt und nicht rekonstruierbar -> auf null setzen; die + # (optionale) Template-Bindung entfaellt, die eigentlichen Werte bleiben erhalten. + $refKeys = @('settingInstanceTemplateReference','settingValueTemplateReference') - # Array-Property normalisieren: $null -> @() (Graph-Schema: Collections sind - # Nullable=False, ein 'children': null wird abgelehnt), Einzelobjekt -> @(obj). - # Inline (nicht als Funktion), sonst entpackt der Return ein Ein-Element-Array. + # Array-Property normalisieren — WICHTIG inline (Zuweisung, KEIN Funktions- + # Return): ein leeres Array aus einer Funktion zurueckzugeben entpackt PS zu + # $null, was zu 'children: {}' statt '[]' fuehrt. Als Zuweisung bleibt @() ein @(). + # $null -> @() (Graph-Schema: Collections sind Nullable=False) + # Einzelobjekt -> @(obj) (Ein-Element-Array wurde vom Roundtrip skalarisiert) + # leere/Whitespace-STRING-Elemente entfernen: ein PS-JSON-Roundtrip macht aus + # einer leeren Collection [] teils ""/[""] -> Graph lehnt das als + # 'Property children ... does not match schema' ab. Diese Keys tragen nie bare + # Strings -> gefahrlos filtern; wird die Collection dadurch leer, bleibt []. if ($Node -is [System.Collections.IDictionary]) { $out = [ordered]@{} foreach ($k in @($Node.Keys)) { @@ -240,7 +255,9 @@ function Repair-SettingsCatalogArrays { if ($k -in $arrayKeys) { if ($null -eq $fixed) { $fixed = @() } elseif (-not ($fixed -is [System.Collections.IList])) { $fixed = @($fixed) } + $fixed = @($fixed | Where-Object { -not (($_ -is [string]) -and [string]::IsNullOrWhiteSpace($_)) }) } + elseif ($k -in $refKeys -and ($fixed -is [string])) { $fixed = $null } $out[$k] = $fixed } return $out @@ -252,7 +269,9 @@ function Repair-SettingsCatalogArrays { if ($p.Name -in $arrayKeys) { if ($null -eq $fixed) { $fixed = @() } elseif (-not ($fixed -is [System.Collections.IList])) { $fixed = @($fixed) } + $fixed = @($fixed | Where-Object { -not (($_ -is [string]) -and [string]::IsNullOrWhiteSpace($_)) }) } + elseif ($p.Name -in $refKeys -and ($fixed -is [string])) { $fixed = $null } $out[$p.Name] = $fixed } return $out @@ -267,6 +286,43 @@ function Repair-SettingsCatalogArrays { return $Node } +# Entfernt rekursiv alle Properties mit $null-Wert. Configuration Profiles +# exportieren nicht genutzte Collection-Properties als null; beim POST lehnt Graph +# null fuer 'Collection(...)[Nullable=False]' ab (400 ModelValidationFailure, z.B. +# 'defenderAdditionalGuardedFolders'). Weggelassene Properties belegt Graph mit +# Defaults -> sicheres Strippen. Array-Typen werden am Parent wieder in @() +# gewrappt, damit ein Ein-Element-Array beim Return nicht zum Skalar entpackt wird. +function Remove-PolicyNullProps { + param($Node) + if ($Node -is [System.Collections.IDictionary]) { + $out = @{} + foreach ($k in @($Node.Keys)) { + $v = $Node[$k] + if ($null -eq $v) { continue } + $fixed = Remove-PolicyNullProps $v + if (($v -is [System.Collections.IEnumerable]) -and -not ($v -is [string]) -and -not ($v -is [System.Collections.IDictionary])) { + $out[$k] = @($fixed) + } else { $out[$k] = $fixed } + } + return $out + } + if ($Node -is [System.Management.Automation.PSCustomObject]) { + $out = @{} + foreach ($p in $Node.PSObject.Properties) { + if ($null -eq $p.Value) { continue } + $fixed = Remove-PolicyNullProps $p.Value + if (($p.Value -is [System.Collections.IEnumerable]) -and -not ($p.Value -is [string]) -and -not ($p.Value -is [System.Collections.IDictionary])) { + $out[$p.Name] = @($fixed) + } else { $out[$p.Name] = $fixed } + } + return $out + } + if (($Node -is [System.Collections.IEnumerable]) -and -not ($Node -is [string])) { + return @($Node | ForEach-Object { Remove-PolicyNullProps $_ }) + } + return $Node +} + function New-DefaultComplianceScheduledActions { # Compliance Policies verlangen beim Anlegen mindestens einen # scheduledActionsForRule-Block, sonst antwortet Graph mit 400. @@ -320,6 +376,8 @@ function Import-GraphConfigurationProfile { if (-not $body['@odata.type']) { throw 'Configuration Profile benoetigt @odata.type fuer den Import.' } + # null-Properties strippen: Graph lehnt null fuer nicht-nullbare Collections ab. + $body = Remove-PolicyNullProps $body $json = $body | ConvertTo-Json -Depth 50 return Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations' -Method POST -Body $json -ContentType 'application/json' } @@ -350,6 +408,17 @@ function Import-GraphSettingsCatalogPolicy { $body['settings'] = @() } $json = $body | ConvertTo-Json -Depth 50 + + # Korruptions-Check: enthaelt der Payload noch stringifizierte .NET-Objekte + # ("System.Collections.Hashtable" / "System.Object[]"), stammt die Quelldatei aus + # einem alten Export mit zu geringer ConvertTo-Json-Tiefe. *TemplateReference + # (optionale Metadaten) wurde oben bereits gerettet; verbleibende Marker sitzen in + # WERT-tragenden Feldern (z.B. groupSettingCollectionValue) -> echte Konfiguration + # ist verloren und nicht rekonstruierbar. Klar abbrechen statt kaputt zu importieren. + if ($json -match 'System\.Collections\.Hashtable|System\.Object\[\]') { + throw 'Quelldatei beschaedigt: Teile der Konfiguration wurden von einem alten Export (zu geringe JSON-Tiefe) zu ".ToString()" verstuemmelt und sind nicht wiederherstellbar. Bitte die Policy neu aus Graph exportieren und die frische Datei importieren.' + } + return Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' -Method POST -Body $json -ContentType 'application/json' } @@ -692,9 +761,27 @@ function ConvertTo-StableObject { return $InputObject } +# git muss nicht im PATH des Server-Prozesses liegen (haeufig, wenn der Server vor +# der Git-Installation gestartet wurde oder mit eingefrorener Umgebung laeuft). +# Erst PATH probieren, dann bekannte Installationsorte. Ergebnis wird gecacht. +function Get-GitExe { + if ($script:GitExe -and (Test-Path $script:GitExe)) { return $script:GitExe } + $cmd = Get-Command git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($cmd) { $script:GitExe = $cmd.Source; return $script:GitExe } + $cands = @() + if ($env:ProgramFiles) { $cands += (Join-Path $env:ProgramFiles 'Git\cmd\git.exe') } + if (${env:ProgramFiles(x86)}) { $cands += (Join-Path ${env:ProgramFiles(x86)} 'Git\cmd\git.exe') } + if ($env:LOCALAPPDATA) { $cands += (Join-Path $env:LOCALAPPDATA 'Programs\Git\cmd\git.exe') } + if ($env:LOCALAPPDATA) { $cands += (Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Links\git.exe') } + foreach ($c in $cands) { if ($c -and (Test-Path $c)) { $script:GitExe = $c; return $c } } + return $null +} + function Invoke-Git { param([Parameter(Mandatory=$true)][string]$RepoPath, [Parameter(Mandatory=$true)][string[]]$GitArgs) - $out = & git -C $RepoPath @GitArgs 2>&1 + $exe = Get-GitExe + if (-not $exe) { return @{ exit = 9009; out = 'git nicht gefunden (weder im PATH noch an bekannten Installationsorten).' } } + $out = & $exe -C $RepoPath @GitArgs 2>&1 return @{ exit = $LASTEXITCODE; out = (@($out) -join "`n").Trim() } } @@ -717,9 +804,10 @@ function Invoke-PolicyGitSnapshotEndpoint { return @{ __status = 400; error = 'Kein Git-Repo-Pfad konfiguriert (Einstellungen -> Policy-Backup).' } } - # git verfuegbar? - try { $null = & git --version 2>&1; if ($LASTEXITCODE -ne 0) { throw 'x' } } - catch { return @{ __status = 500; error = 'git ist nicht installiert oder nicht im PATH.' } } + # git verfuegbar? (PATH + bekannte Installationsorte) + if (-not (Get-GitExe)) { + return @{ __status = 500; error = 'git ist nicht auffindbar (weder im PATH des Server-Prozesses noch an den Standard-Installationsorten). Ggf. Server nach der Git-Installation neu starten.' } + } # Repo-Ordner + .git sicherstellen if (-not (Test-Path $repo)) { New-Item -ItemType Directory -Path $repo -Force | Out-Null } diff --git a/www/app.js b/www/app.js index e1309a4..f0ccdb2 100644 --- a/www/app.js +++ b/www/app.js @@ -11,7 +11,7 @@ const State = { targets: [], selectedTargetIds: new Set(), apps: [], - appFilter: { search: '', preset: 'all', category: '', onlyMember: false, hideNative: false, modFrom: '', modTo: '' }, + appFilter: { search: '', preset: 'all', platform: 'all', category: '', onlyMember: false, hideNative: false, modFrom: '', modTo: '' }, session: [], membershipCache: new Map(), loadedModes: new Set(), @@ -432,6 +432,13 @@ document.querySelectorAll('.seg').forEach(seg => { search.value = ''; search.focus(); try { search.setSelectionRange(0, 0); } catch {} + } else if (newMode === 'dept') { + // Gruppen-Modus: Live-Suche ueber ALLE Gruppen (min. 2 Zeichen), kein Praefix-Filter + search.placeholder = 'Gruppe suchen...'; + hint.classList.remove('hidden'); + search.value = ''; + search.focus(); + try { search.setSelectionRange(0, 0); } catch {} } else { search.placeholder = 'Filtern...'; hint.classList.add('hidden'); @@ -441,8 +448,9 @@ document.querySelectorAll('.seg').forEach(seg => { renderTargets(); onTargetSelectionChange(); - // Auto-Load nur wenn fuer diesen Mode noch nichts geladen wurde UND es kein User-Mode ist - if (State.connected && newMode !== 'user' && !State.loadedModes.has(newMode) && State.targets.length === 0) { + // Auto-Load nur fuer Listen-Modi (RPA). 'dept' (Gruppensuche) und 'user' + // laden erst bei Sucheingabe. + if (State.connected && newMode === 'rpa' && !State.loadedModes.has(newMode) && State.targets.length === 0) { loadTargets({ silent: false }); } }); @@ -461,6 +469,14 @@ document.getElementById('btnReloadTargets').addEventListener('click', () => { return; } runUserSearch(term); + } else if (State.mode === 'dept') { + const term = document.getElementById('targetSearch').value; + if (!term || term.length < 2) { + document.getElementById('targetSearch').focus(); + toast('Tippe mind. 2 Zeichen für die Gruppensuche', 'warn'); + return; + } + runGroupSearch(term); } else { State.loadedModes.delete(State.mode); loadTargets({ silent: false }); @@ -470,16 +486,20 @@ document.getElementById('btnReloadTargets').addEventListener('click', () => { document.getElementById('targetSearch').addEventListener('input', e => { if (State.mode === 'user') { debouncedUserSearch(e.target.value); + } else if (State.mode === 'dept') { + debouncedGroupSearch(e.target.value); } else { renderTargets(); } }); -// Enter key triggers explicit user search +// Enter loest die Server-Suche sofort aus (Benutzer- und Gruppen-Modus) document.getElementById('targetSearch').addEventListener('keydown', e => { - if (e.key === 'Enter' && State.mode === 'user') { - const term = e.target.value; - if (term && term.length >= 2) runUserSearch(term); + if (e.key !== 'Enter') return; + const term = e.target.value; + if (term && term.length >= 2) { + if (State.mode === 'user') runUserSearch(term); + else if (State.mode === 'dept') runGroupSearch(term); } }); @@ -576,6 +596,77 @@ async function runUserSearch(term) { } } +// ── Gruppen-Live-Suche: durchsucht ALLE Gruppen (nicht nur Praefix-Gruppen) ── +let groupSearchTimer = null; +let groupSearchAbort = null; +let groupSearchCache = { query: '', results: [] }; + +// Bereits ausgewaehlte Gruppen in ein neues Ergebnis mergen, damit die Auswahl +// (und die Namen in der Pinned-Leiste) ueber mehrere Suchen hinweg bestehen bleibt. +function mergeSelectedGroups(results) { + const ids = new Set(results.map(r => r.Id)); + const keep = State.targets.filter(t => State.selectedTargetIds.has(t.Id) && !ids.has(t.Id)); + return keep.length ? results.concat(keep) : results; +} + +function debouncedGroupSearch(term) { + clearTimeout(groupSearchTimer); + const t = (term || '').trim(); + if (t.length < 2) { + // Unter 2 Zeichen: nur die ausgewaehlten Gruppen stehen lassen (Auswahl behalten) + State.targets = State.targets.filter(x => State.selectedTargetIds.has(x.Id)); + groupSearchCache = { query: '', results: [] }; + renderTargets(); + return; + } + // Optimistisch lokal verfeinern, wenn die neue Anfrage die vorige praezisiert + const lower = t.toLowerCase(); + const cachedLower = groupSearchCache.query.toLowerCase(); + if (cachedLower && lower.startsWith(cachedLower) && groupSearchCache.results.length > 0) { + const filtered = groupSearchCache.results.filter(g => (g.DisplayName || '').toLowerCase().includes(lower)); + State.targets = mergeSelectedGroups(filtered); + renderTargets(); + return; + } + groupSearchTimer = setTimeout(() => runGroupSearch(t), 220); +} + +async function runGroupSearch(term) { + if (!State.connected) { toast('Bitte zuerst verbinden', 'warn'); return; } + + if (groupSearchAbort) { try { groupSearchAbort.abort(); } catch {} } + groupSearchAbort = new AbortController(); + const signal = groupSearchAbort.signal; + + document.getElementById('targetList').innerHTML = ` +
+
+
+
+
`; + try { + const resp = await fetch('/api/groups/search?q=' + encodeURIComponent(term), { + headers: { 'Content-Type': 'application/json' }, + signal, + }); + let data = {}; + try { data = await resp.json(); } catch {} + if (signal.aborted) return; + if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); + + const results = data.items || []; + groupSearchCache = { query: term, results: results.slice() }; + State.targets = mergeSelectedGroups(results); + renderTargets(); + onTargetSelectionChange(); + if (results.length === 0) toast(`Keine Gruppen für "${term}" gefunden`, 'warn'); + } catch (e) { + if (e.name === 'AbortError') return; // neue Suche laeuft schon + toast(e.message, 'err', 'Gruppensuche fehlgeschlagen'); + renderTargets(); + } +} + let loadTargetsInFlight = null; // welcher Mode laeuft gerade async function loadTargets(opts = {}) { @@ -589,12 +680,19 @@ async function loadTargets(opts = {}) { } return runUserSearch(term); } + // 'dept' (Gruppen) ist suchbasiert — kein Listen-Load. Bei vorhandener Eingabe + // die Suche ausloesen, sonst nichts tun (auch beim Auto-Load nach Connect). + if (State.mode === 'dept') { + const term = document.getElementById('targetSearch').value; + if (term && term.length >= 2) return runGroupSearch(term); + return; + } const requestedMode = State.mode; if (loadTargetsInFlight === requestedMode) return; // gleiche Anfrage laeuft bereits loadTargetsInFlight = requestedMode; - if (!opts.silent) setLoading('Lade ' + (requestedMode === 'rpa' ? 'RPA-Gruppen' : 'Abteilungen') + '...'); + if (!opts.silent) setLoading('Lade ' + (requestedMode === 'rpa' ? 'RPA-Gruppen' : 'Gruppen') + '...'); document.getElementById('targetList').innerHTML = `
@@ -619,7 +717,7 @@ async function loadTargets(opts = {}) { }); renderTargets(); onTargetSelectionChange(); - if (!opts.silent) toast(`${res.count} ${requestedMode === 'rpa' ? 'RPA-Gruppen' : 'Abteilungen'} geladen`, 'ok'); + if (!opts.silent) toast(`${res.count} ${requestedMode === 'rpa' ? 'RPA-Gruppen' : 'Gruppen'} geladen`, 'ok'); } catch (e) { if (State.mode === requestedMode) { toast(e.message, 'err', 'Laden fehlgeschlagen'); @@ -633,7 +731,8 @@ async function loadTargets(opts = {}) { function getFilteredTargets() { const q = document.getElementById('targetSearch').value.toLowerCase().trim(); - if (!q || State.mode === 'user') return State.targets; + // 'user' und 'dept' werden serverseitig gesucht -> State.targets ist bereits das Ergebnis. + if (!q || State.mode === 'user' || State.mode === 'dept') return State.targets; return State.targets.filter(t => (t.DisplayName || '').toLowerCase().includes(q) || (t.UserPrincipalName || '').toLowerCase().includes(q) @@ -656,13 +755,17 @@ function renderTargets() { } if (filtered.length === 0) { - if (State.mode === 'user') { + if (State.mode === 'user' || State.mode === 'dept') { const term = document.getElementById('targetSearch').value; + const isUser = State.mode === 'user'; + const has2 = term && term.length >= 2; list.innerHTML = emptyState({ - title: term && term.length >= 2 ? 'Keine Treffer' : 'Benutzer suchen', - text: term && term.length >= 2 - ? `Keine Benutzer für "${escapeHtml(term)}" gefunden.` - : 'Tippe mind. 2 Zeichen — Name, UPN oder E-Mail.', + title: has2 ? 'Keine Treffer' : (isUser ? 'Benutzer suchen' : 'Gruppe suchen'), + text: has2 + ? `Keine ${isUser ? 'Benutzer' : 'Gruppen'} für "${escapeHtml(term)}" gefunden.` + : (isUser + ? 'Tippe mind. 2 Zeichen — Name, UPN oder E-Mail.' + : 'Tippe mind. 2 Zeichen — durchsucht alle Gruppen im Tenant.'), }); } else { list.innerHTML = emptyState({ @@ -776,7 +879,7 @@ function renderPinnedTargets() { } wrap.classList.remove('hidden'); - const modeLabels = { dept: 'Abt', rpa: 'RPA', user: 'User' }; + const modeLabels = { dept: 'Grp', rpa: 'RPA', user: 'User' }; const chips = items.map(it => { const isEmpty = it.hasMembers === false ? 'pin-empty' : ''; const title = `${it.name}${it.meta ? ' · ' + it.meta : ''} (${modeLabels[it.mode] || it.mode}) — Klick: zur Zeile springen, X: entfernen`; @@ -865,6 +968,10 @@ document.getElementById('appFilter').addEventListener('change', e => { State.appFilter.preset = e.target.value; renderApps(); }); +document.getElementById('appPlatformFilter')?.addEventListener('change', e => { + State.appFilter.platform = e.target.value; + renderApps(); +}); document.getElementById('appCategoryFilter').addEventListener('change', e => { State.appFilter.category = e.target.value; renderApps(); @@ -1008,6 +1115,13 @@ function getFilteredApps() { let list = State.apps.slice(); const f = State.appFilter; if (f.search) list = list.filter(a => (a.AppName || '').toLowerCase().includes(f.search)); + // Plattform-Filter ueber den rohen @odata.type: macOS-Typen enthalten "macos" + // (z.B. macOSDmgApp, macOsVppApp), alles uebrige ist Windows (iOS/Android sind + // bereits serverseitig ausgeschlossen). + if (f.platform && f.platform !== 'all') { + const isMac = a => (a.AppTypeRaw || '').toLowerCase().includes('macos'); + list = f.platform === 'macos' ? list.filter(isMac) : list.filter(a => !isMac(a)); + } switch (f.preset) { case 'none': list = list.filter(a => a.AvailableCount === 0 && a.RequiredCount === 0); break; case 'available': list = list.filter(a => a.AvailableCount > 0 && a.RequiredCount === 0); break; @@ -1694,6 +1808,12 @@ async function jumpToAppById(appId) { // Suchfeld leeren damit die App garantiert sichtbar ist const search = document.getElementById('appSearch'); if (search && search.value) { search.value = ''; State.appFilter.search = ''; } + // Plattform-Filter zuruecksetzen, sonst koennte die Ziel-App ausgeblendet sein + if (State.appFilter.platform !== 'all') { + State.appFilter.platform = 'all'; + const pf = document.getElementById('appPlatformFilter'); + if (pf) pf.value = 'all'; + } // WICHTIG: Wenn die App noch nicht expanded ist, muessen wir den DETAILS- // FETCH triggern. Frueher haben wir nur expandedApps.add() gemacht — ohne @@ -2156,7 +2276,7 @@ function toggleSession({ appId, groupId, groupName, type }) { return; } if (allSel.length === 0) { - toast('Wähle erst Abteilungen oder Benutzer aus', 'warn'); + toast('Wähle erst Gruppen oder Benutzer aus', 'warn'); return; } @@ -2441,7 +2561,7 @@ async function applyAssignments() { document.getElementById('confirmText').innerHTML = ` ${total} Vorgang${total === 1 ? '' : 'e'} wird ausgeführt:
    - ${deptOps > 0 ? `
  • ${deptOps} auf Abteilungsgruppen
  • ` : ''} + ${deptOps > 0 ? `
  • ${deptOps} auf Gruppen
  • ` : ''} ${userOps > 0 ? `
  • ${userOps} auf Einzelbenutzer
  • ` : ''}
`; openModal('modalConfirm'); @@ -2662,17 +2782,19 @@ function getCurrentCgMode() { return el ? el.value : 'req-group'; } -// Modus -> { intent, isGroup, target } — single source of truth +// Modus -> { intent, isGroup, isExisting, 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' }; + case 'req-group': return { intent: 'required', isGroup: true, isExisting: false, target: null }; + case 'req-existing': return { intent: 'required', isGroup: false, isExisting: true, target: null }; + case 'req-allusers': return { intent: 'required', isGroup: false, isExisting: false, target: 'ALL_USERS' }; + case 'req-alldevices': return { intent: 'required', isGroup: false, isExisting: false, target: 'ALL_DEVICES' }; + case 'avail-group': return { intent: 'available', isGroup: true, isExisting: false, target: null }; + case 'avail-existing': return { intent: 'available', isGroup: false, isExisting: true, target: null }; + case 'avail-allusers': return { intent: 'available', isGroup: false, isExisting: false, target: 'ALL_USERS' }; + case 'avail-alldevices':return { intent: 'available', isGroup: false, isExisting: false, target: 'ALL_DEVICES' }; } - return { intent: 'required', isGroup: true, target: null }; + return { intent: 'required', isGroup: true, isExisting: false, target: null }; } function openCreateGroupModal(appId, appName) { @@ -2720,6 +2842,7 @@ function openCreateGroupModal(appId, appName) { } } document.getElementById('cgAssignToApp').checked = true; + resetCgExisting(); applyCgModeUi(); openModal('modalCreateGroup'); } @@ -2731,12 +2854,20 @@ function applyCgModeUi() { const meta = cgModeMeta(mode); const nameGroup = document.getElementById('cgGroupNameGroup'); const assignWrap = document.getElementById('cgAssignToAppWrap'); + const existingGroup = document.getElementById('cgExistingGroup'); const createBtn = document.getElementById('cgCreate'); const intentLabel = meta.intent === 'available' ? 'Available' : 'Required'; - if (meta.isGroup) { + if (meta.isExisting) { + // Bestehende Gruppe(n) suchen + zuweisen (Mehrfachauswahl) + nameGroup.hidden = true; + assignWrap.hidden = true; + if (existingGroup) existingGroup.hidden = false; + renderCgSelected(); // setzt Button-Label + disabled anhand der Auswahl + } else if (meta.isGroup) { nameGroup.hidden = false; assignWrap.hidden = false; + if (existingGroup) existingGroup.hidden = true; document.getElementById('cgGroupName').value = suggestedGroupName(createGroupContext.appName, meta.intent); document.querySelector('#cgAssignToAppWrap span').textContent = `Gruppe direkt der App als ${intentLabel} zuweisen`; @@ -2745,6 +2876,7 @@ function applyCgModeUi() { } else { nameGroup.hidden = true; assignWrap.hidden = true; + if (existingGroup) existingGroup.hidden = true; createBtn.disabled = false; const tgtLbl = meta.target === 'ALL_USERS' ? 'All Users' : 'All Devices'; createBtn.textContent = `Als ${intentLabel} für ${tgtLbl} zuweisen`; @@ -2755,6 +2887,110 @@ document.querySelectorAll('input[name="cgMode"]').forEach(r => { r.addEventListener('change', applyCgModeUi); }); +// ── "Bestehende Gruppe" — Mehrfach-Suche + -Auswahl im Zuweisungs-Dialog ── +let cgExistingTimer = null; +let cgExistingAbort = null; + +function cgSelectedGroups() { + if (!createGroupContext) return []; + if (!Array.isArray(createGroupContext.selectedGroups)) createGroupContext.selectedGroups = []; + return createGroupContext.selectedGroups; +} + +function resetCgExisting() { + if (createGroupContext) createGroupContext.selectedGroups = []; + const s = document.getElementById('cgExistingSearch'); + const r = document.getElementById('cgExistingResults'); + if (s) s.value = ''; + if (r) r.innerHTML = ''; + renderCgSelected(); +} + +// Ausgewaehlte Gruppen als entfernbare Chips zeigen + Button-Status/Label setzen. +function renderCgSelected() { + const sel = document.getElementById('cgExistingSelected'); + const groups = cgSelectedGroups(); + if (sel) { + sel.className = 'cg-existing-chips'; + sel.innerHTML = groups.length === 0 + ? 'Noch keine Gruppe ausgewählt.' + : groups.map(g => + ` + ${escapeHtml(g.name)} + + `).join(''); + } + // Nur im "Bestehende Gruppe"-Modus den Erstellen/Zuweisen-Button steuern. + const meta = cgModeMeta(getCurrentCgMode()); + if (meta.isExisting) { + const btn = document.getElementById('cgCreate'); + const intentLabel = meta.intent === 'available' ? 'Available' : 'Required'; + btn.disabled = groups.length === 0; + btn.textContent = groups.length > 1 + ? `${groups.length} Gruppen als ${intentLabel} zuweisen` + : `Als ${intentLabel} zuweisen`; + } +} + +document.getElementById('cgExistingSearch')?.addEventListener('input', e => { + clearTimeout(cgExistingTimer); + const term = e.target.value.trim(); + const results = document.getElementById('cgExistingResults'); + if (term.length < 2) { if (results) results.innerHTML = ''; return; } + cgExistingTimer = setTimeout(() => runCgExistingSearch(term), 250); +}); + +async function runCgExistingSearch(term) { + const results = document.getElementById('cgExistingResults'); + if (!results) return; + if (cgExistingAbort) { try { cgExistingAbort.abort(); } catch {} } + cgExistingAbort = new AbortController(); + results.innerHTML = '
Suche…
'; + try { + const resp = await fetch('/api/groups/search?q=' + encodeURIComponent(term), { signal: cgExistingAbort.signal }); + let data = {}; try { data = await resp.json(); } catch {} + if (cgExistingAbort.signal.aborted) return; + if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`); + const items = data.items || []; + if (items.length === 0) { results.innerHTML = '
Keine Treffer
'; return; } + const selIds = new Set(cgSelectedGroups().map(g => g.id)); + results.innerHTML = items.map(g => + ``).join(''); + } catch (e) { + if (e.name === 'AbortError') return; + results.innerHTML = `
Fehler: ${escapeHtml(e.message)}
`; + } +} + +// Ergebnis anklicken -> Auswahl togglen (Mehrfachauswahl) +document.getElementById('cgExistingResults')?.addEventListener('click', e => { + const btn = e.target.closest('.cg-existing-item'); + if (!btn) return; + const id = btn.dataset.id, name = btn.dataset.name; + const groups = cgSelectedGroups(); + const idx = groups.findIndex(g => g.id === id); + if (idx >= 0) { groups.splice(idx, 1); btn.classList.remove('is-selected'); } + else { groups.push({ id, name }); btn.classList.add('is-selected'); } + renderCgSelected(); +}); + +// Chip entfernen -> Auswahl abwaehlen (und ggf. Ergebnis-Item entmarkieren) +document.getElementById('cgExistingSelected')?.addEventListener('click', e => { + const x = e.target.closest('[data-remove-id]'); + if (!x) return; + const id = x.dataset.removeId; + const groups = cgSelectedGroups(); + const idx = groups.findIndex(g => g.id === id); + if (idx >= 0) groups.splice(idx, 1); + document.querySelectorAll('.cg-existing-item').forEach(it => { + if (it.dataset.id === id) it.classList.remove('is-selected'); + }); + renderCgSelected(); +}); + document.getElementById('cgGroupName').addEventListener('input', () => { clearTimeout(cgCheckTimer); cgCheckTimer = setTimeout(checkGroupName, 400); @@ -2793,6 +3029,30 @@ document.getElementById('cgCreate').addEventListener('click', async () => { if (!createGroupContext) return; const meta = cgModeMeta(getCurrentCgMode()); + if (meta.isExisting) { + // Bestehende Gruppe(n) zuweisen — EIN Request mit allen Zielen. Das Backend + // mergt die neuen Gruppen mit den bestehenden Zuweisungen (die /assign-Action + // ersetzt sonst die komplette Liste -> nur die letzte Gruppe bliebe uebrig). + const groups = cgSelectedGroups(); + if (groups.length === 0) { toast('Bitte zuerst mind. eine Gruppe auswählen', 'warn'); return; } + const intentLabel = meta.intent === 'available' ? 'Available' : 'Required'; + setLoading(`Weise ${groups.length} Gruppe(n) als ${intentLabel} zu...`); + try { + await api(`/api/apps/${encodeURIComponent(createGroupContext.appId)}/assignments`, { + method: 'POST', + body: { intent: meta.intent, targets: groups.map(g => g.id) }, + }); + toast(`${groups.length} ${intentLabel}-Zuweisung(en) hinzugefügt`, 'ok'); + closeModal('modalCreateGroup'); + await loadApps(true); + } catch (e) { + toast(e.message, 'err', 'Zuweisung fehlgeschlagen'); + } finally { + clearLoading(); + } + return; + } + if (meta.isGroup) { // Gruppe erstellen + zuweisen const { prefix, suffix } = getNamingFor(meta.intent); @@ -3303,7 +3563,7 @@ function applyExpandedSelection(groupId) { renderTargets(); onTargetSelectionChange(); - toast(`${picked.length} Benutzer aus "${entry.name}" übernommen — Abteilungs-Auswahl bleibt erhalten`, 'ok'); + toast(`${picked.length} Benutzer aus "${entry.name}" übernommen — Gruppen-Auswahl bleibt erhalten`, 'ok'); } // (Alte Modal-Logik — unbenutzt, aber harmlos:) @@ -5968,10 +6228,30 @@ async function polDoImport() { if (nn && nn !== polEnvName(env)) env.newName = nn; else delete env.newName; }); closeModal('modalPolImport'); + const envelopes = _polImportEnvelopes.slice(); + const total = envelopes.length; setLoading('Importiere Policies…'); try { - const res = await api('/api/policies/import', { method: 'POST', body: { policies: _polImportEnvelopes }, timeoutMs: 180000 }); - const results = res.results || []; + // Sequenzieller Server-Import in kleinen Batches: umgeht die 180-s-Timeout-Wand + // eines einzelnen Requests bei vielen Policies und erlaubt Fortschrittsanzeige. + const BATCH = 5; + const loadingText = document.getElementById('loadingText'); + const results = []; + for (let start = 0; start < total; start += BATCH) { + const chunk = envelopes.slice(start, start + BATCH); + if (loadingText && total > BATCH) { + loadingText.textContent = `Importiere Policies… (${start + 1}–${Math.min(start + chunk.length, total)} von ${total})`; + } + // Timeout pro Batch nach Batch-Größe skaliert (Anlegen + Retry je Policy). + try { + const res = await api('/api/policies/import', { method: 'POST', body: { policies: chunk }, timeoutMs: 30000 + chunk.length * 30000 }); + if (res && res.results) results.push(...res.results); + } catch (be) { + // Batch-Fehler nicht den Gesamtimport abbrechen lassen — als fehlgeschlagen + // vermerken und mit dem nächsten Batch weitermachen. + chunk.forEach(env => results.push({ policyName: polEnvName(env), success: false, error: be.message })); + } + } const ok = results.filter(r => r.success); const fail = results.filter(r => !r.success); if (fail.length) { diff --git a/www/index.html b/www/index.html index b96adfe..fc9e9d4 100644 --- a/www/index.html +++ b/www/index.html @@ -126,7 +126,7 @@
-

Abteilungen & Benutzer

+

Gruppen & Benutzer

0
- +
@@ -193,6 +193,11 @@ + + + Bestehende Gruppe + Eine vorhandene Entra-Gruppe als Required zuweisen. + + +
+