# API-Endpoint-Handler # Routing-Konvention: $Path startet mit /api/ und wird hier gematched. function Invoke-ApiHandler { param( [Parameter(Mandatory=$true)][string]$Path, [Parameter(Mandatory=$true)][string]$Method, $Body, $Query ) $key = "$Method $Path" switch ($key) { "GET /api/ping" { return @{ pong = $true; time = (Get-Date).ToString("HH:mm:ss.fff") } } "GET /api/version" { return @{ version = $script:ToolVersion; build = $script:BuildStamp } } "GET /api/status" { return Get-StatusEndpoint } "GET /api/config" { return Get-ConfigEndpoint } "GET /api/settings" { return Get-SettingsEndpoint } "PUT /api/settings" { return Save-SettingsEndpoint -Body $Body } "POST /api/settings/reset" { return Reset-SettingsEndpoint } "POST /api/settings/test" { return Test-SettingsEndpoint -Body $Body } "POST /api/settings/logo" { return Save-LogoEndpoint -Body $Body } "DELETE /api/settings/logo" { return Remove-LogoEndpoint } "POST /api/connect" { return Invoke-ConnectEndpoint } "POST /api/connect/token" { return Invoke-ConnectWithTokenEndpoint -Body $Body } "POST /api/connect/start" { return Start-DeviceCodeConnect } "POST /api/connect/cancel" { Stop-ConnectFlow; return @{ ok = $true } } "POST /api/disconnect" { return Invoke-DisconnectEndpoint } "GET /api/groups" { return Get-GroupsEndpoint -Query $Query } "GET /api/groups/rpa" { return Get-RpaGroupsEndpoint } "GET /api/groups/search" { return Search-GroupsEndpoint -Query $Query } "POST /api/groups" { return New-GroupEndpoint -Body $Body } "POST /api/groups/check" { return Test-GroupNameEndpoint -Body $Body } "GET /api/users" { return Search-UsersEndpoint -Query $Query } "GET /api/apps" { return Get-AppsEndpoint -Query $Query } "GET /api/apps/categories" { return Get-AppCategoriesEndpoint } "POST /api/apps/refresh" { return Get-AppsEndpoint -Query @{ refresh = "true" } } "GET /api/membership" { return Get-MembershipEndpoint -Query $Query } "POST /api/membership/bulk" { return Get-MembershipBulkEndpoint -Body $Body } "POST /api/assignments/apply" { return Invoke-ApplyEndpoint -Body $Body } } # 2-segment fallbacks (z.B. /api/groups//members) # Export-Route VOR der generischen /members-Route pruefen 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/([^/]+)/members$") { return Get-GroupMembersEndpoint -GroupId $matches[1] } if ($Method -eq "POST" -and $Path -match "^/api/groups/([^/]+)/members$") { return Add-GroupMembersEndpoint -GroupId $matches[1] -Body $Body } if ($Method -eq "DELETE" -and $Path -match "^/api/groups/([^/]+)/members/([^/]+)$") { return Remove-GroupMemberEndpoint -GroupId $matches[1] -UserId $matches[2] } if ($Method -eq "GET" -and $Path -match "^/api/apps/([^/]+)/details$") { return Get-AppDetailsEndpoint -AppId $matches[1] } if ($Method -eq "DELETE" -and $Path -match "^/api/apps/([^/]+)$") { return Remove-AppEndpoint -AppId $matches[1] } if ($Method -eq "PATCH" -and $Path -match "^/api/apps/([^/]+)$") { return Update-AppEndpoint -AppId $matches[1] -Body $Body } if ($Method -eq "DELETE" -and $Path -match "^/api/apps/([^/]+)/assignments/([^/]+)$") { return Remove-AppAssignmentEndpoint -AppId $matches[1] -GroupId $matches[2] -Query $Query } if ($Method -eq "POST" -and $Path -match "^/api/apps/([^/]+)/assignments$") { return Add-AppAssignmentEndpoint -AppId $matches[1] -Body $Body } if ($Method -eq "POST" -and $Path -match "^/api/apps/([^/]+)/content$") { return Update-AppContentEndpoint -AppId $matches[1] -Body $Body } if ($Method -eq "POST" -and $Path -eq "/api/pickfile") { return Invoke-FilePickerEndpoint -Body $Body } return $null } # ============================================================ # Status & Connection # ============================================================ function Get-ConfigEndpoint { return @{ tenantId = $script:Config.TenantId clientId = $script:Config.ClientId scopes = $script:Config.Scopes } } # ============================================================ # Settings: lesen / schreiben / zuruecksetzen # ============================================================ function Get-SettingsEndpoint { # cacheTag fuer das Logo: nutzt File-Mtime — so kann das Frontend den # Browser-Cache zuverlaessig brechen, unabhaengig davon ob das Logo gerade # erst hochgeladen oder die Seite frisch geladen wurde. $logoCacheTag = $null if ($script:Settings.branding -and $script:Settings.branding.logoFile) { $logoPath = Join-Path (Get-BrandingDir) $script:Settings.branding.logoFile if (Test-Path $logoPath -PathType Leaf) { $logoCacheTag = [DateTimeOffset]::new((Get-Item $logoPath).LastWriteTimeUtc).ToUnixTimeSeconds() } else { # Datei verschwunden -> Setting zuruecksetzen damit das Frontend # nicht versucht ein 404-Image zu rendern $script:Settings.branding.logoFile = $null } } # Klon des Settings-Objekts mit branding.logoCacheTag — damit kein # zusaetzlicher Outer-Key noetig ist und das Frontend einheitlich nur # 'branding' liest. $settings = $script:Settings | ConvertTo-Json -Depth 10 | ConvertFrom-Json if (-not $settings.branding) { $settings | Add-Member -NotePropertyName 'branding' -NotePropertyValue ([pscustomobject]@{ logoFile = $null; logoCacheTag = $null }) -Force } else { $settings.branding | Add-Member -NotePropertyName 'logoCacheTag' -NotePropertyValue $logoCacheTag -Force } return @{ settings = $settings; path = (Get-SettingsPath) } } function Sync-ConfigFromSettings { # $script:Config spiegelt die settings.connection-Werte. Wird nach jedem # Save aufgerufen damit Connect-Aufrufe sofort die neuen IDs verwenden. $script:Config.TenantId = $script:Settings.connection.tenantId $script:Config.ClientId = $script:Settings.connection.clientId $script:Config.Scopes = @($script:Settings.connection.scopes) } function Save-SettingsEndpoint { param($Body) if (-not $Body) { return @{ __status = 400; error = "Request-Body fehlt" } } # Body kann hashtable (vom Router) oder pscustomobject sein -> normalisieren $incoming = $Body if ($incoming -is [hashtable]) { $incoming = $incoming | ConvertTo-Json -Depth 10 | ConvertFrom-Json } # Mit aktuellen Settings mergen, damit ueberspringbare Sub-Sektionen aus dem # Frontend nichts ueberschreiben was nicht mitgeschickt wurde. $merged = Merge-Settings -Base $script:Settings -Override $incoming $errs = Get-SettingsValidationErrors -S $merged if ($errs.Count -gt 0) { return @{ __status = 400; error = ($errs -join " | "); errors = $errs } } # Vergleich altes vs. neues Setting — Cache nur leeren wo wirklich noetig. $old = $script:Settings $connectionChanged = ( $merged.connection.tenantId -ne $old.connection.tenantId -or $merged.connection.clientId -ne $old.connection.clientId -or (($merged.connection.scopes -join "|") -ne ($old.connection.scopes -join "|")) ) # Normalisierte Praefix-Listen vergleichen (deckt sowohl 'prefixes' als auch # den alten Single-String 'prefix' ab; Reihenfolge ignoriert, Case ignoriert). $deptOld = @(Get-DepartmentPrefixes -Settings $old) | Sort-Object -Property { $_.ToLowerInvariant() } $deptNew = @(Get-DepartmentPrefixes -Settings $merged) | Sort-Object -Property { $_.ToLowerInvariant() } $deptChanged = (($deptOld -join '|') -ne ($deptNew -join '|')) $rpaChanged = (($merged.rpa.groupNames -join "|") -ne ($old.rpa.groupNames -join "|")) $userSearchChanged = ( (($merged.userSearch.fields -join "|") -ne ($old.userSearch.fields -join "|")) ) # requiredGroupNaming + theme + branding sind reine UI-/Workflow-Werte — # die brechen keine Backend-Caches. $script:Settings = $merged Sync-ConfigFromSettings try { Write-Settings -Settings $script:Settings } catch { return @{ __status = 500; error = "Speichern fehlgeschlagen: $($_.Exception.Message)" } } if ($connectionChanged -and $script:State.Connected) { Write-Host "[SETTINGS] Connection-Werte geaendert -> Disconnect" -ForegroundColor Yellow try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} $script:State.Connected = $false $script:State.Account = $null $script:State.TenantId = $null # Bei neuem Tenant ist ALLES potentiell anders — alle Caches weg. $script:State.Groups = @() $script:State.RpaGroups = @() $script:State.Apps = @() $script:State.GroupMembers = @{} } else { # Selektiv: nur die Caches leeren, deren Quelle sich tatsaechlich # geaendert hat. if ($deptChanged) { $script:State.Groups = @() } if ($rpaChanged) { $script:State.RpaGroups = @() } } $changed = @{ connection = [bool]$connectionChanged departments = [bool]$deptChanged rpa = [bool]$rpaChanged userSearch = [bool]$userSearchChanged # Diese muss das Frontend lokal anwenden, kein Backend-Cache-Reset noetig: branding = (($merged.branding.logoFile) -ne ($old.branding.logoFile)) theme = (($merged.theme.colors | ConvertTo-Json -Compress) -ne ($old.theme.colors | ConvertTo-Json -Compress)) requiredGroupNaming = ($merged.requiredGroupNaming.prefix -ne $old.requiredGroupNaming.prefix -or $merged.requiredGroupNaming.suffix -ne $old.requiredGroupNaming.suffix) } Write-Host ("[SETTINGS] geaendert: " + (($changed.GetEnumerator() | Where-Object { $_.Value }) | ForEach-Object { $_.Key } | Sort-Object) -join ', ') -ForegroundColor DarkGray return @{ ok = $true settings = $script:Settings disconnected = [bool]$connectionChanged changed = $changed } } function Reset-SettingsEndpoint { $script:Settings = Get-DefaultSettings Sync-ConfigFromSettings try { Write-Settings -Settings $script:Settings } catch { return @{ __status = 500; error = "Reset fehlgeschlagen: $($_.Exception.Message)" } } if ($script:State.Connected) { try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} $script:State.Connected = $false $script:State.Account = $null $script:State.TenantId = $null } $script:State.Groups = @() $script:State.RpaGroups = @() $script:State.Apps = @() $script:State.GroupMembers = @{} return @{ ok = $true; settings = $script:Settings; disconnected = $true } } function Test-SettingsEndpoint { # Prueft die im Body uebergebenen (oder, falls leer, die aktuell gespeicherten) # Settings gegen Microsoft Graph. Liefert pro Pruefung ok/Trefferzahl/Fehler. # Aenderungen werden NICHT gespeichert — nur ein temporaerer Swap fuers Lookup. param($Body) $err = Test-Connected if ($err) { return @{ ok = $false connection = @{ ok = $false; reason = "not_connected"; message = "Bitte zuerst per Connect-Button verbinden." } } } # Body normalisieren (Hashtable -> PSCustomObject) und mit aktuellen Settings mergen. $incoming = $Body if ($incoming -is [hashtable]) { $incoming = $incoming | ConvertTo-Json -Depth 10 | ConvertFrom-Json } $effective = if ($incoming) { Merge-Settings -Base $script:Settings -Override $incoming } else { $script:Settings } # Settings nur fuer die Dauer des Tests umschalten — Search-GraphUser greift # auf $script:Settings.userSearch.fields zu. Im finally garantiert zuruecksetzen. $original = $script:Settings $script:Settings = $effective $result = @{ ok = $true connection = @{ ok = $true; tenantId = $script:State.TenantId; account = $script:State.Account } } try { # 1) Abteilungs-Lookup pro konfiguriertem Praefix $prefixes = @(Get-DepartmentPrefixes -Settings $effective) if ($prefixes.Count -eq 0) { $result.departments = @{ ok = $false; reason = "not_configured"; message = "Kein Abteilungs-Praefix gesetzt." } } else { $perPrefix = @() $totalCount = 0 $allOk = $true foreach ($p in $prefixes) { try { $groups = @(Get-GraphGroupByFilter -Filter "startswith(displayName,'$p')" -Property @("id","displayName")) $sample = @($groups | Select-Object -First 3 | ForEach-Object { if ($_.displayName) { $_.displayName } else { $_.displayname } }) $perPrefix += @{ prefix = $p; ok = $true; count = $groups.Count; sample = $sample } $totalCount += $groups.Count } catch { $perPrefix += @{ prefix = $p; ok = $false; error = $_.Exception.Message } $allOk = $false } } $result.departments = @{ ok = $allOk prefixes = $prefixes totalCount = $totalCount perPrefix = $perPrefix } } # 2) RPA-Gruppen-Lookup $rpaNames = @($effective.rpa.groupNames | Where-Object { $_ -and $_.Trim() }) if ($rpaNames.Count -eq 0) { $result.rpa = @{ ok = $false; reason = "not_configured"; message = "Keine RPA-Gruppen konfiguriert." } } else { $found = @() $missing = @() foreach ($n in $rpaNames) { try { $g = @(Get-GraphGroupByFilter -Filter "displayName eq '$n'" -Property @("id","displayName")) if ($g.Count -gt 0) { $found += $n } else { $missing += $n } } catch { $missing += $n } } $result.rpa = @{ ok = ($missing.Count -eq 0) expected = $rpaNames.Count found = $found missing = $missing } } # 3) User-Such-Probe — sehr kurzer Sondierungs-Request $fields = @($effective.userSearch.fields | Where-Object { $_ }) if ($fields.Count -eq 0) { $result.userSearch = @{ ok = $false; reason = "no_fields"; message = "Mindestens ein Such-Feld waehlen." } } else { try { # Such-Term "a" -> trifft praktisch immer mind. einen User, schnell genug. $hits = @(Search-GraphUser -SearchTerm "a") $result.userSearch = @{ ok = $true; fields = $fields; sampleCount = [Math]::Min($hits.Count, 10) } } catch { $result.userSearch = @{ ok = $false; fields = $fields; error = $_.Exception.Message } } } # 4) Vendor-Match-Counts — pro konfiguriertem Vendor zaehlen wie viele # gecachte Apps unter dessen Detection-Regel fallen. 0 Treffer = die # Regel greift nicht (Hinweis im UI). $vendorList = @($effective.vendors) if ($vendorList.Count -eq 0) { $result.vendors = @{ ok = $true; configured = 0; counts = @() } } else { $counts = @() foreach ($v in $vendorList) { $count = 0 if ($script:State.Apps -and $script:State.Apps.Count -gt 0) { $count = @($script:State.Apps | Where-Object { $_.Source -eq $v.displayName }).Count } $counts += [pscustomobject]@{ id = $v.id displayName = $v.displayName count = $count detection = "$($v.detection.field) $($v.detection.match) '$($v.detection.pattern)'" } } $allZero = -not ($counts | Where-Object { $_.count -gt 0 }) $result.vendors = @{ ok = $true configured = $vendorList.Count counts = $counts hint = if ($allZero -and $script:State.Apps.Count -eq 0) { "Apps wurden noch nicht geladen — Counts sind 0." } else { $null } } } } finally { $script:Settings = $original } # Gesamt-OK = alle Sub-Checks ok (Vendors sind informativ, schlagen nicht fehl) $result.ok = ($result.departments.ok -and $result.rpa.ok -and $result.userSearch.ok) return $result } # ============================================================ # Branding: Logo hochladen / loeschen # ============================================================ # Wo Logos landen — gleiches Verzeichnis wie intune.png/pmpc.png, ausgeliefert # ueber die vorhandene /assets/-Route. function Get-BrandingDir { return Split-Path -Parent $script:Config.WebRoot } # Branding-Logos haben einen festen Praefix, damit wir alte Versionen sauber # loeschen koennen wenn die Extension wechselt. $script:BrandingLogoPrefix = 'branding-logo' function Remove-BrandingLogoFiles { $dir = Get-BrandingDir Get-ChildItem -Path $dir -Filter "$($script:BrandingLogoPrefix).*" -File -ErrorAction SilentlyContinue | ForEach-Object { Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue } } function Save-LogoEndpoint { param($Body) if (-not $Body) { return @{ __status = 400; error = "Body fehlt" } } $mime = [string]$Body.mime $dataBase64 = [string]$Body.dataBase64 if (-not $dataBase64) { return @{ __status = 400; error = "dataBase64 fehlt" } } if ($mime -notmatch '^image/') { return @{ __status = 400; error = "Datei muss ein Bild sein (mime: $mime)" } } $ext = switch -Regex ($mime) { 'png$' { 'png'; break } 'jpe?g$' { 'jpg'; break } 'svg' { 'svg'; break } 'webp' { 'webp'; break } 'gif' { 'gif'; break } default { $null } } if (-not $ext) { return @{ __status = 400; error = "Bildformat nicht unterstuetzt: $mime" } } try { $bytes = [Convert]::FromBase64String($dataBase64) } catch { return @{ __status = 400; error = "Base64 ungueltig: $($_.Exception.Message)" } } $maxBytes = 2 * 1024 * 1024 # 2 MB if ($bytes.Length -gt $maxBytes) { return @{ __status = 413; error = "Logo zu gross ($([int]($bytes.Length / 1024)) KB, max. 2 MB)" } } if ($bytes.Length -lt 4) { return @{ __status = 400; error = "Datei zu klein / leer" } } $fileName = "$($script:BrandingLogoPrefix).$ext" $dir = Get-BrandingDir if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } # Alte Logo-Varianten weg, dann neue Datei schreiben Remove-BrandingLogoFiles $target = Join-Path $dir $fileName [IO.File]::WriteAllBytes($target, $bytes) if (-not $script:Settings.branding) { $script:Settings | Add-Member -NotePropertyName 'branding' -NotePropertyValue ([pscustomobject]@{}) -Force } $script:Settings.branding.logoFile = $fileName try { Write-Settings -Settings $script:Settings } catch { return @{ __status = 500; error = "Settings-Speichern fehlgeschlagen: $($_.Exception.Message)" } } Write-Host "[LOGO] Gespeichert: $target ($([int]($bytes.Length / 1024)) KB)" -ForegroundColor Green $mtime = [DateTimeOffset]::new((Get-Item $target).LastWriteTimeUtc).ToUnixTimeSeconds() return @{ ok = $true logoFile = $fileName sizeKb = [int]($bytes.Length / 1024) # Cache-Bust-Tag = File-Mtime, identisch zu dem was Get-Settings liefert. cacheTag = $mtime } } function Remove-LogoEndpoint { Remove-BrandingLogoFiles if ($script:Settings.branding) { $script:Settings.branding.logoFile = $null } try { Write-Settings -Settings $script:Settings } catch { return @{ __status = 500; error = "Settings-Speichern fehlgeschlagen: $($_.Exception.Message)" } } Write-Host "[LOGO] Entfernt" -ForegroundColor DarkGray return @{ ok = $true } } function Invoke-ConnectWithTokenEndpoint { param($Body) try { Initialize-GraphModule } catch { return @{ __status = 500; error = "Microsoft.Graph.Authentication fehlt: $($_.Exception.Message)" } } $token = $Body.accessToken if ([string]::IsNullOrWhiteSpace($token)) { return @{ __status = 400; error = "accessToken fehlt im Body" } } Write-Host "[CONNECT] Token-Login fuer Account: $($Body.account)" -ForegroundColor Cyan # Eventuell aktive Session beenden try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} try { # Microsoft.Graph.Authentication v2+ erwartet SecureString $secure = ConvertTo-SecureString $token -AsPlainText -Force Connect-MgGraph -AccessToken $secure -NoWelcome -ErrorAction Stop | Out-Null } catch { # Fallback fuer aeltere Modul-Versionen die Plain-String akzeptieren try { Connect-MgGraph -AccessToken $token -NoWelcome -ErrorAction Stop | Out-Null } catch { $msg = $_.Exception.Message Write-Host "[CONNECT] Token-Login fehlgeschlagen: $msg" -ForegroundColor Red return @{ __status = 500; error = "Connect-MgGraph mit Token fehlgeschlagen: $msg" } } } $ctx = $null try { $ctx = Get-MgContext } catch {} if (-not $ctx -or -not $ctx.Account) { return @{ __status = 500; error = "Kein Kontext nach Token-Login (ungueltiger oder abgelaufener Token?)" } } $script:State.Connected = $true $script:State.Account = $ctx.Account $script:State.TenantId = $ctx.TenantId Write-Host "[CONNECT] OK via Token - Account: $($ctx.Account), Tenant: $($ctx.TenantId)" -ForegroundColor Green return Get-StatusEndpoint } function Get-StatusEndpoint { $cs = $script:ConnectState $connect = $null if ($cs -and ($cs.Active -or $cs.Error) -and -not $script:State.Connected) { $connect = @{ active = [bool]$cs.Active done = [bool]$cs.Done error = $cs.Error } } return @{ connected = $script:State.Connected account = $script:State.Account tenantId = $script:State.TenantId groupsLoaded = $script:State.Groups.Count rpaLoaded = $script:State.RpaGroups.Count appsLoaded = $script:State.Apps.Count sessionCount = $script:State.Session.Count connect = $connect } } # ============================================================ # Device-Code-Flow: Login direkt in der Website, ohne WAM, mit # voller Account-Kontrolle. Laeuft in Background-Runspace, damit # der HTTP-Listener waehrend des Logins nicht blockiert. # ============================================================ function Get-DeviceCodeState { if (-not $script:DeviceCodeState) { $script:DeviceCodeState = [hashtable]::Synchronized(@{ Active = $false Code = $null Url = $null UrlComplete = $null DeviceCode = $null ExpiresAt = $null Done = $false Error = $null Runspace = $null PowerShell = $null }) } return $script:DeviceCodeState } function Start-DeviceCodeConnect { try { Initialize-GraphModule } catch { return @{ __status = 500; error = "Microsoft.Graph.Authentication fehlt: $($_.Exception.Message)" } } $dc = Get-DeviceCodeState # Idempotenz: laufender Code wird wieder zurueckgegeben if ($dc.Active -and -not $dc.Done -and $dc.Code) { return @{ code = $dc.Code url = $dc.Url urlComplete = $dc.UrlComplete existing = $true } } # Vorherige Session aufraeumen Stop-DeviceCodeConnect | Out-Null try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} $script:State.Connected = $false $script:State.Account = $null $tenantId = $script:Config.TenantId $clientId = $script:Config.ClientId $scopeStr = (($script:Config.Scopes | ForEach-Object { "https://graph.microsoft.com/$_" }) + 'offline_access') -join ' ' # 1) Device-Code direkt von Microsoft holen (synchron, schnell) Write-Host "[CONNECT] Hole Device-Code von Microsoft..." -ForegroundColor DarkGray try { $body = @{ client_id = $clientId; scope = $scopeStr } $resp = Invoke-RestMethod ` -Method POST ` -Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/devicecode" ` -Body $body ` -ContentType 'application/x-www-form-urlencoded' ` -ErrorAction Stop } catch { $msg = $_.Exception.Message Write-Host "[CONNECT] Device-Code-Anforderung fehlgeschlagen: $msg" -ForegroundColor Red return @{ __status = 500; error = "Device-Code anfordern fehlgeschlagen: $msg" } } $dc.Active = $true $dc.Code = $resp.user_code $dc.Url = $resp.verification_uri $dc.UrlComplete = if ($resp.verification_uri_complete) { $resp.verification_uri_complete } else { "$($resp.verification_uri)?otc=$($resp.user_code)" } $dc.DeviceCode = $resp.device_code $dc.ExpiresAt = (Get-Date).AddSeconds([int]$resp.expires_in) $dc.Done = $false $dc.Error = $null Write-Host "[CONNECT] Code: $($resp.user_code) - Url: $($dc.UrlComplete)" -ForegroundColor Cyan # 2) Hintergrund-Runspace pollt das Token-Endpoint $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault2() $iss.ImportPSModule("Microsoft.Graph.Authentication") $rs = [runspacefactory]::CreateRunspace($iss) $rs.Open() $rs.SessionStateProxy.SetVariable("DeviceCode", $dc) $rs.SessionStateProxy.SetVariable("MainState", $script:State) $rs.SessionStateProxy.SetVariable("PollTenantId", $tenantId) $rs.SessionStateProxy.SetVariable("PollClientId", $clientId) $rs.SessionStateProxy.SetVariable("PollInterval", [int]$resp.interval) $ps = [powershell]::Create() $ps.Runspace = $rs $null = $ps.AddScript({ $tokenUri = "https://login.microsoftonline.com/$PollTenantId/oauth2/v2.0/token" $body = @{ grant_type = 'urn:ietf:params:oauth:grant-type:device_code' client_id = $PollClientId device_code = $DeviceCode.DeviceCode } while (-not $DeviceCode.Done -and (Get-Date) -lt $DeviceCode.ExpiresAt) { Start-Sleep -Seconds $PollInterval if ($DeviceCode.Done) { return } # vom User abgebrochen try { $tok = Invoke-RestMethod ` -Method POST ` -Uri $tokenUri ` -Body $body ` -ContentType 'application/x-www-form-urlencoded' ` -ErrorAction Stop if ($tok.access_token) { # Token bekommen, an Connect-MgGraph weiterreichen try { $secure = ConvertTo-SecureString $tok.access_token -AsPlainText -Force Connect-MgGraph -AccessToken $secure -NoWelcome -ErrorAction Stop | Out-Null } catch { # Fallback fuer aeltere Modul-Versionen Connect-MgGraph -AccessToken $tok.access_token -NoWelcome -ErrorAction Stop | Out-Null } $ctx = Get-MgContext if ($ctx -and $ctx.Account) { $MainState.Connected = $true $MainState.Account = $ctx.Account $MainState.TenantId = $ctx.TenantId } else { $DeviceCode.Error = "Token erhalten aber Get-MgContext leer" } $DeviceCode.Done = $true $DeviceCode.Active = $false return } } catch { # Fehler-Body parsen (authorization_pending ist erwartet) $errStr = "$($_.ErrorDetails.Message)" if (-not $errStr) { $errStr = $_.Exception.Message } if ($errStr -match 'authorization_pending') { continue # User hat noch nicht abgeschlossen, weiterpollen } elseif ($errStr -match 'slow_down') { Start-Sleep -Seconds 5 continue } elseif ($errStr -match 'authorization_declined') { $DeviceCode.Error = 'Anmeldung wurde abgelehnt' $DeviceCode.Done = $true $DeviceCode.Active = $false return } elseif ($errStr -match 'expired_token') { $DeviceCode.Error = 'Code ist abgelaufen - bitte neu starten' $DeviceCode.Done = $true $DeviceCode.Active = $false return } else { # Versuche JSON-Body zu extrahieren try { $j = $errStr | ConvertFrom-Json $DeviceCode.Error = "$($j.error): $($j.error_description)" } catch { $DeviceCode.Error = $errStr } $DeviceCode.Done = $true $DeviceCode.Active = $false return } } } if (-not $MainState.Connected -and -not $DeviceCode.Error) { $DeviceCode.Error = "Anmeldung abgelaufen (Code nicht eingegeben)" } $DeviceCode.Done = $true $DeviceCode.Active = $false }) $dc.PowerShell = $ps $dc.Runspace = $rs $null = $ps.BeginInvoke() return @{ code = $dc.Code url = $dc.Url urlComplete = $dc.UrlComplete expiresIn = [int]$resp.expires_in existing = $false } } function Stop-DeviceCodeConnect { $dc = Get-DeviceCodeState if ($dc.PowerShell) { try { $dc.PowerShell.Stop() } catch {} try { $dc.PowerShell.Dispose() } catch {} } if ($dc.Runspace) { try { $dc.Runspace.Close() } catch {} try { $dc.Runspace.Dispose() } catch {} } $dc.PowerShell = $null $dc.Runspace = $null $dc.Active = $false $dc.Code = $null $dc.Url = $null $dc.Done = $true $dc.Error = $null return @{ ok = $true } } # ============================================================ # Status-Endpoint kennt jetzt auch den Device-Code-Flow # ============================================================ function Initialize-WinFocus { if ('WinFocusHelper' -as [type]) { return } Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; using System.Text; public class WinFocusHelper { [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] public static extern bool AllowSetForegroundWindow(int dwProcessId); [DllImport("user32.dll")] public static extern IntPtr FindWindow(string lpClassName, string lpWindowName); [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc lpEnumFunc, IntPtr lParam); [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam); public static IntPtr FindAuthWindow() { IntPtr found = IntPtr.Zero; string[] needles = new string[] { "Anmelden", "Sign in", "Konto auswählen", "Pick an account", "Authentifizierung", "Microsoft Account", "Microsoft Authentication" }; EnumWindows((hWnd, lParam) => { if (!IsWindowVisible(hWnd)) return true; StringBuilder sb = new StringBuilder(256); GetWindowText(hWnd, sb, sb.Capacity); string title = sb.ToString(); if (string.IsNullOrEmpty(title)) return true; foreach (var n in needles) { if (title.IndexOf(n, StringComparison.OrdinalIgnoreCase) >= 0) { found = hWnd; return false; // stop enumerating } } return true; }, IntPtr.Zero); return found; } public static void BringToFront(IntPtr hWnd) { if (hWnd == IntPtr.Zero) return; ShowWindow(hWnd, 9); // SW_RESTORE BringWindowToTop(hWnd); SetForegroundWindow(hWnd); } } '@ } # Status fuer den asynchronen interaktiven Login function Get-ConnectState { if (-not $script:ConnectState) { $script:ConnectState = [hashtable]::Synchronized(@{ Active = $false Done = $false Error = $null StartedAt = $null Runspace = $null PowerShell = $null }) } return $script:ConnectState } function Stop-ConnectFlow { $cs = Get-ConnectState if ($cs.PowerShell) { try { $cs.PowerShell.Stop() } catch {} try { $cs.PowerShell.Dispose() } catch {} } if ($cs.Runspace) { try { $cs.Runspace.Close() } catch {} try { $cs.Runspace.Dispose() } catch {} } $cs.PowerShell = $null $cs.Runspace = $null $cs.Active = $false } function Invoke-ConnectEndpoint { try { Initialize-GraphModule | Out-Null Write-Host "[CONNECT] Verbinde mit Microsoft Graph..." -ForegroundColor Cyan # Parallel-Runspace, der das WAM-Account-Picker-Fenster sucht und # nach vorne bringt — sonst landet es hinter anderen Fenstern und # der User sieht nichts, was er bestaetigen koennte. $bringToFront = $null try { Initialize-WinFocus $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault2() $rs = [runspacefactory]::CreateRunspace($iss) $rs.Open() $bringToFront = [powershell]::Create() $bringToFront.Runspace = $rs $null = $bringToFront.AddScript({ Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; using System.Text; public class WinFocusHelper2 { [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc lpEnumFunc, IntPtr lParam); [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam); } '@ -ErrorAction SilentlyContinue $deadline = (Get-Date).AddSeconds(30) while ((Get-Date) -lt $deadline) { $found = [IntPtr]::Zero [WinFocusHelper2]::EnumWindows({ param($hWnd, $lParam) if (-not [WinFocusHelper2]::IsWindowVisible($hWnd)) { return $true } $sb = New-Object System.Text.StringBuilder 256 [void][WinFocusHelper2]::GetWindowText($hWnd, $sb, $sb.Capacity) $t = $sb.ToString() if ([string]::IsNullOrEmpty($t)) { return $true } foreach ($n in @('Anmelden','Sign in','Konto','Pick an account','Authentifizierung','Microsoft Account','Microsoft Authentication')) { if ($t.IndexOf($n, [StringComparison]::OrdinalIgnoreCase) -ge 0) { $script:found = $hWnd return $false } } return $true }, [IntPtr]::Zero) | Out-Null if ($script:found -ne [IntPtr]::Zero) { [WinFocusHelper2]::ShowWindow($script:found, 9) | Out-Null # SW_RESTORE [WinFocusHelper2]::BringWindowToTop($script:found) | Out-Null [WinFocusHelper2]::SetForegroundWindow($script:found) | Out-Null Start-Sleep -Milliseconds 800 } Start-Sleep -Milliseconds 400 } }) $null = $bringToFront.BeginInvoke() } catch { Write-Host "[CONNECT] WAM-Focus-Helper konnte nicht gestartet werden: $($_.Exception.Message)" -ForegroundColor DarkYellow } Connect-MgGraph ` -TenantId $script:Config.TenantId ` -ClientId $script:Config.ClientId ` -Scopes $script:Config.Scopes ` -NoWelcome # Helper-Runspace aufraeumen if ($bringToFront) { try { $bringToFront.Stop() } catch {} try { $bringToFront.Dispose() } catch {} } $context = Get-MgContext if ($context) { $script:State.Connected = $true $script:State.Account = $context.Account $script:State.TenantId = $context.TenantId Write-Host "[CONNECT] Verbunden als: $($context.Account)" -ForegroundColor Green return Get-StatusEndpoint } Write-Host "[CONNECT] Kein Context nach Connect-MgGraph" -ForegroundColor Red return @{ __status = 401; error = "Anmeldung fehlgeschlagen — kein Context" } } catch { Write-Host "[CONNECT] Fehler: $_" -ForegroundColor Red return @{ __status = 500; error = "Fehler beim Verbinden: $($_.Exception.Message)" } } } function Invoke-DisconnectEndpoint { try { Disconnect-MgGraph | Out-Null } catch {} $script:State.Connected = $false $script:State.Account = $null $script:State.TenantId = $null $script:State.Groups = @() $script:State.RpaGroups = @() $script:State.Apps = @() $script:State.Session = @() $script:State.GroupMembers = @{} return Get-StatusEndpoint } function Test-Connected { if (-not $script:State.Connected) { return @{ __status = 401; error = "Nicht mit Microsoft Graph verbunden" } } return $null } function Get-AppCategoryNames { # Extrahiert die Kategorie-Namen aus dem rohen Graph-Categories-Feld. # Robust gegen alle Source-Types die MgGraph/Invoke-MgGraphRequest in # PS 5.1 + PS 7 liefern kann (PSCustomObject, Hashtable, generische # Dictionary, Array von Strings, Skalar). Loggt im Server-Console wenn # Items leer durchrutschen — dann sehen wir live was schief geht. param($RawCategories, [string]$AppId) # Wichtig: IMMER ein Array zurueckgeben (auch leer), nicht $null. # @() wrap am Aufruf-Site reicht in PS5.1 oft nicht — explizite Liste. $names = [System.Collections.Generic.List[string]]::new() if ($null -eq $RawCategories) { return ,$names.ToArray() } $items = @($RawCategories) if ($items.Count -eq 0) { return ,$names.ToArray() } $typeNames = ($items | ForEach-Object { if ($null -eq $_) { 'null' } else { $_.GetType().Name } }) -join ', ' Write-Host " [CATS] ${AppId}: $($items.Count) Roh-Items, Types: $typeNames" -ForegroundColor DarkGray foreach ($cat in $items) { if ($null -eq $cat) { continue } if ($cat -is [string]) { if ($cat) { $names.Add($cat) } continue } $n = $null # Direkt-Dot-Access (PSCustomObject + PS-7-Hashtable + Dictionary) try { if ($cat.displayName) { $n = [string]$cat.displayName } } catch {} if (-not $n) { try { if ($cat.DisplayName) { $n = [string]$cat.DisplayName } } catch {} } # Hashtable-Indexer (PS 5.1 Hashtable) if (-not $n) { try { if ($cat -is [System.Collections.IDictionary]) { foreach ($k in 'displayName','DisplayName','name','Name') { if ($cat.Contains($k) -and $cat[$k]) { $n = [string]$cat[$k]; break } } } } catch {} } # Letzter Versuch: JSON-Roundtrip if (-not $n) { try { $obj = $cat | ConvertTo-Json -Depth 3 -Compress | ConvertFrom-Json foreach ($k in 'displayName','DisplayName','name','Name') { try { if ($obj.$k) { $n = [string]$obj.$k; break } } catch {} } } catch {} } if ($n) { $names.Add($n) } else { $dump = $null try { $dump = $cat | ConvertTo-Json -Depth 2 -Compress } catch { $dump = $cat.GetType().FullName } Write-Host " [CATS] ${AppId}: konnte Name nicht extrahieren aus: $dump" -ForegroundColor Yellow } } # Komma vor $names.ToArray() forciert dass PowerShell IMMER ein Array # zurueckgibt — auch bei genau 1 Element (sonst Skalar = JSON-Fehler). return ,$names.ToArray() } function Find-VendorBySource { # Liefert den Vendor-Eintrag aus Settings, dessen displayName mit dem # Source-String einer App uebereinstimmt. $null wenn nichts passt # (z.B. Source = "Intune"). param([string]$Source) if (-not $Source -or $Source -eq 'Intune') { return $null } if (-not $script:Settings.vendors) { return $null } return @($script:Settings.vendors | Where-Object { $_.displayName -eq $Source } | Select-Object -First 1)[0] } # ============================================================ # Gruppen # ============================================================ function Get-GroupsEndpoint { param($Query) $err = Test-Connected if ($err) { return $err } # Query-Override hat Vorrang (Debug-Pfad); sonst alle konfigurierten Praefixe. if ($Query.prefix) { $prefixes = @([string]$Query.prefix) } else { $prefixes = @(Get-DepartmentPrefixes -Settings $script:Settings) } if ($prefixes.Count -eq 0) { return @{ __status = 412 error = "Abteilungs-Praefix(e) nicht konfiguriert. Bitte unter Einstellungen -> Abteilungs-Gruppen setzen." code = "SettingsRequired" setting = "departments.prefixes" } } # OR-verketteter Graph-$filter: alle Praefixe in einem Listen-Request laden. # Graph erlaubt mehrfache startswith-Klauseln per 'or'. $parts = @($prefixes | ForEach-Object { "startswith(displayName,'$($_)')" }) $filter = $parts -join " or " $raw = Get-GraphGroupByFilter -Filter $filter -Property @("id","displayName") -ExpandMembers $items = @() foreach ($g in $raw) { $id = if ($g.id) { $g.id } else { $g.Id } $name = if ($g.displayName) { $g.displayName } else { $g.displayname } $members = if ($null -ne $g.members) { $g.members } else { $g.Members } $hasMembers = ($members -is [array] -and $members.Count -gt 0) -or ($null -ne $members -and -not ($members -is [array])) if ($id) { $items += [pscustomobject]@{ Id = [string]$id DisplayName = [string]$name HasMembers = [bool]$hasMembers } } } # Alphabetisch sortieren $items = @($items | Sort-Object -Property DisplayName -Culture de-DE) $script:State.Groups = $items return @{ items = $items; count = $items.Count } } function Get-RpaGroupsEndpoint { $err = Test-Connected if ($err) { return $err } $rpaNames = @($script:Settings.rpa.groupNames | Where-Object { $_ }) if ($rpaNames.Count -eq 0) { # Settings leer -> ehrlich melden, das Frontend zeigt einen Konfig-Hinweis. return @{ items = @(); count = 0; notConfigured = $true } } $items = @() foreach ($n in $rpaNames) { try { # WICHTIG: @() wrappen — sonst entwickelt PowerShell ein Single-Item- # Resultat zu einem Skalar und $g[0] indexiert in Properties statt Array. $g = @(Get-GraphGroupByFilter -Filter "displayName eq '$n'" -Property @("id","displayName") -ExpandMembers) if ($g.Count -gt 0) { $first = $g[0] # Defensiv beide Casings abfragen, da $select je nach Modul-Version unterschiedlich casing zurueckgibt $name = if ($first.displayName) { $first.displayName } elseif ($first.displayname) { $first.displayname } else { $n } $id = if ($first.id) { $first.id } elseif ($first.Id) { $first.Id } else { $null } $members = if ($null -ne $first.members) { $first.members } else { $first.Members } $hasMembers = ($members -is [array] -and $members.Count -gt 0) -or ($null -ne $members -and -not ($members -is [array])) if ($id) { $items += [pscustomobject]@{ Id = [string]$id; DisplayName = [string]$name; HasMembers = [bool]$hasMembers } Write-Host " RPA: $name ($id) members=$hasMembers" -ForegroundColor DarkGray } } else { Write-Host " RPA-Gruppe nicht gefunden: $n" -ForegroundColor Yellow } } catch { Write-Host " RPA-Gruppe-Lookup-Fehler ($n): $($_.Exception.Message)" -ForegroundColor Yellow } } $script:State.RpaGroups = $items return @{ items = $items; count = $items.Count } } function Test-GroupNameEndpoint { param($Body) $err = Test-Connected if ($err) { return $err } $name = $Body.displayName $existing = Get-GraphGroupByFilter -Filter "displayName eq '$name'" -Property @("id","displayName") return @{ exists = ($existing -and @($existing).Count -gt 0) displayName = $name } } function New-GroupEndpoint { param($Body) $err = Test-Connected if ($err) { return $err } $appName = $Body.appName $customName = $Body.customName # optional - falls null wird automatischer Name verwendet # Intent steuert Naming + Zuweisung. Default "required" fuer Backwards-Compat. $intent = if ($Body.intent) { ([string]$Body.intent).ToLower() } else { "required" } if ($intent -notin @("required","available")) { return @{ __status = 400; error = "Intent muss 'required' oder 'available' sein" } } $namingObj = if ($intent -eq "available") { $script:Settings.availableGroupNaming } else { $script:Settings.requiredGroupNaming } $defaultSuffix = if ($intent -eq "available") { "-available" } else { "-required" } $namingPrefix = if ($namingObj -and $namingObj.prefix) { $namingObj.prefix } else { "intune-win-app-" } $namingSuffix = if ($namingObj -and $namingObj.suffix) { $namingObj.suffix } else { $defaultSuffix } $cleaned = if ($customName) { Format-GroupNameSlug -Name $customName } else { Format-GroupNameSlug -Name $appName } $displayName = "$namingPrefix$cleaned$namingSuffix".ToLower() $mailNickname = $displayName # check duplikat $existing = Get-GraphGroupByFilter -Filter "displayName eq '$displayName'" -Property @("id","displayName") if ($existing -and @($existing).Count -gt 0) { return @{ __status = 409; error = "Gruppe existiert bereits"; displayName = $displayName } } $group = New-GraphSecurityGroup -DisplayName $displayName -MailNickname $mailNickname $assigned = $false if ($Body.assignToApp -eq $true -and $Body.appId) { try { Add-GraphAppAssignment -AppId $Body.appId -Intent $intent -GroupId $group.id $assigned = $true } catch { Write-Host "Auto-Assign fehlgeschlagen: $_" -ForegroundColor Yellow } } return @{ id = $group.id displayName = $group.displayName intent = $intent assigned = $assigned } } function Format-GroupNameSlug { param([string]$Name) $clean = $Name -replace '[^a-zA-Z0-9-]', '-' $clean = $clean -replace '-+', '-' $clean = $clean.Trim('-') return $clean.ToLower() } function Get-GroupMembersEndpoint { param([string]$GroupId) $err = Test-Connected if ($err) { return $err } $members = Get-GraphGroupMembersTransitive -GroupId $GroupId $items = @() foreach ($m in $members) { $items += [pscustomobject]@{ Id = $m.id DisplayName = $m.displayName UserPrincipalName = $m.userPrincipalName } } return @{ items = $items; count = $items.Count } } function Add-GroupMembersEndpoint { param([string]$GroupId, $Body) $err = Test-Connected if ($err) { return $err } if ([string]::IsNullOrWhiteSpace($GroupId)) { return @{ __status = 400; error = "GroupId fehlt" } } if (-not $Body) { return @{ __status = 400; error = "Request-Body fehlt" } } $ids = @() if ($Body.userIds) { $ids = @($Body.userIds | ForEach-Object { [string]$_ } | Where-Object { $_ }) } elseif ($Body.userId) { $ids = @([string]$Body.userId) } if ($ids.Count -eq 0) { return @{ __status = 400; error = "userId oder userIds fehlt im Body" } } $success = 0; $errors = 0 $results = @() foreach ($uid in $ids) { try { Add-GraphMember -GroupId $GroupId -DirectoryObjectId $uid $success++ $results += @{ userId = $uid; status = "Success" } Write-Host " [ADD MEMBER] GroupId=$GroupId UserId=$uid OK" -ForegroundColor Green } catch { $details = Get-GraphErrorFriendly -ErrorRecord $_ if ($details.IsWarning) { $success++ $results += @{ userId = $uid; status = "AlreadyMember"; message = $details.Friendly } Write-Host " [ADD MEMBER] $uid bereits Mitglied in $GroupId" -ForegroundColor DarkGreen } else { $errors++ $results += @{ userId = $uid; status = "Error"; code = $details.Code; message = $details.Friendly } Write-Host " [ADD MEMBER] FAIL $uid -> $GroupId [$($details.Code)] $($details.Friendly)" -ForegroundColor Red } } } if ($script:State.GroupMembers.ContainsKey($GroupId)) { $script:State.GroupMembers.Remove($GroupId) } return @{ ok = ($errors -eq 0) success = [int]$success errors = [int]$errors total = [int]$ids.Count results = $results } } function Remove-GroupMemberEndpoint { param([string]$GroupId, [string]$UserId) $err = Test-Connected if ($err) { return $err } if ([string]::IsNullOrWhiteSpace($GroupId) -or [string]::IsNullOrWhiteSpace($UserId)) { return @{ __status = 400; error = "GroupId und UserId erforderlich" } } try { $null = Invoke-MgGraphRequest ` -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/$UserId/`$ref" ` -Method DELETE Write-Host " [REMOVE MEMBER] GroupId=$GroupId UserId=$UserId OK" -ForegroundColor Green } catch { $details = Get-GraphErrorFriendly -ErrorRecord $_ $status = if ($details.Code -like '*404*') { 404 } elseif ($details.Code -like '*403*') { 403 } else { 500 } Write-Host " [REMOVE MEMBER] FAIL [$($details.Code)] $($details.Friendly)" -ForegroundColor Red return @{ __status = $status; error = $details.Friendly; code = $details.Code } } if ($script:State.GroupMembers.ContainsKey($GroupId)) { $script:State.GroupMembers.Remove($GroupId) } return @{ ok = $true; groupId = $GroupId; userId = $UserId } } function Search-GroupsEndpoint { param($Query) $err = Test-Connected if ($err) { return $err } $term = [string]$Query.q if ([string]::IsNullOrWhiteSpace($term) -or $term.Length -lt 2) { return @{ items = @(); count = 0 } } $sw = [System.Diagnostics.Stopwatch]::StartNew() $raw = @(Search-GraphGroups -SearchTerm $term -Top 50) $sw.Stop() Write-Host " [GSEARCH] '$term': $($raw.Count) Treffer in $($sw.ElapsedMilliseconds)ms" -ForegroundColor DarkGray $items = @() foreach ($g in $raw) { $id = if ($g.id) { $g.id } else { $g.Id } $name = if ($g.displayName) { $g.displayName } else { $g.displayname } if ($id) { $items += [pscustomobject]@{ Id = [string]$id DisplayName = [string]$name Description = [string]$g.description } } } return @{ items = $items; count = $items.Count } } function Get-GroupMembersExportEndpoint { # Loest alle Benutzer einer Gruppe auf, einschliesslich Mitglieder aus # verschachtelten Unter-Gruppen. Gibt die flache, deduplizierte Benutzer- # liste mit SourcePath-Angabe zurueck — das Frontend erzeugt daraus den CSV. 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 "[EXPORT] Starte Aufloesung: '$groupName' ($GroupId)" -ForegroundColor Cyan $sw = [System.Diagnostics.Stopwatch]::StartNew() $allUsers = @(Resolve-GroupMembersWithNesting -GroupId $GroupId -GroupName $groupName) # Deduplizieren: erster Treffer (= direktes Mitglied) gewinnt $seen = @{} $unique = [System.Collections.Generic.List[object]]::new() $dups = 0 foreach ($u in $allUsers) { if (-not $seen.ContainsKey($u.Id)) { $seen[$u.Id] = $true $unique.Add($u) } else { $dups++ } } $sw.Stop() Write-Host " -> $($unique.Count) eindeutige Benutzer, $dups Duplikate, $($sw.ElapsedMilliseconds)ms" -ForegroundColor Green return @{ groupId = $GroupId groupName = $groupName users = $unique.ToArray() count = $unique.Count duplicates = $dups resolvedMs = [int]$sw.ElapsedMilliseconds } } # ============================================================ # Users # ============================================================ function Search-UsersEndpoint { param($Query) $err = Test-Connected if ($err) { return $err } $term = $Query.q if ([string]::IsNullOrWhiteSpace($term) -or $term.Length -lt 2) { return @{ items = @(); count = 0 } } $raw = Search-GraphUser -SearchTerm $term $items = @() foreach ($u in $raw) { $items += [pscustomobject]@{ Id = $u.id DisplayName = $u.displayName UserPrincipalName = $u.userPrincipalName Mail = $u.mail Department = $u.department } } return @{ items = $items; count = $items.Count } } # ============================================================ # Apps # ============================================================ function Get-AppsEndpoint { param($Query) $err = Test-Connected if ($err) { return $err } if (-not $Query) { $Query = @{} } $forceRefresh = ($Query.refresh -eq "true") if (-not $forceRefresh -and $script:State.Apps.Count -gt 0) { return @{ items = $script:State.Apps; count = $script:State.Apps.Count; cached = $true } } $sw = [System.Diagnostics.Stopwatch]::StartNew() Write-Host "Lade Apps aus Intune..." -ForegroundColor Cyan $raw = Get-GraphMobileApps -WithAssignments Write-Host " -> $($raw.Count) Apps gesamt vor Filter ($([int]$sw.Elapsed.TotalSeconds)s)" -ForegroundColor DarkGray # Eindeutige Gruppen-IDs aus allen Zuweisungen sammeln $groupIds = @{} foreach ($app in $raw) { foreach ($a in @($app.assignments)) { $tgt = $a.target if ($tgt.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget' -and $tgt.groupId) { $groupIds[$tgt.groupId] = $true } } } Write-Host " -> $($groupIds.Count) eindeutige Gruppen referenziert" -ForegroundColor DarkGray # Lookup mit bereits bekannten Namen vorbefuellen $lookup = @{} foreach ($g in $script:State.Groups) { $lookup[$g.Id] = $g.DisplayName } foreach ($g in $script:State.RpaGroups) { $lookup[$g.Id] = $g.DisplayName } # Unbekannte IDs in Batches per directoryObjects/getByIds aufloesen (1 Request fuer 1000 IDs statt 1000 Requests) $unknown = [string[]]@($groupIds.Keys | ForEach-Object { [string]$_ } | Where-Object { $_ -and -not $lookup.ContainsKey($_) }) if ($unknown.Count -gt 0) { Write-Host " -> Loese $($unknown.Count) Gruppen-Namen via getByIds auf..." -ForegroundColor DarkGray $sw2 = [System.Diagnostics.Stopwatch]::StartNew() Resolve-GroupNamesBulk -Ids $unknown -Lookup $lookup $sw2.Stop() Write-Host " -> Aufloesung in $([int]$sw2.Elapsed.TotalSeconds)s erledigt" -ForegroundColor DarkGray } $items = @() foreach ($app in $raw) { if (-not (Test-AppTypeAllowed -Type $app.'@odata.type')) { continue } $items += Format-AppForFrontend -RawApp $app -AllGroupsLookup $lookup } $sw.Stop() Write-Host " -> $($items.Count) Apps nach Filter ($([int]$sw.Elapsed.TotalSeconds)s gesamt)" -ForegroundColor Green $script:State.Apps = $items return @{ items = $items; count = $items.Count; cached = $false } } function Get-AppCategoriesEndpoint { # Liefert eine Map appId -> [KategorieNamen] fuer alle gecachten Apps. # Wird vom Frontend NACH dem App-Laden im Hintergrund geholt (blockiert # das App-Laden nicht). Throttle-sicher per $batch. $err = Test-Connected if ($err) { return $err } $ids = @($script:State.Apps | ForEach-Object { [string]$_.AppId } | Where-Object { $_ }) if ($ids.Count -eq 0) { return @{ map = @{}; count = 0 } } $sw = [System.Diagnostics.Stopwatch]::StartNew() Write-Host "[CATS] Lade Kategorien fuer $($ids.Count) Apps via batch..." -ForegroundColor DarkCyan $map = Get-GraphMobileAppCategoriesBatch -AppIds $ids $sw.Stop() # Cache mitfuehren, damit Filter auch ohne erneuten Call konsistent ist foreach ($a in $script:State.Apps) { if ($map.ContainsKey($a.AppId)) { $a.Categories = @($map[$a.AppId]) } } Write-Host " -> Kategorien fuer $($map.Keys.Count) Apps in $([int]$sw.Elapsed.TotalSeconds)s" -ForegroundColor DarkGray return @{ map = $map; count = $map.Keys.Count } } function Get-AppDetailsEndpoint { param([string]$AppId) $err = Test-Connected if ($err) { return $err } if ([string]::IsNullOrWhiteSpace($AppId)) { return @{ __status = 400; error = "AppId fehlt" } } # Drei Calls (App-Details + InstallSummary + Relationships) in EINEM # HTTP-Roundtrip via Graph $batch — server-seitig parallelisiert, # spart 60-70% Latenz. $batch = Get-GraphAppDetailsBatch -AppId $AppId $app = $batch.App if (-not $app) { return @{ __status = 404; error = "App nicht gefunden (Batch-Response ohne App-Body)" } } # Helper: Wert holen, leere Strings als $null normalisieren $val = { param($obj, $name) if ($null -eq $obj) { return $null } $v = $obj.$name if ($null -eq $v) { return $null } if ($v -is [string] -and [string]::IsNullOrWhiteSpace($v)) { return $null } return $v } $type = [string]$app.'@odata.type' $shortType = $type -replace '^#microsoft\.graph\.', '' # Min-OS in lesbarem Format zusammenfassen $minOs = $null $mos = $app.minimumSupportedOperatingSystem if ($mos) { $flags = @() foreach ($p in @('v8_0','v8_1','v10_0','v10_1607','v10_1703','v10_1709','v10_1803','v10_1809','v10_1903','v10_1909','v10_2004','v10_20H2','v10_21H1','v10_21H2','v10_22H2','v11_21H2','v11_22H2','v11_23H2')) { if ($mos.$p -eq $true) { $flags += ($p -replace '^v','Win ') } } if ($flags.Count -gt 0) { $minOs = ($flags -join ', ') } } # Architekturen $arch = $null if ($app.applicableArchitectures) { $arch = [string]$app.applicableArchitectures } # Detection-Rules in Kurzform $detection = @() if ($app.detectionRules) { foreach ($r in @($app.detectionRules)) { $rt = [string]$r.'@odata.type' -replace '^#microsoft\.graph\.win32LobApp', '' $summary = switch -Wildcard ($rt) { 'FileSystemDetection' { "Datei: $([string]$r.path)\$([string]$r.fileOrFolderName)" } 'RegistryDetection' { "Registry: $([string]$r.keyPath) ($([string]$r.valueName))" } 'MsiInformation' { "MSI: $([string]$r.productCode)" } 'ProductCodeDetection' { "MSI-ProductCode: $([string]$r.productCode)" } 'PowerShellScriptDetection' { "PowerShell-Skript" } default { $rt } } $detection += $summary } } # MSI-Details $msi = $null if ($app.msiInformation) { $msi = @{ ProductCode = [string]$app.msiInformation.productCode ProductVersion = [string]$app.msiInformation.productVersion Publisher = [string]$app.msiInformation.publisher PackageType = [string]$app.msiInformation.packageType UpgradeCode = [string]$app.msiInformation.upgradeCode RequiresReboot = [bool]$app.msiInformation.requiresReboot } } # Return-Codes $returnCodes = @() if ($app.returnCodes) { foreach ($rc in @($app.returnCodes)) { $returnCodes += @{ Code = [int]$rc.returnCode; Type = [string]$rc.type } } } return @{ AppId = [string]$app.id Type = $shortType DisplayName = & $val $app 'displayName' Publisher = & $val $app 'publisher' Developer = & $val $app 'developer' Owner = & $val $app 'owner' Description = & $val $app 'description' Notes = & $val $app 'notes' DisplayVersion = & $val $app 'displayVersion' Version = & $val $app 'version' ProductVersion = & $val $app 'productVersion' FileName = & $val $app 'fileName' SetupFilePath = & $val $app 'setupFilePath' InstallCommandLine = & $val $app 'installCommandLine' UninstallCommandLine = & $val $app 'uninstallCommandLine' CommandLine = & $val $app 'commandLine' InformationUrl = & $val $app 'informationUrl' PrivacyInformationUrl= & $val $app 'privacyInformationUrl' InstallExperience = if ($app.installExperience) { [string]$app.installExperience.runAsAccount } else { $null } Architectures = $arch MinimumOS = $minOs IsFeatured = [bool]$app.isFeatured # Dates explizit als ISO 8601 zurueckgeben — ConvertTo-Json wuerde # [DateTime]-Objekte je nach PS-Version unterschiedlich (und teils # JS-untauglich) serialisieren. CreatedDateTime = ConvertTo-IsoDate (& $val $app 'createdDateTime') LastModifiedDateTime = ConvertTo-IsoDate (& $val $app 'lastModifiedDateTime') Categories = (Get-AppCategoryNames -RawCategories $app.categories -AppId $AppId) DetectionRules = $detection ReturnCodes = $returnCodes Msi = $msi InstallSummary = Get-AppInstallSummaryNormalized -AppId $AppId -Raw $batch.InstallSummary # WICHTIG: @() Wrap am Call-Site — PowerShell entpackt sonst ein # Single-Element-Array zu einer einzelnen Hashtable, und ConvertTo-Json # serialisiert sie als Objekt statt als Array. Frontend sieht dann # d.Dependencies.length === undefined und ueberspringt das Rendern. # Items kommt aus dem Batch — kein zweiter /relationships-Request. Dependencies = @(Get-AppRelationshipsNormalized -AppId $AppId -OdataKind '#microsoft.graph.mobileAppDependency' -Items $batch.Relationships) Supersedence = @(Get-AppRelationshipsNormalized -AppId $AppId -OdataKind '#microsoft.graph.mobileAppSupersedence' -Items $batch.Relationships) } } # Normalisiert das installSummary-Objekt von Graph in flache, JS-freundliche # Properties. Bei API-Fehler / fehlenden Werten -> $null (Frontend zeigt die # Sektion dann nicht). function Get-AppInstallSummaryNormalized { param( [string]$AppId, # Optional: bereits geladenes Raw-Objekt (z.B. aus $batch) — spart den # zusaetzlichen HTTP-Roundtrip. $Raw = $null ) if ($null -eq $Raw) { $Raw = Get-GraphMobileAppInstallSummary -AppId $AppId } if (-not $Raw) { return $null } $raw = $Raw $int = { param($v) if ($null -eq $v) { 0 } else { [int]$v } } return @{ InstalledDeviceCount = & $int $raw.installedDeviceCount FailedDeviceCount = & $int $raw.failedDeviceCount NotInstalledDeviceCount = & $int $raw.notInstalledDeviceCount NotApplicableDeviceCount = & $int $raw.notApplicableDeviceCount PendingInstallDeviceCount = & $int $raw.pendingInstallDeviceCount InstalledUserCount = & $int $raw.installedUserCount FailedUserCount = & $int $raw.failedUserCount NotInstalledUserCount = & $int $raw.notInstalledUserCount NotApplicableUserCount = & $int $raw.notApplicableUserCount PendingInstallUserCount = & $int $raw.pendingInstallUserCount } } # Filtert die Relationships nach @odata.type (Dependency oder Supersedence) # und packt sie in eine flache, JS-freundliche Struktur. Bei fehlenden # Properties (Microsoft liefert nicht immer Display-Name fuer Targets!) # wird die App-ID als Fallback verwendet. function Get-AppRelationshipsNormalized { param( [string]$AppId, [string]$OdataKind, # Optional: vorhandene Items (z.B. aus $batch). Wenn gesetzt wird # KEIN zusaetzlicher /relationships-Request mehr gemacht. $Items = $null ) if ($null -eq $Items) { $items = @() try { $items = Get-GraphMobileAppRelationships -AppId $AppId } catch { return @() } } else { $items = $Items } if (-not $items) { return @() } # Normalisierung: Casing + fuehrendes # entfernen, um Mikro-Unterschiede # in der API-Response zuverlaessig zu matchen. $norm = { param($t) ([string]$t).TrimStart('#').ToLower() } $wanted = & $norm $OdataKind $isDependency = $wanted -like '*dependency*' $isSupersedence= $wanted -like '*supersedence*' $result = @() foreach ($r in $items) { $actual = & $norm $r.'@odata.type' if ($actual -ne $wanted) { continue } $entry = @{ Id = [string]$r.id TargetId = [string]$r.targetId TargetDisplayName = if ($r.targetDisplayName) { [string]$r.targetDisplayName } else { [string]$r.targetId } TargetPublisher = if ($r.targetPublisher) { [string]$r.targetPublisher } else { $null } TargetDisplayVersion = if ($r.targetDisplayVersion) { [string]$r.targetDisplayVersion } else { $null } TargetType = if ($r.targetType) { [string]$r.targetType } else { $null } } if ($isDependency) { $entry['DependencyType'] = if ($r.dependencyType) { [string]$r.dependencyType } else { 'detect' } } elseif ($isSupersedence) { $entry['SupersedenceType'] = if ($r.supersedenceType) { [string]$r.supersedenceType } else { 'update' } } $result += $entry } Write-Host " [RELS] $AppId -> $($result.Count) gefiltert auf '$wanted'" -ForegroundColor DarkGray return $result } # ============================================================ # Membership Check # ============================================================ function Get-MembershipBulkEndpoint { param($Body) $err = Test-Connected if ($err) { return $err } $targetIds = @($Body.targets | ForEach-Object { [string]$_ } | Where-Object { $_ }) $groupIds = @($Body.groupIds | ForEach-Object { [string]$_ } | Where-Object { $_ }) if ($targetIds.Count -eq 0 -or $groupIds.Count -eq 0) { return @{ memberships = @{} } } $sw = [System.Diagnostics.Stopwatch]::StartNew() Write-Host " Bulk-Membership: $($targetIds.Count) Targets x $($groupIds.Count) Gruppen" -ForegroundColor DarkGray # Pro Target: ALLE Gruppen-Mitgliedschaften EINMAL holen (transitiveMemberOf) # Statt pro Gruppe pro Target eine Anfrage. Bei 1 Target + 600 Gruppen # = 1 Call statt 600. $perTarget = @{} foreach ($tid in $targetIds) { $set = New-Object System.Collections.Generic.HashSet[string] try { $uri = "https://graph.microsoft.com/v1.0/directoryObjects/$tid/transitiveMemberOf?`$select=id&`$top=999" $next = $uri do { $resp = Invoke-MgGraphRequest -Uri $next -Method GET if ($resp.value) { foreach ($g in $resp.value) { if ($g.id) { [void]$set.Add([string]$g.id) } } } $next = $resp.'@odata.nextLink' } while ($next) } catch { Write-Host " -> transitiveMemberOf fehlgeschlagen fuer $tid : $($_.Exception.Message)" -ForegroundColor DarkYellow } $perTarget[$tid] = $set } # Lokal mappen — Group-IDs gegen alle Target-Sets pruefen $result = @{} foreach ($gid in $groupIds) { if ($gid -in @("ALL_USERS","ALL_DEVICES")) { $result[$gid] = @{ status = "Native"; matched = 0; total = $targetIds.Count } continue } $matched = 0 foreach ($tid in $targetIds) { if ($perTarget[$tid].Contains($gid)) { $matched++ } } $status = if ($matched -eq 0) { "None" } elseif ($matched -eq $targetIds.Count) { "Full" } else { "Partial" } $result[$gid] = @{ status = $status; matched = $matched; total = $targetIds.Count } } $sw.Stop() Write-Host " Bulk-Membership: $($result.Count) Resultate in $($sw.ElapsedMilliseconds)ms" -ForegroundColor DarkGray return @{ memberships = $result } } function Get-MembershipEndpoint { param($Query) $err = Test-Connected if ($err) { return $err } $targetIds = @($Query.targets -split ",") $groupId = $Query.groupId if ([string]::IsNullOrWhiteSpace($groupId) -or $targetIds.Count -eq 0) { return @{ status = "None"; details = @() } } if ($groupId -in @("ALL_USERS","ALL_DEVICES")) { return @{ status = "Native"; details = @() } } if (-not $script:State.GroupMembers.ContainsKey($groupId)) { try { $members = Get-GraphGroupMembersTransitive -GroupId $groupId $ids = New-Object System.Collections.Generic.HashSet[string] foreach ($m in $members) { [void]$ids.Add($m.id) } $script:State.GroupMembers[$groupId] = $ids } catch { return @{ status = "Unknown"; error = $_.Exception.Message } } } $set = $script:State.GroupMembers[$groupId] $matchCount = 0 $details = @() foreach ($tid in $targetIds) { $isMember = $set.Contains($tid) if ($isMember) { $matchCount++ } $details += @{ id = $tid; isMember = $isMember } } $status = if ($matchCount -eq 0) { "None" } elseif ($matchCount -eq $targetIds.Count) { "Full" } else { "Partial" } return @{ status = $status matched = $matchCount total = $targetIds.Count details = $details } } # ============================================================ # App-Loeschung & Assignment-Entfernung # ============================================================ function Remove-AppEndpoint { param([string]$AppId) $err = Test-Connected if ($err) { return $err } if ([string]::IsNullOrWhiteSpace($AppId)) { return @{ __status = 400; error = "AppId fehlt" } } # App-Namen + Source fuer Logging aus Cache versuchen (nice-to-have) $appName = $AppId $appSource = $null $cachedApp = $script:State.Apps | Where-Object { $_.AppId -eq $AppId } | Select-Object -First 1 if ($cachedApp) { $appName = $cachedApp.AppName $appSource = $cachedApp.Source } # Vendor-managed Apps (PMPC, Robopack, ...) koennen nur im jeweiligen # Vendor-Portal geloescht werden. Ein Delete in Intune liefe auf einen # verwirrenden 400er hinaus oder der Vendor wuerde die App beim naechsten # Sync wieder anlegen. Wir blocken den Versuch hier. $vendor = Find-VendorBySource -Source $appSource if ($vendor -and $vendor.block -and $vendor.block.delete) { Write-Host "[DELETE APP] BLOCK $appName ($($vendor.displayName)-managed)" -ForegroundColor DarkYellow return @{ __status = 409 error = "Diese App wird durch $($vendor.displayName) verwaltet und kann nur im $($vendor.displayName)-Portal geloescht werden — nicht in Intune." code = "$($vendor.id)Managed" source = $vendor.displayName } } Write-Host "[DELETE APP] $appName ($AppId)" -ForegroundColor Yellow try { Remove-GraphMobileApp -AppId $AppId } catch { $exMsg = $_.Exception.Message # Original-Graph-Body extrahieren (am informativsten) $graphBody = $null try { $graphBody = $_.ErrorDetails.Message } catch {} if (-not $graphBody -and $exMsg -match 'Graph-Response:\s*(.+)$') { $graphBody = $matches[1] } $graphMsg = $null if ($graphBody) { try { $j = $graphBody | ConvertFrom-Json if ($j.error -and $j.error.message) { $graphMsg = [string]$j.error.message } } catch {} } # Bei 400: Relationships pruefen — haeufige Ursache $rels = @() if ($exMsg -match '400|BadRequest') { try { $rels = Get-GraphMobileAppRelationships -AppId $AppId } catch {} } $details = Get-GraphErrorFriendly -ErrorRecord $_ $friendly = if ($graphMsg) { $graphMsg } else { $details.Friendly } $status = if ($details.Code -like '*403*') { 403 } elseif ($details.Code -like '*404*') { 404 } else { 500 } # Wenn Relationships gefunden: Hinweis daran haengen if ($rels.Count -gt 0) { $relTypes = @($rels | ForEach-Object { ([string]$_.'@odata.type' -replace '^#microsoft\.graph\.','') } | Select-Object -Unique) $friendly = "$friendly`n`nDie App hat $($rels.Count) Abhaengigkeit(en) ($($relTypes -join ', ')). Bitte zuerst diese Beziehungen im Intune-Portal entfernen (Eigenschaften > Abhaengigkeiten / Supersedence)." } Write-Host "[DELETE APP] FAIL [$($details.Code)] $friendly" -ForegroundColor Red if ($graphBody) { Write-Host " Graph-Body: $graphBody" -ForegroundColor DarkRed } return @{ __status = $status error = $friendly code = $details.Code details = $details.Full graph = $graphBody relationships = $rels.Count } } # Cache aktualisieren: geloeschte App rauswerfen if ($script:State.Apps -and $script:State.Apps.Count -gt 0) { $script:State.Apps = @($script:State.Apps | Where-Object { $_.AppId -ne $AppId }) } Write-Host "[DELETE APP] OK $appName" -ForegroundColor Green return @{ ok = $true; appId = $AppId; appName = $appName } } function Update-AppEndpoint { param( [string]$AppId, $Body ) $err = Test-Connected if ($err) { return $err } if ([string]::IsNullOrWhiteSpace($AppId)) { return @{ __status = 400; error = "AppId fehlt" } } if (-not $Body) { return @{ __status = 400; error = "Request-Body fehlt" } } # Aktuell wird nur displayName per UI editiert. Andere Felder waeren leicht # nachruestbar, brauchen aber jeweils eigene Validierung. $newName = $null if ($Body.displayName) { $newName = [string]$Body.displayName } if (-not $newName) { return @{ __status = 400; error = "displayName fehlt im Body" } } $newName = $newName.Trim() if ($newName.Length -lt 1) { return @{ __status = 400; error = "Name darf nicht leer sein" } } if ($newName.Length -gt 256) { return @{ __status = 400; error = "Name zu lang (max. 256 Zeichen)" } } # App im Cache nachschlagen — fuer Source-Check (PMPC blocken) und Typ (PATCH braucht @odata.type) $cachedApp = $script:State.Apps | Where-Object { $_.AppId -eq $AppId } | Select-Object -First 1 if (-not $cachedApp) { return @{ __status = 404; error = "App im Cache nicht gefunden. Bitte Apps neu laden und erneut versuchen." } } $vendor = Find-VendorBySource -Source $cachedApp.Source if ($vendor -and $vendor.block -and $vendor.block.rename) { Write-Host "[PATCH APP] BLOCK $($cachedApp.AppName) ($($vendor.displayName)-managed)" -ForegroundColor DarkYellow return @{ __status = 409 error = "Diese App wird durch $($vendor.displayName) verwaltet — Namensaenderungen in Intune werden beim naechsten Sync ueberschrieben. Bitte im $($vendor.displayName)-Portal umbenennen." code = "$($vendor.id)Managed" source = $vendor.displayName } } if (-not $cachedApp.AppTypeRaw) { return @{ __status = 500; error = "App-Typ unbekannt — Cache ist inkonsistent. Bitte Apps neu laden." } } $oldName = [string]$cachedApp.AppName if ($oldName -eq $newName) { return @{ ok = $true; appId = $AppId; appName = $newName; unchanged = $true } } Write-Host "[PATCH APP] '$oldName' -> '$newName' ($AppId)" -ForegroundColor Yellow try { Update-GraphMobileApp -AppId $AppId -AppTypeRaw $cachedApp.AppTypeRaw -Patch @{ displayName = $newName } } catch { $exMsg = $_.Exception.Message $graphBody = $null try { $graphBody = $_.ErrorDetails.Message } catch {} if (-not $graphBody -and $exMsg -match 'Graph-Response:\s*(.+)$') { $graphBody = $matches[1] } $graphMsg = $null if ($graphBody) { try { $j = $graphBody | ConvertFrom-Json if ($j.error -and $j.error.message) { $graphMsg = [string]$j.error.message } } catch {} } $details = Get-GraphErrorFriendly -ErrorRecord $_ $friendly = if ($graphMsg) { $graphMsg } else { $details.Friendly } $status = if ($details.Code -like '*403*') { 403 } elseif ($details.Code -like '*404*') { 404 } else { 500 } Write-Host "[PATCH APP] FAIL [$($details.Code)] $friendly" -ForegroundColor Red if ($graphBody) { Write-Host " Graph-Body: $graphBody" -ForegroundColor DarkRed } return @{ __status = $status; error = $friendly; code = $details.Code; graph = $graphBody } } # Cache aktualisieren $cachedApp.AppName = $newName Write-Host "[PATCH APP] OK '$newName'" -ForegroundColor Green return @{ ok = $true; appId = $AppId; appName = $newName; previousName = $oldName } } # Oeffnet einen nativen Windows-Datei-Dialog auf dem Server-Rechner (= der # Rechner des Users, da localhost-Tool). Browser geben den vollen lokalen Pfad # nie heraus — deshalb der Backend-Dialog. Laeuft in einem STA-Runspace, weil # WinForms-Dialoge zwingend STA brauchen. Owner-Form mit TopMost holt den Dialog # vor das Browser-Fenster. function Show-OpenFileDialog { param( [string]$Filter = "Alle Dateien (*.*)|*.*", [string]$Title = "Datei auswaehlen" ) # Eigener powershell.exe -STA Prozess: in einem In-Process-Runspace blitzt # der Dialog nur kurz auf (keine echte Windows-Message-Pump, er bleibt nicht # modal). Ein separater STA-Konsolen-Prozess hat einen vollwertigen # STA-Hauptthread -> der Dialog erscheint und bleibt offen bis zur Auswahl. # Ergebnis-Pfad kommt ueber stdout zurueck. $fEsc = $Filter -replace "'", "''" $tEsc = $Title -replace "'", "''" $inner = @" `$ProgressPreference = 'SilentlyContinue' Add-Type -AssemblyName System.Windows.Forms `$dlg = New-Object System.Windows.Forms.OpenFileDialog `$dlg.Filter = '$fEsc' `$dlg.Title = '$tEsc' `$dlg.CheckFileExists = `$true `$dlg.Multiselect = `$false `$owner = New-Object System.Windows.Forms.Form `$owner.TopMost = `$true `$owner.ShowInTaskbar = `$false `$owner.WindowState = 'Minimized' `$owner.Show(); `$owner.Activate() `$r = `$dlg.ShowDialog(`$owner) `$owner.Dispose() if (`$r -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write(`$dlg.FileName) } "@ $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($inner)) $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = "powershell.exe" $psi.Arguments = "-NoProfile -STA -ExecutionPolicy Bypass -EncodedCommand $encoded" $psi.UseShellExecute = $false $psi.RedirectStandardOutput = $true $psi.CreateNoWindow = $true try { $proc = [System.Diagnostics.Process]::Start($psi) $path = $proc.StandardOutput.ReadToEnd() $proc.WaitForExit() } catch { throw "Datei-Dialog-Prozess konnte nicht gestartet werden: $($_.Exception.Message)" } return ([string]$path).Trim() } function Invoke-FilePickerEndpoint { # Oeffnet den Datei-Dialog und liefert den gewaehlten Pfad. Braucht keine # Graph-Verbindung. Body optional: { filter, title }. param($Body) $filter = "Intune-Pakete (*.intunewin)|*.intunewin|MSI (*.msi)|*.msi|MSIX/AppX (*.msix;*.appx)|*.msix;*.appx|Alle Dateien (*.*)|*.*" $title = "Setup-Datei auswaehlen" if ($Body -and $Body.filter) { $filter = [string]$Body.filter } if ($Body -and $Body.title) { $title = [string]$Body.title } try { $path = Show-OpenFileDialog -Filter $filter -Title $title } catch { return @{ __status = 500; error = "Datei-Dialog fehlgeschlagen: $($_.Exception.Message)" } } if (-not $path) { return @{ ok = $true; cancelled = $true } } return @{ ok = $true; path = $path } } # App-Typ (@odata.type) -> unterstuetzter Content-Update-Pfad + erlaubte Endung. # Phase 1: nur win32LobApp/.intunewin aktiv; MSI/MSIX kommen in Phase 2/3. function Get-AppContentTypeMap { param([string]$AppTypeRaw) $t = ($AppTypeRaw -replace '^#microsoft\.graph\.', '') switch ($t) { 'win32LobApp' { return @{ graphType = 'win32LobApp'; exts = @('.intunewin'); phase = 1 } } 'windowsMobileMSI' { return @{ graphType = 'windowsMobileMSI'; exts = @('.msi'); phase = 2 } } 'windowsUniversalAppX'{ return @{ graphType = 'windowsUniversalAppX'; exts = @('.msix','.appx'); phase = 3 } } default { return $null } } } function Update-AppContentEndpoint { # Laedt eine neue Setup-Datei (lokaler Pfad) in eine native App und aktiviert # sie als neue contentVersion. Body: { filePath, displayVersion? }. param([string]$AppId, $Body) $err = Test-Connected if ($err) { return $err } if ([string]::IsNullOrWhiteSpace($AppId)) { return @{ __status = 400; error = "AppId fehlt" } } if (-not $Body) { return @{ __status = 400; error = "Request-Body fehlt" } } $filePath = [string]$Body.filePath $displayVersion = if ($Body.displayVersion) { [string]$Body.displayVersion } else { $null } if (-not $filePath) { return @{ __status = 400; error = "filePath fehlt im Body" } } # App aus Cache (fuer Typ + Vendor-Check) $cachedApp = $script:State.Apps | Where-Object { $_.AppId -eq $AppId } | Select-Object -First 1 if (-not $cachedApp) { return @{ __status = 404; error = "App im Cache nicht gefunden. Bitte Apps neu laden." } } if (-not $cachedApp.AppTypeRaw) { return @{ __status = 500; error = "App-Typ unbekannt — Cache inkonsistent, bitte Apps neu laden." } } # Vendor-managed Apps blocken (PMPC/Robopack) — analog Rename/Delete $vendor = Find-VendorBySource -Source $cachedApp.Source if ($vendor) { return @{ __status = 409 error = "Diese App wird durch $($vendor.displayName) verwaltet — der Content kann nur im $($vendor.displayName)-Portal aktualisiert werden." code = "$($vendor.id)Managed" source = $vendor.displayName } } # App-Typ unterstuetzt? $map = Get-AppContentTypeMap -AppTypeRaw $cachedApp.AppTypeRaw if (-not $map) { return @{ __status = 400; error = "App-Typ '$($cachedApp.AppTypeRaw)' unterstuetzt keine Content-Aktualisierung." } } if ($map.phase -gt 1) { return @{ __status = 501; error = "Content-Update fuer $($map.graphType) ist noch nicht aktiviert (kommt in einer spaeteren Phase). Aktuell nur .intunewin (win32LobApp)." } } # Datei + Endung pruefen if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) { return @{ __status = 400; error = "Datei nicht gefunden: $filePath" } } $ext = [IO.Path]::GetExtension($filePath).ToLower() if ($ext -notin $map.exts) { return @{ __status = 400; error = "Dateiendung '$ext' passt nicht zum App-Typ $($map.graphType). Erwartet: $($map.exts -join ', ')" } } Write-Host "[CONTENT] Update $($cachedApp.AppName) ($($map.graphType)) <- $filePath" -ForegroundColor Yellow try { $result = Update-GraphAppContent -AppId $AppId -GraphTypeRaw $cachedApp.AppTypeRaw -FilePath $filePath -DisplayVersion $displayVersion } catch { $exMsg = $_.Exception.Message $graphBody = $null try { $graphBody = $_.ErrorDetails.Message } catch {} if (-not $graphBody -and $exMsg -match 'Graph-Response:\s*(.+)$') { $graphBody = $matches[1] } $details = Get-GraphErrorFriendly -ErrorRecord $_ $friendly = if ($exMsg) { $exMsg } else { $details.Friendly } $status = if ($details.Code -like '*403*') { 403 } elseif ($details.Code -like '*404*') { 404 } else { 500 } Write-Host "[CONTENT] FAIL [$($details.Code)] $friendly" -ForegroundColor Red if ($graphBody) { Write-Host " Graph-Body: $graphBody" -ForegroundColor DarkRed } return @{ __status = $status; error = $friendly; code = $details.Code; graph = $graphBody } } # App-Liste-Cache invalidieren, damit neue Version/Daten nachgeladen werden if ($displayVersion) { $cachedApp.Version = $displayVersion } $script:State.Apps = @() Write-Host "[CONTENT] OK $($cachedApp.AppName) -> contentVersion $($result.contentVersion)" -ForegroundColor Green return @{ ok = $true appId = $AppId appName = $cachedApp.AppName contentVersion = $result.contentVersion displayVersion = $displayVersion } } function Add-AppAssignmentEndpoint { # Erzeugt eine neue Zuweisung fuer eine App. # Body: # { intent: "available"|"required", # target: "ALL_USERS"|"ALL_DEVICES"|"" } param( [string]$AppId, $Body ) $err = Test-Connected if ($err) { return $err } if ([string]::IsNullOrWhiteSpace($AppId)) { return @{ __status = 400; error = "AppId fehlt" } } if (-not $Body) { return @{ __status = 400; error = "Request-Body fehlt" } } $intent = if ($Body.intent) { ([string]$Body.intent).ToLower() } else { "" } 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 / )" } } # 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" } } # 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 try { Add-GraphAppAssignment -AppId $AppId -Intent $intent -GroupId $target } catch { $graphBody = $null try { $graphBody = $_.ErrorDetails.Message } catch {} $graphMsg = $null if ($graphBody) { try { $j = $graphBody | ConvertFrom-Json; if ($j.error -and $j.error.message) { $graphMsg = [string]$j.error.message } } catch {} } $details = Get-GraphErrorFriendly -ErrorRecord $_ $friendly = if ($graphMsg) { $graphMsg } else { $details.Friendly } $status = if ($details.Code -like '*403*') { 403 } elseif ($details.Code -like '*409*') { 409 } else { 500 } Write-Host "[ADD ASSIGN] FAIL [$($details.Code)] $friendly" -ForegroundColor Red return @{ __status = $status; error = $friendly; code = $details.Code; graph = $graphBody } } # Cache aktualisieren — neue Zuweisung im App-Eintrag ergaenzen 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 {} } @{ 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 } } function Remove-AppAssignmentEndpoint { param( [string]$AppId, [string]$GroupId, $Query ) $err = Test-Connected if ($err) { return $err } if ([string]::IsNullOrWhiteSpace($AppId) -or [string]::IsNullOrWhiteSpace($GroupId)) { return @{ __status = 400; error = "AppId und GroupId erforderlich" } } $intent = $null if ($Query -and $Query.type) { $t = ([string]$Query.type).ToLower() if ($t -in @('available','required')) { $intent = $t } } Write-Host "[DELETE ASSIGN] App=$AppId Group=$GroupId Intent=$(if ($intent) { $intent } else { '(alle)' })" -ForegroundColor Yellow try { $deleted = Remove-GraphAppAssignmentByGroup -AppId $AppId -GroupId $GroupId -Intent $intent } catch { $details = Get-GraphErrorFriendly -ErrorRecord $_ Write-Host "[DELETE ASSIGN] FAIL [$($details.Code)] $($details.Friendly)" -ForegroundColor Red $status = if ($details.Code -like '*403*') { 403 } elseif ($details.Code -like '*404*') { 404 } else { 500 } return @{ __status = $status; error = $details.Friendly; code = $details.Code } } if (-not $deleted -or $deleted.Count -eq 0) { Write-Host "[DELETE ASSIGN] Keine passende Zuweisung gefunden" -ForegroundColor DarkYellow return @{ __status = 404; error = "Keine passende Zuweisung gefunden" } } # Cache aktualisieren: zuweisungs-Eintrag aus der App entfernen if ($script:State.Apps -and $script:State.Apps.Count -gt 0) { foreach ($app in $script:State.Apps) { if ($app.AppId -ne $AppId) { continue } if ($intent -in @($null,'available') -and $app.AvailableGroups) { $app.AvailableGroups = @($app.AvailableGroups | Where-Object { $_.GroupId -ne $GroupId }) $app.AvailableCount = $app.AvailableGroups.Count } if ($intent -in @($null,'required') -and $app.RequiredGroups) { $app.RequiredGroups = @($app.RequiredGroups | Where-Object { $_.GroupId -ne $GroupId }) $app.RequiredCount = $app.RequiredGroups.Count } } } Write-Host "[DELETE ASSIGN] OK ($($deleted.Count) entfernt)" -ForegroundColor Green return @{ ok = $true; appId = $AppId; groupId = $GroupId; removed = $deleted.Count } } # ============================================================ # Apply Assignments # ============================================================ function Invoke-ApplyEndpoint { param($Body) $err = Test-Connected if ($err) { return $err } # Pre-Flight: Token-Check $ctx = $null try { $ctx = Get-MgContext } catch {} if (-not $ctx -or -not $ctx.Account) { Write-Host "[APPLY] Get-MgContext leer — Token verloren!" -ForegroundColor Red $script:State.Connected = $false return @{ __status = 401; error = "Microsoft-Graph-Token verloren. Bitte neu anmelden." } } Write-Host "[APPLY] Pre-Flight OK — Account=$($ctx.Account)" -ForegroundColor DarkGray # Bevorzugt: flache Ops-Liste mit pro-Item-Empfaenger. # Backwards-compat: alte targets/assignments-Struktur wird zur Ops-Liste expandiert. $ops = @() if ($Body.ops) { $ops = @($Body.ops) } elseif ($Body.targets -and $Body.assignments) { foreach ($t in @($Body.targets)) { foreach ($a in @($Body.assignments)) { $ops += @{ targetId = $t.id targetName = $t.displayName targetType = $t.type appId = $a.appId appName = $a.appName groupId = $a.groupId groupName = $a.groupName type = $a.type } } } } if ($ops.Count -eq 0) { return @{ __status = 400; error = "Keine Vorgaenge angegeben" } } $isUserMode = ($ops | Where-Object { $_.targetType -eq 'user' }).Count -gt 0 $total = $ops.Count $success = 0; $errors = 0; $skipped = 0 $log = @() $detailedResults = @() Write-Host "[APPLY] Body geparst: ops.Count=$($ops.Count) (deptOps=$(@($ops | Where-Object { $_.targetType -ne 'user' }).Count) userOps=$(@($ops | Where-Object { $_.targetType -eq 'user' }).Count))" -ForegroundColor DarkGray Write-Host "[APPLY] Starte $total Vorgaenge..." -ForegroundColor Cyan $applyStart = Get-Date foreach ($op in $ops) { $tId = [string]$op.targetId $tName = [string]$op.targetName $aGid = [string]$op.groupId $aGname = [string]$op.groupName $aApp = [string]$op.appName $aType = [string]$op.type $entry = "$tName -> $aApp ($aGname - $aType)" if ($aGid -in @("ALL_USERS","ALL_DEVICES")) { $skipped++ $log += "[SKIP] $entry (Native Target)" $detailedResults += @{ target=$tName; app=$aApp; group=$aGname; type=$aType; status="Skipped"; message="Native target - nicht aenderbar" } Write-Host " [SKIP] $entry" -ForegroundColor DarkYellow continue } $opStart = Get-Date try { Write-Host " -> Add-GraphMember GroupId=$aGid DirectoryObjectId=$tId" -ForegroundColor DarkGray Add-GraphMember -GroupId $aGid -DirectoryObjectId $tId $opMs = [int]((Get-Date) - $opStart).TotalMilliseconds $success++ $log += "[OK] $entry" $detailedResults += @{ target=$tName; app=$aApp; group=$aGname; type=$aType; status="Success"; message="Hinzugefuegt" } Write-Host " [OK] $entry (${opMs}ms)" -ForegroundColor Green } catch { $opMs = [int]((Get-Date) - $opStart).TotalMilliseconds $details = Get-GraphErrorFriendly -ErrorRecord $_ if ($details.IsWarning) { $success++ $log += "[OK*] $entry (bereits Mitglied)" $detailedResults += @{ target=$tName; app=$aApp; group=$aGname; type=$aType; status="AlreadyMember"; message=$details.Friendly } Write-Host " [OK*] $entry (bereits Mitglied, ${opMs}ms)" -ForegroundColor DarkGreen } else { $errors++ $log += "[FAIL] $entry [$($details.Code)] $($details.Friendly)" $detailedResults += @{ target=$tName; app=$aApp; group=$aGname; type=$aType; status="Error"; code=$details.Code; message=$details.Friendly } Write-Host " [FAIL] $entry [$($details.Code)] $($details.Friendly) (${opMs}ms)" -ForegroundColor Red Write-Host " Vollstaendig: $($details.Full)" -ForegroundColor DarkRed } } } # Targets/Assignments fuer den HTML-Report rekonstruieren $targets = @{} $assignments = @{} foreach ($op in $ops) { $targets[$op.targetId] = @{ id=$op.targetId; displayName=$op.targetName; type=$op.targetType } $aKey = "$($op.appId)|$($op.groupId)|$($op.type)" $assignments[$aKey] = @{ appId=$op.appId; appName=$op.appName; groupId=$op.groupId; groupName=$op.groupName; type=$op.type } } $targets = @($targets.Values) $assignments = @($assignments.Values) $applyMs = [int]((Get-Date) - $applyStart).TotalMilliseconds Write-Host "[APPLY] Fertig in ${applyMs}ms — OK=$success Skip=$skipped Err=$errors" -ForegroundColor Cyan # Report VOR Response generieren $reportFile = $null try { $reportFile = New-HtmlReport -Targets $targets -Assignments $assignments -IsUserMode $isUserMode -Success $success -Errors $errors -Skipped $skipped -Total $total -Log $log -Details $detailedResults Write-Host "[APPLY] HTML-Report: $reportFile" -ForegroundColor DarkGray } catch { Write-Host "[APPLY] HTML-Report-Erstellung fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor Red } # Cache invalidieren - Membership stimmt nicht mehr $script:State.GroupMembers = @{} $reportUrl = if ($reportFile) { "/reports/" + (Split-Path $reportFile -Leaf) } else { "" } $resp = @{ success = [int]$success errors = [int]$errors skipped = [int]$skipped total = [int]$total reportUrl = [string]$reportUrl details = $detailedResults build = [string]$script:BuildStamp } Write-Host "[APPLY] === Response: success=$success errors=$errors skipped=$skipped total=$total reportUrl='$reportUrl' details=$($detailedResults.Count) build=$script:BuildStamp" -ForegroundColor Yellow return $resp } function New-HtmlReport { param($Targets, $Assignments, $IsUserMode, $Success, $Errors, $Skipped, $Total, $Log, $Details) Add-Type -AssemblyName System.Web function _enc { param($s) if ($null -eq $s) { return "" } return [System.Web.HttpUtility]::HtmlEncode([string]$s) } $ts = Get-Date -Format "yyyy-MM-dd_HH-mm-ss" $tsHuman = Get-Date -Format "dd.MM.yyyy HH:mm:ss" $name = "report-$ts.html" $path = Join-Path $script:Config.ReportDir $name $user = $env:USERNAME $machine = $env:COMPUTERNAME $tenant = if ($script:State.TenantId) { $script:State.TenantId } else { "" } $account = if ($script:State.Account) { $script:State.Account } else { "" } # Erfolgsquote — Skipped zaehlen wir neutral, nicht als Fehler $effective = [Math]::Max(1, ($Success + $Errors)) $rate = [int](($Success / $effective) * 100) $rateStatus = if ($Errors -eq 0 -and $Success -gt 0) { "ok" } elseif ($Errors -gt 0 -and $Success -gt 0) { "warn" } elseif ($Errors -gt 0) { "err" } else { "muted" } # Empfaenger-Mix $targetGroups = @($Targets | Where-Object { $_.type -ne 'user' }) $targetUsers = @($Targets | Where-Object { $_.type -eq 'user' }) $grpSuffix = if ($targetGroups.Count -eq 1) { "" } else { "n" } $assignSuffix = if ($Assignments.Count -eq 1) { "" } else { "en" } # Status-Mapping fuer Detail-Zeilen $statusMeta = @{ "Success" = @{ cls = "ok"; icon = "✓"; label = "Erfolgreich" } "AlreadyMember" = @{ cls = "ok"; icon = "✓"; label = "Bereits Mitglied" } "Skipped" = @{ cls = "skip"; icon = "–"; label = "Uebersprungen" } "Error" = @{ cls = "err"; icon = "!"; label = "Fehler" } } # Detail-Zeilen pro App gruppieren — ergibt eine kompaktere Darstellung $byApp = @{} foreach ($d in $Details) { $key = [string]$d.app if (-not $byApp.ContainsKey($key)) { $byApp[$key] = @() } $byApp[$key] += $d } $appBlocksHtml = "" foreach ($appName in ($byApp.Keys | Sort-Object)) { $rows = @($byApp[$appName]) $okCount = @($rows | Where-Object { $_.status -in @('Success','AlreadyMember') }).Count $skipCount = @($rows | Where-Object { $_.status -eq 'Skipped' }).Count $errCount = @($rows | Where-Object { $_.status -eq 'Error' }).Count # Pro App: Gruppen-Spalte + Empfaenger-Zeilen $rowsHtml = "" foreach ($d in $rows) { $st = $statusMeta[[string]$d.status] if (-not $st) { $st = @{ cls = ""; icon = ""; label = [string]$d.status } } $msg = if ($d.message) { _enc $d.message } else { "" } $errCode = if ($d.code) { " $(_enc $d.code)" } else { "" } $rowsHtml += "" + "$($st.icon) $($st.label)" + "$(_enc $d.target)" + "$(_enc $d.group)" + "$(_enc $d.type)" + "$msg$errCode" + "" } $appHeadStats = "" if ($okCount -gt 0) { $appHeadStats += "$okCount OK" } if ($skipCount -gt 0) { $appHeadStats += "$skipCount Skip" } if ($errCount -gt 0) { $appHeadStats += "$errCount Fehler" } $openAttr = if ($errCount -gt 0) { " open" } else { "" } $appBlocksHtml += "
" + "$(_enc $appName)" + "$appHeadStats" + "" + "
" + "" + "" + "" + "$rowsHtml
StatusEmpfaengerIntune-GruppeTypMeldung
" } if (-not $appBlocksHtml) { $appBlocksHtml = "
Keine Detail-Eintraege.
" } # Empfaenger-Liste $recipChipsHtml = "" foreach ($t in $Targets) { $isUser = ($t.type -eq 'user') $cls = if ($isUser) { "chip chip-user" } else { "chip chip-group" } $modeLbl = if ($isUser) { "User" } else { "Gruppe" } $entraUrl = if ($isUser) { "https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/$([uri]::EscapeDataString([string]$t.id))" } else { "https://intune.microsoft.com/#view/Microsoft_AAD_IAM/GroupDetailsMenuBlade/~/Overview/groupId/$([uri]::EscapeDataString([string]$t.id))/menuId/" } $recipChipsHtml += "" + "$modeLbl" + "$(_enc $t.displayName)" } # Zuweisungs-Liste $assignChipsHtml = "" foreach ($a in $Assignments) { $intent = if ($a.type -eq 'Required') { 'req' } else { 'avail' } $intentLbl = $a.type $intuneUrl = "https://intune.microsoft.com/#view/Microsoft_Intune_Apps/SettingsMenu/~/2/appId/$([uri]::EscapeDataString([string]$a.appId))" $assignChipsHtml += "
" + "$(_enc $a.appName)" + "
" + "$(_enc $intentLbl)" + "$(_enc $a.groupName)" + "
" } if (-not $assignChipsHtml) { $assignChipsHtml = "
Keine Zuweisungen.
" } $logHtml = ($Log | ForEach-Object { $line = _enc $_ if ($line -like "`[OK`]*") { "$line" } elseif ($line -like "`[OK*`]*") { "$line" } elseif ($line -like "`[FAIL`]*") { "$line" } elseif ($line -like "`[SKIP`]*") { "$line" } else { $line } }) -join "`n" if (-not $logHtml) { $logHtml = "(kein Log)" } # Header-Status-Banner $bannerCls = if ($Errors -gt 0) { "banner-err" } elseif ($Skipped -gt 0 -and $Success -gt 0) { "banner-warn" } else { "banner-ok" } $bannerTxt = if ($Errors -gt 0) { "$Errors Fehler aufgetreten - Details unten" } elseif ($Errors -eq 0 -and $Skipped -gt 0 -and $Success -gt 0) { "Alle Vorgaenge ohne Fehler. $Skipped uebersprungen." } elseif ($Errors -eq 0 -and $Success -gt 0) { "Alle $Success Vorgaenge erfolgreich" } else { "Keine ausgefuehrten Vorgaenge" } $html = @" Intune Zuweisungs-Report - $tsHuman
Intune Zuweisungs-Report
$tsHuman · ausgefuehrt von $(_enc $user)
"@ if ($account) { $html += "Account $(_enc $account)" } if ($tenant) { $html += "Tenant $(_enc $tenant)" } $html += "Host $(_enc $machine)" $html += @"
$Total
Vorgaenge
$Success
Erfolgreich
$Skipped
Uebersprungen
$Errors
Fehler
$rate%
Erfolgsquote
$Success von $effective effektiv
Empfaenger
$($targetGroups.Count) Gruppe$grpSuffix · $($targetUsers.Count) Benutzer
$recipChipsHtml
Zuweisungen
$($Assignments.Count) eindeutige App-Gruppen-Zuweisung$assignSuffix
$assignChipsHtml
Detail-Ergebnisse
Gruppiert nach App · Bloecke mit Fehlern sind aufgeklappt
$appBlocksHtml
Debug-Log ($($Log.Count) Zeilen)
$logHtml
"@ [IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8) return $path }