# 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 } "GET /api/update/check" { return Get-UpdateCheckEndpoint } "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 } "POST /api/groups/resolve-upns" { return Resolve-UpnsEndpoint -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 } "GET /api/apps/report" { return Get-AppInstallReportEndpoint } "GET /api/apps/devicestatus" { return Get-AppDeviceStatusEndpoint -Query $Query } "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 } "GET /api/tenants" { return Get-TenantsEndpoint } "POST /api/tenants/switch" { return Switch-TenantEndpoint -Body $Body } "GET /api/policies/compliance" { return Get-CompliancePoliciesEndpoint } "GET /api/policies/configuration" { return Get-ConfigurationProfilesEndpoint } "GET /api/policies/settingscatalog" { return Get-SettingsCatalogPoliciesEndpoint } "GET /api/policies/administrativetemplate" { return Get-AdministrativeTemplatesEndpoint } "POST /api/policies/export" { return Export-PoliciesEndpoint -Body $Body } "GET /api/policies/exports" { return Get-PolicyExportsEndpoint } "POST /api/policies/import" { return Import-PoliciesEndpoint -Body $Body } "POST /api/policies/assign" { return Invoke-PolicyAssignEndpoint -Body $Body } "POST /api/policies/consolidate" { return Invoke-PolicyConsolidateEndpoint -Body $Body } "POST /api/policies/git-snapshot" { return Invoke-PolicyGitSnapshotEndpoint } "POST /api/pickfolder" { return Invoke-FolderPickerEndpoint -Body $Body } "GET /api/offboard/search" { return Search-OffboardDevicesEndpoint -Query $Query } "POST /api/offboard/resolve" { return Get-OffboardResolveEndpoint -Body $Body } "POST /api/offboard/keys" { return Get-OffboardKeysEndpoint -Body $Body } "POST /api/offboard/execute" { return Invoke-OffboardExecuteEndpoint -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/([^/]+)/devices/export$") { return Get-GroupDevicesExportEndpoint -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/users/([^/]+)/memberof$") { return Get-UserMemberOfEndpoint -UserId $matches[1] } if ($Method -eq "GET" -and $Path -eq "/api/devices") { return Search-DevicesEndpoint -Query $Query } if ($Method -eq "GET" -and $Path -match "^/api/devices/([^/]+)$") { return Get-DeviceEndpoint -DeviceId $matches[1] } if ($Method -eq "POST" -and $Path -match "^/api/devices/([^/]+)/action$") { return Invoke-DeviceActionEndpoint -DeviceId $matches[1] -Body $Body } 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 } # ============================================================ # Update-Check (neueste Release-Version von Gitea vergleichen) # ============================================================ # Semver-Vergleich: 1 wenn A>B, -1 wenn A 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. # Im Multi-Tenant-Modus kommen TenantId/ClientId aus dem aktiven Profil. $active = Get-ActiveConnection -Settings $script:Settings $script:Config.TenantId = $active.tenantId $script:Config.ClientId = $active.clientId $script:Config.ClientIdRo = $active.clientIdRo $script:Config.Scopes = @($script:Settings.connection.scopes) $script:Config.ScopesRo = @($script:Settings.connection.scopesRo) # Policy Export/Import braucht den Configuration-Scope. Immer sicherstellen — # sonst faellt er nach einem Settings-Save (der Config.Scopes neu aus den # persistierten Settings setzt) bis zum Neustart weg. if ($script:Config.Scopes -notcontains 'DeviceManagementConfiguration.ReadWrite.All') { $script:Config.Scopes = @($script:Config.Scopes) + 'DeviceManagementConfiguration.ReadWrite.All' } if ($script:Config.ScopesRo -notcontains 'DeviceManagementConfiguration.Read.All') { $script:Config.ScopesRo = @($script:Config.ScopesRo) + 'DeviceManagementConfiguration.Read.All' } } 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 # Aktive Verbindung vergleichen (deckt Single-Felder UND das aktive # Multi-Tenant-Profil ab), plus Moduswechsel Single<->Multi. $activeOld = Get-ActiveConnection -Settings $old $activeNew = Get-ActiveConnection -Settings $merged $multiOld = ($old.connection.PSObject.Properties['multiTenant'] -and [bool]$old.connection.multiTenant) $multiNew = ($merged.connection.PSObject.Properties['multiTenant'] -and [bool]$merged.connection.multiTenant) $connectionChanged = ( $activeNew.tenantId -ne $activeOld.tenantId -or $activeNew.clientId -ne $activeOld.clientId -or $activeNew.clientIdRo -ne $activeOld.clientIdRo -or (($merged.connection.scopes -join "|") -ne ($old.connection.scopes -join "|")) -or ($multiNew -ne $multiOld) ) # 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 '|')) # Tenant-bewusst: vergleicht die aufgeloesten RPA-Namen (Profil-Override oder global). $rpaChanged = ((@(Get-RpaGroupNames -Settings $merged) -join "|") -ne (@(Get-RpaGroupNames -Settings $old) -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 $script:State.ReadOnly = $false # 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 } } $activeConn = Get-ActiveConnection -Settings $script:Settings $activeId = "" if ($script:Settings.connection.PSObject.Properties['activeTenantId']) { $activeId = [string]$script:Settings.connection.activeTenantId } return @{ connected = $script:State.Connected account = $script:State.Account tenantId = $script:State.TenantId readOnly = [bool]$script:State.ReadOnly groupsLoaded = $script:State.Groups.Count rpaLoaded = $script:State.RpaGroups.Count appsLoaded = $script:State.Apps.Count sessionCount = $script:State.Session.Count connect = $connect multiTenant = (Test-ConnectionMultiTenant) tenantLabel = $activeConn.label activeTenantId = $activeId } } # ============================================================ # 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); [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr lpdwProcessId); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId(); [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam); // Holt ein Fenster zuverlaessig in den Vordergrund und umgeht den Windows- // Foreground-Lock: kurzer ALT-Tap (entsperrt SetForegroundWindow) + Anhaengen // an den Input-Thread des aktuellen Vordergrundfensters (AttachThreadInput). public static void ForceForeground(IntPtr hWnd) { keybd_event(0x12, 0, 0, UIntPtr.Zero); // ALT down -> Foreground-Lock loesen keybd_event(0x12, 0, 2, UIntPtr.Zero); // ALT up (KEYEVENTF_KEYUP = 2) IntPtr fore = GetForegroundWindow(); uint foreThread = GetWindowThreadProcessId(fore, IntPtr.Zero); uint thisThread = GetCurrentThreadId(); bool attached = false; if (foreThread != 0 && foreThread != thisThread) { attached = AttachThreadInput(foreThread, thisThread, true); } ShowWindow(hWnd, 9); // SW_RESTORE BringWindowToTop(hWnd); SetForegroundWindow(hWnd); if (attached) { AttachThreadInput(foreThread, thisThread, false); } } } '@ -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]::ForceForeground($script:found) 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 } # Erst Read/Write-App probieren, dann (falls konfiguriert) Read-Only-App. $clientIdRw = $script:Config.ClientId $clientIdRo = $script:Config.ClientIdRo $useRo = $false $connectErr = $null try { Connect-MgGraph ` -TenantId $script:Config.TenantId ` -ClientId $clientIdRw ` -Scopes $script:Config.Scopes ` -NoWelcome -ErrorAction Stop } catch { $connectErr = $_.Exception.Message Write-Host "[CONNECT] RW-App fehlgeschlagen ($connectErr)" -ForegroundColor Yellow if ($clientIdRo -and $clientIdRo.Trim()) { Write-Host "[CONNECT] Versuche Read-Only-App..." -ForegroundColor Cyan try { Connect-MgGraph ` -TenantId $script:Config.TenantId ` -ClientId $clientIdRo ` -Scopes $script:Config.ScopesRo ` -NoWelcome -ErrorAction Stop $useRo = $true $connectErr = $null } catch { $connectErr = $_.Exception.Message } } } # Helper-Runspace aufraeumen if ($bringToFront) { try { $bringToFront.Stop() } catch {} try { $bringToFront.Dispose() } catch {} } if ($connectErr) { Write-Host "[CONNECT] Beide App-IDs fehlgeschlagen: $connectErr" -ForegroundColor Red return @{ __status = 401; error = "Anmeldung fehlgeschlagen: $connectErr" } } $context = Get-MgContext if ($context) { $script:State.Connected = $true $script:State.Account = $context.Account $script:State.TenantId = $context.TenantId $script:State.ReadOnly = $useRo Write-Host "[CONNECT] Verbunden als: $($context.Account) ($(if ($useRo) { 'Read-Only' } else { 'Read/Write' }))" -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.ReadOnly = $false $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 } # ============================================================ # Multi-Tenant # ============================================================ function Test-ConnectionMultiTenant { $c = $script:Settings.connection return ($c -and $c.PSObject.Properties['multiTenant'] -and [bool]$c.multiTenant) } function Get-TenantsEndpoint { # Liste der Tenant-Profile fuer den Topbar-Umschalter. Client-IDs werden # nicht mitgeliefert (fuer die Anzeige nicht noetig). $c = $script:Settings.connection $isMulti = Test-ConnectionMultiTenant $items = @() if ($isMulti -and $c.PSObject.Properties['tenants']) { foreach ($t in @($c.tenants | Where-Object { $_ })) { $items += @{ id = [string]$t.id label = [string]$t.label tenantId = [string]$t.tenantId hasRo = [bool]($t.clientIdRo -and ([string]$t.clientIdRo).Trim()) } } } $activeId = if ($c.PSObject.Properties['activeTenantId']) { [string]$c.activeTenantId } else { "" } if ($isMulti -and $items.Count -gt 0 -and -not (@($items | Where-Object { $_.id -eq $activeId }).Count)) { $activeId = $items[0].id } return @{ multiTenant = $isMulti; activeId = $activeId; items = @($items) } } function Switch-TenantEndpoint { param($Body) if (-not (Test-ConnectionMultiTenant)) { return @{ __status = 400; error = "Multi-Tenant-Modus ist nicht aktiv." } } $id = if ($Body) { [string]$Body.id } else { "" } if ([string]::IsNullOrWhiteSpace($id)) { return @{ __status = 400; error = "Tenant-id fehlt." } } $c = $script:Settings.connection $tenants = @() if ($c.PSObject.Properties['tenants']) { $tenants = @($c.tenants | Where-Object { $_ }) } $profile = $tenants | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1 if (-not $profile) { return @{ __status = 404; error = "Tenant-Profil nicht gefunden." } } # Aktives Profil setzen + persistieren, Config spiegeln. $script:Settings.connection.activeTenantId = $id try { Write-Settings -Settings $script:Settings } catch { Write-Host "[TENANT] Speichern des aktiven Profils fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor Yellow } Sync-ConfigFromSettings # Bestehende Verbindung trennen (raeumt alle Tenant-Caches ab), dann sofort # mit dem neuen Tenant neu verbinden. Invoke-DisconnectEndpoint | Out-Null Write-Host "[TENANT] Wechsel zu '$([string]$profile.label)' ($($profile.tenantId))" -ForegroundColor Cyan return Invoke-ConnectEndpoint } 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 } # Tenant-bewusst: aktives Profil kann eigene RPA-Gruppen definieren, sonst global. $rpaNames = @(Get-RpaGroupNames -Settings $script:Settings) 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" } } # Naming tenant-bewusst aufloesen (aktives Profil kann ueberschreiben). $naming = Get-GroupNaming -Settings $script:Settings -Intent $intent $namingPrefix = $naming.prefix $namingSuffix = $naming.suffix $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 Resolve-UpnsEndpoint { # Loest eine Liste von UPNs/E-Mails per Graph auf und gibt userId + DisplayName zurueck. # Nutzt $batch (20 pro Call) fuer Geschwindigkeit. param($Body) $err = Test-Connected if ($err) { return $err } $upns = @($Body.upns | ForEach-Object { [string]$_.Trim() } | Where-Object { $_ -ne '' }) if ($upns.Count -eq 0) { return @{ __status = 400; error = "upns fehlt oder leer" } } Write-Host "[IMPORT] Loese $($upns.Count) UPNs auf..." -ForegroundColor DarkCyan $batchSize = 20 $resolved = [System.Collections.Generic.List[object]]::new() for ($i = 0; $i -lt $upns.Count; $i += $batchSize) { $chunk = $upns[$i .. [Math]::Min($i + $batchSize - 1, $upns.Count - 1)] $requests = @($chunk | ForEach-Object -Begin { $idx = $i } { $upn = $_ $enc = [Uri]::EscapeDataString("userPrincipalName eq '$upn' or mail eq '$upn'") $idx++ @{ id = [string]($idx - 1); method = 'GET'; url = "/users?`$filter=$enc&`$select=id,displayName,userPrincipalName,mail&`$top=1" } }) $body = @{ requests = $requests } | ConvertTo-Json -Depth 5 -Compress try { $resp = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/$batch' -Method POST -Body $body -ContentType 'application/json' $respMap = @{} foreach ($r in @($resp.responses)) { $respMap[[string]$r.id] = $r } for ($j = 0; $j -lt $chunk.Count; $j++) { $upn = $chunk[$j] $rid = [string]($i + $j) $r = $respMap[$rid] if ($r -and [int]$r.status -eq 200 -and @($r.body.value).Count -gt 0) { $u = $r.body.value[0] $resolved.Add([pscustomobject]@{ Upn = $upn UserId = [string]$u.id DisplayName = [string]$u.displayName Mail = [string]$u.mail Found = $true }) } else { $resolved.Add([pscustomobject]@{ Upn = $upn; UserId = ''; DisplayName = ''; Mail = ''; Found = $false }) } } } catch { Write-Host " [IMPORT] Batch-Fehler: $($_.Exception.Message)" -ForegroundColor DarkYellow foreach ($upn in $chunk) { $resolved.Add([pscustomobject]@{ Upn = $upn; UserId = ''; DisplayName = ''; Mail = ''; Found = $false }) } } } $found = @($resolved | Where-Object { $_.Found }) $notFound = @($resolved | Where-Object { -not $_.Found }) Write-Host " -> $($found.Count) gefunden, $($notFound.Count) nicht gefunden" -ForegroundColor DarkGray return @{ items = @($resolved); found = $found.Count; notFound = $notFound.Count } } 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 } } function Get-GroupDevicesExportEndpoint { # Loest die User einer Gruppe (inkl. verschachtelter Untergruppen) auf und liefert # deren Intune-Geraete (Geraete, deren PRIMAERER Benutzer in der Gruppe ist) fuer # einen CSV-Export. Geraete werden per $batch ueber userId eq '' geholt. param([string]$GroupId) $err = Test-Connected if ($err) { return $err } if ([string]::IsNullOrWhiteSpace($GroupId)) { return @{ __status = 400; error = "GroupId fehlt" } } $groupName = $GroupId try { $g = Get-GraphGroupById -Id $GroupId -Property @("id","displayName") if ($g.displayName) { $groupName = [string]$g.displayName } } catch { return @{ __status = 404; error = "Gruppe nicht gefunden: $($_.Exception.Message)" } } Write-Host "[GRP-DEV-EXPORT] Geraete-Export fuer Gruppe '$groupName' ($GroupId)" -ForegroundColor Cyan $sw = [System.Diagnostics.Stopwatch]::StartNew() # User aufloesen + deduplizieren (nur eindeutige Entra-User-Ids) $allUsers = @(Resolve-GroupMembersWithNesting -GroupId $GroupId -GroupName $groupName) $seen = @{} $userIds = [System.Collections.Generic.List[string]]::new() $userMap = @{} # userId -> @{ Upn; DisplayName } (aus den Gruppen-Mitgliedern) foreach ($u in $allUsers) { $uid = [string]$u.Id if ($uid -and -not $seen.ContainsKey($uid)) { $seen[$uid] = $true $userIds.Add($uid) $userMap[$uid] = @{ Upn = [string]$u.UserPrincipalName; DisplayName = [string]$u.DisplayName } } } if ($userIds.Count -eq 0) { return @{ ok = $true; groupId = $GroupId; groupName = $groupName; userCount = 0; items = @(); count = 0; resolvedMs = [int]$sw.ElapsedMilliseconds } } $select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,serialNumber,model,manufacturer,lastSyncDateTime,enrolledDateTime,managementAgent' $batchSize = 20 $devSeen = @{} $items = [System.Collections.Generic.List[object]]::new() $ids = $userIds.ToArray() $script:GrpDevErrors = @() for ($i = 0; $i -lt $ids.Count; $i += $batchSize) { $chunk = $ids[$i .. [Math]::Min($i + $batchSize - 1, $ids.Count - 1)] $requests = @() for ($j = 0; $j -lt $chunk.Count; $j++) { # Kanonischer Weg: die managedDevices-Navigation des Users. Zuverlaessiger # als $filter=userId eq '..' auf /deviceManagement/managedDevices. $requests += @{ id = [string]($i + $j); method = 'GET'; url = "/users/$($chunk[$j])/managedDevices?`$select=$select" } } $body = @{ requests = $requests } | ConvertTo-Json -Depth 5 -Compress try { $resp = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/$batch' -Method POST -Body $body -ContentType 'application/json' foreach ($r in @($resp.responses)) { if ([int]$r.status -ne 200) { if (@($script:GrpDevErrors).Count -lt 3) { $em = $null try { $em = [string]$r.body.error.message } catch {} $script:GrpDevErrors += "HTTP $($r.status)$(if ($em) { ": $em" })" } continue } # Batch-Request-Id -> Index in $ids -> Gruppen-User (fuer UPN/Name-Fallback, # da managedDevice.userPrincipalName bei Graph oft leer ist). $owner = $null $reqIdx = -1; if ([int]::TryParse([string]$r.id, [ref]$reqIdx)) { if ($reqIdx -ge 0 -and $reqIdx -lt $ids.Count) { $owner = $userMap[$ids[$reqIdx]] } } foreach ($d in @($r.body.value)) { $did = [string]$d.id if (-not $did -or $devSeen.ContainsKey($did)) { continue } $devSeen[$did] = $true $upn = if ($d.userPrincipalName) { [string]$d.userPrincipalName } elseif ($owner) { $owner.Upn } else { '' } $udn = if ($d.userDisplayName) { [string]$d.userDisplayName } elseif ($owner) { $owner.DisplayName } else { '' } $items.Add([pscustomobject]@{ Id = $did DeviceName = [string]$d.deviceName UserDisplayName = $udn UserPrincipalName = $upn OS = [string]$d.operatingSystem OSVersion = [string]$d.osVersion ComplianceState = [string]$d.complianceState ManagementAgent = [string]$d.managementAgent ManagementType = Get-ManagementType ([string]$d.managementAgent) SerialNumber = [string]$d.serialNumber Model = [string]$d.model Manufacturer = [string]$d.manufacturer LastSync = ConvertTo-IsoDate $d.lastSyncDateTime EnrolledDateTime = ConvertTo-IsoDate $d.enrolledDateTime }) } } } catch { Write-Host " [GRP-DEV-EXPORT] Batch-Fehler: $($_.Exception.Message)" -ForegroundColor DarkYellow } } $sw.Stop() Write-Host " -> $($items.Count) Geraete fuer $($userIds.Count) User, $($sw.ElapsedMilliseconds)ms" -ForegroundColor Green return @{ ok = $true groupId = $GroupId groupName = $groupName userCount = $userIds.Count items = @($items.ToArray()) count = $items.Count resolvedMs = [int]$sw.ElapsedMilliseconds errors = @($script:GrpDevErrors) } } # ============================================================ # Users # ============================================================ 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 } } function Get-UserMemberOfEndpoint { param($UserId) $err = Test-Connected if ($err) { return $err } $groups = @() $uri = "https://graph.microsoft.com/v1.0/users/$UserId/transitiveMemberOf/microsoft.graph.group?`$select=id,displayName,description,groupTypes,mailEnabled,securityEnabled&`$top=100" while ($uri) { $resp = Invoke-MgGraphRequestRetry -Uri $uri -Method GET foreach ($g in $resp.value) { $type = 'Sicherheitsgruppe' if ($g.groupTypes -contains 'Unified') { $type = 'Microsoft 365' } elseif ($g.mailEnabled -and -not $g.securityEnabled) { $type = 'Verteiler' } $groups += [pscustomobject]@{ Id = $g.id DisplayName = $g.displayName Description = $g.description Type = $type } } $uri = $resp.'@odata.nextLink' } $groups = $groups | Sort-Object DisplayName return @{ groups = $groups; count = $groups.Count } } # ============================================================ # Devices # ============================================================ # Verwaltungsart aus dem Graph-Feld 'managementAgent' ableiten. # 'configurationManagerClientMdm' / 'configurationManagerClientMdmEas' -> Co-Managed # (ConfigMgr + Intune), alles andere -> reines Intune (MDM). function Get-ManagementType { param([string]$Agent) if ($Agent -match 'configurationManager') { return 'Co-Managed' } return 'Intune' } function Search-DevicesEndpoint { param($Query) $err = Test-Connected if ($err) { return $err } $q = ([string]$Query.q) -replace "'", "''" # OData-Escape fuer Apostroph $os = [string]$Query.os $compliance = [string]$Query.compliance $top = 50 # HINWEIS: 'managementState' ist KEIN gueltiges $select-Feld auf managedDevices # -> fuehrt zu 400 BadRequest. Bewusst weggelassen. $select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,serialNumber,model,manufacturer,enrolledDateTime,managementAgent' # Ohne Suchbegriff: alle Geräte (erste Seite) # Mit Suchbegriff: Graph unterstuetzt startswith nur auf deviceName/userDisplayName/userPrincipalName. # Seriennummer wird separat per exaktem Filter gesucht und mit Name-Ergebnissen zusammengefuehrt. $nameItems = @() $snItems = @() if ($q -and $q.Length -ge 2) { $osComp = @() if ($os) { $osComp += "operatingSystem eq '$os'" } if ($compliance) { $osComp += "complianceState eq '$compliance'" } # Voller Namensfilter vs. nur deviceName. Manche Intune-Backends (DeviceFE) # unterstuetzen startswith NUR auf deviceName -> bei "Unsupported parameter" # (400) auf deviceName-only zurueckfallen. Kein $orderby mit $filter. $nameVariants = @( "(startswith(deviceName,'$q') or startswith(userDisplayName,'$q') or startswith(userPrincipalName,'$q'))", "startswith(deviceName,'$q')" ) for ($vi = 0; $vi -lt $nameVariants.Count; $vi++) { $filters = @($nameVariants[$vi]) + $osComp $filterStr = '$filter=' + [uri]::EscapeDataString(($filters -join ' and ')) + '&' $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?${filterStr}`$select=$select&`$top=$top" try { $resp = Invoke-MgGraphRequestRetry -Uri $uri -Method GET $nameItems = @($resp.value) break } catch { if ($vi -eq $nameVariants.Count - 1) { throw } # letzter Versuch -> durchreichen Write-Host " [DEVICES] Namensfilter nicht unterstuetzt -> Fallback auf deviceName-only" -ForegroundColor DarkYellow } } # Seriennummer: exakter Vergleich (Graph unterstuetzt kein startswith auf serialNumber) $snFilters = @("serialNumber eq '$q'") if ($os) { $snFilters += "operatingSystem eq '$os'" } if ($compliance) { $snFilters += "complianceState eq '$compliance'" } $snFilter = '$filter=' + [uri]::EscapeDataString(($snFilters -join ' and ')) + '&' $snUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?${snFilter}`$select=$select&`$top=10" try { $snResp = Invoke-MgGraphRequestRetry -Uri $snUri -Method GET $snItems = @($snResp.value) } catch { $snItems = @() } } else { $filters = @() if ($os) { $filters += "operatingSystem eq '$os'" } if ($compliance) { $filters += "complianceState eq '$compliance'" } $filterStr = if ($filters.Count -gt 0) { '$filter=' + [uri]::EscapeDataString(($filters -join ' and ')) + '&' } else { '' } # Kein $orderby: das Intune-DeviceFE-Backend lehnt es (mit/ohne Filter) teils ab. # Die Sortierung macht ohnehin das Frontend. $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?${filterStr}`$select=$select&`$top=$top" $resp = Invoke-MgGraphRequestRetry -Uri $uri -Method GET $nameItems = @($resp.value) } # Zusammenfuehren, Duplikate entfernen $seen = @{} $all = @($nameItems) + @($snItems) $items = @() foreach ($d in $all) { if (-not $d.id -or $seen[$d.id]) { continue } $seen[$d.id] = $true $items += [pscustomobject]@{ Id = $d.id DeviceName = $d.deviceName UserDisplayName = $d.userDisplayName UserPrincipalName= $d.userPrincipalName OS = $d.operatingSystem OSVersion = $d.osVersion ComplianceState = $d.complianceState LastSync = ConvertTo-IsoDate $d.lastSyncDateTime ManagementState = $d.managementState ManagementAgent = [string]$d.managementAgent ManagementType = Get-ManagementType ([string]$d.managementAgent) SerialNumber = $d.serialNumber Model = $d.model Manufacturer = $d.manufacturer EnrolledDateTime = ConvertTo-IsoDate $d.enrolledDateTime } } return @{ items = $items; count = $items.Count } } function Get-DeviceEndpoint { param($DeviceId) $err = Test-Connected if ($err) { return $err } # 'managementState' entfernt: kein gueltiges $select-Feld auf managedDevices (400). $select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,serialNumber,model,manufacturer,enrolledDateTime,imei,wiFiMacAddress,azureADDeviceId,joinType,deviceEnrollmentType,managedDeviceOwnerType,managementAgent,totalStorageSpaceInBytes,freeStorageSpaceInBytes' $d = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$DeviceId`?`$select=$select" -Method GET $totalGB = if ($d.totalStorageSpaceInBytes) { [math]::Round($d.totalStorageSpaceInBytes / 1GB, 1) } else { $null } $freeGB = if ($d.freeStorageSpaceInBytes) { [math]::Round($d.freeStorageSpaceInBytes / 1GB, 1) } else { $null } # Autopilot-Identity per Seriennummer nachladen (nur Windows, fehlertolerant). # HINWEIS: Ein echtes "Import-/Registrierungsdatum" liefert Graph nicht; verfuegbar # sind nur Profil-Zuweisung (deploymentProfileAssignedDateTime) und letzter Kontakt. $inAutopilot = $false $apProfileAssign = $null $apLastContact = $null $sn = [string]$d.serialNumber if ($sn -and ([string]$d.operatingSystem).ToLower() -eq 'windows') { try { $snEsc = $sn -replace "'", "''" $apFilt = [Uri]::EscapeDataString("contains(serialNumber,'$snEsc')") $apSel = 'id,serialNumber,deploymentProfileAssignedDateTime,lastContactedDateTime,enrollmentState' $apUri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=$apFilt&`$select=$apSel&`$top=1" $apResp = Invoke-MgGraphRequestRetry -Uri $apUri -Method GET $autop = @($apResp.value)[0] if ($autop) { $inAutopilot = $true $apProfileAssign = ConvertTo-IsoDate $autop.deploymentProfileAssignedDateTime $apLastContact = ConvertTo-IsoDate $autop.lastContactedDateTime } } catch { Write-Host " [DEVICE] Autopilot-Lookup (SN=$sn): $($_.Exception.Message)" -ForegroundColor DarkYellow } } return @{ Id = $d.id DeviceName = $d.deviceName UserDisplayName = $d.userDisplayName UserPrincipalName= $d.userPrincipalName OS = $d.operatingSystem OSVersion = $d.osVersion ComplianceState = $d.complianceState LastSync = ConvertTo-IsoDate $d.lastSyncDateTime ManagementState = $d.managementState ManagementAgent = [string]$d.managementAgent ManagementType = Get-ManagementType ([string]$d.managementAgent) SerialNumber = $d.serialNumber Model = $d.model Manufacturer = $d.manufacturer EnrolledDateTime = ConvertTo-IsoDate $d.enrolledDateTime Imei = $d.imei WiFiMac = $d.wiFiMacAddress AzureADDeviceId = $d.azureADDeviceId JoinType = $d.joinType EnrollmentType = $d.deviceEnrollmentType OwnerType = $d.managedDeviceOwnerType TotalStorageGB = $totalGB FreeStorageGB = $freeGB InAutopilot = $inAutopilot AutopilotProfileAssigned = $apProfileAssign AutopilotLastContacted = $apLastContact } } function Invoke-DeviceActionEndpoint { param($DeviceId, $Body) $err = Test-Connected if ($err) { return $err } $action = [string]$Body.action $allowed = @('syncDevice','rebootNow','remoteLock','collectDiagnostics','rotateBitLockerKeys','retire','autopilotReset','wipe') if ($action -notin $allowed) { return @{ statusCode = 400; error = "Unbekannte Aktion: $action" } } if ($action -eq 'autopilotReset') { $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$DeviceId/wipe" $bodyJson = '{"keepEnrollmentData":true,"keepUserData":false}' } elseif ($action -eq 'wipe') { $keepUserData = if ($Body.keepUserData) { 'true' } else { 'false' } $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$DeviceId/wipe" $bodyJson = "{""keepEnrollmentData"":false,""keepUserData"":$keepUserData}" } else { $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$DeviceId/$action" $bodyJson = '{}' } Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body $bodyJson -ContentType 'application/json' | Out-Null return @{ success = $true; action = $action } } # ============================================================ # 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-AppInstallReportEndpoint { # Verwendet den Intune Export-Job (AppInstallStatusAggregate). # Benoetigt: DeviceManagementApps.Read.All $err = Test-Connected if ($err) { return $err } Write-Host "[REPORT] Lade Apps fuer Report..." -ForegroundColor DarkCyan $rawApps = @(Get-GraphMobileApps) if ($rawApps.Count -eq 0) { return @{ items = @(); count = 0 } } Write-Host "[REPORT] Starte Export-Job (AppInstallStatusAggregate)..." -ForegroundColor DarkCyan $sw = [System.Diagnostics.Stopwatch]::StartNew() $summaries = @{} # appId -> Zaehler try { # 1. Export-Job anlegen — kein select damit falsche Spaltennamen keinen BadRequest ausloesen $jobJson = '{"reportName":"AppInstallStatusAggregate","filter":""}' $job = Invoke-MgGraphRequestRetry ` -Uri 'https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs' ` -Method POST -Body $jobJson -ContentType 'application/json' $jobId = $job.id Write-Host " [REPORT] Export-Job ID: $jobId" -ForegroundColor DarkGray # 2. Auf Fertigstellung warten (max. 120s) $status = $job.status $waited = 0 while ($status -ne 'completed' -and $status -ne 'failed' -and $waited -lt 120) { Start-Sleep -Seconds 3 $waited += 3 $job = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$jobId" $status = $job.status Write-Host " [REPORT] Status: $status ($waited s)" -ForegroundColor DarkGray } if ($status -ne 'completed') { throw "Export-Job nicht abgeschlossen (Status: $status nach $waited s)" } $downloadUrl = $job.url Write-Host " [REPORT] Download: $downloadUrl" -ForegroundColor DarkGray # 3. ZIP herunterladen und CSV parsen $tmpZip = [System.IO.Path]::GetTempFileName() + '.zip' $tmpDir = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "IntuneReport_$jobId") try { Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpZip -UseBasicParsing Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::ExtractToDirectory($tmpZip, $tmpDir) $csv = Get-ChildItem -Path $tmpDir -Filter '*.csv' | Select-Object -First 1 if (-not $csv) { throw "Keine CSV im Export-ZIP gefunden" } $rows = Import-Csv -Path $csv.FullName -Encoding UTF8 Write-Host " [REPORT] CSV: $($rows.Count) Zeilen, Spalten: $(($rows[0].PSObject.Properties.Name) -join ',')" -ForegroundColor DarkGray foreach ($row in $rows) { $aid = [string]$row.ApplicationId if (-not $aid) { continue } $instV = $row.InstalledDeviceCount; if (-not $instV) { $instV = 0 } $failV = $row.FailedDeviceCount; if (-not $failV) { $failV = 0 } $pendV = $row.PendingInstallDeviceCount; if (-not $pendV) { $pendV = 0 } $notInstV = $row.NotInstalledDeviceCount; if (-not $notInstV) { $notInstV = 0 } $notApplV = $row.NotApplicableDeviceCount;if (-not $notApplV) { $notApplV = 0 } $summaries[$aid] = @{ inst = [int]$instV fail = [int]$failV pend = [int]$pendV notInst = [int]$notInstV notAppl = [int]$notApplV } } Write-Host " [REPORT] $($summaries.Count) Apps mit Install-Daten" -ForegroundColor Green } finally { Remove-Item -Path $tmpZip -Force -ErrorAction SilentlyContinue Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue } } catch { $errMsg = $_.Exception.Message # Graph-PS-Modul steckt den Response-Body in $_.Exception.Response try { $stream = $_.Exception.Response.GetResponseStream() $reader = [System.IO.StreamReader]::new($stream) $body = $reader.ReadToEnd() if ($body) { $errMsg += " | Body: $body" } } catch {} try { if ($_.ErrorDetails.Message) { $errMsg += " | Details: $($_.ErrorDetails.Message)" } } catch {} Write-Host " [REPORT] Export-Job fehlgeschlagen: $errMsg" -ForegroundColor Yellow } $sw.Stop() Write-Host " -> $($rawApps.Count) Apps in $([int]$sw.Elapsed.TotalSeconds)s" -ForegroundColor Green $items = @($rawApps | ForEach-Object { $aid = [string]$_.id $s = $summaries[$aid] $ver = if ($_.buildNumber) { $_.buildNumber } elseif ($_.versionNumber) { $_.versionNumber } elseif ($_.version) { $_.version } else { '' } [pscustomobject]@{ AppId = $aid AppName = [string]$_.displayName AppType = ([string]$_.('@odata.type') -replace '#microsoft.graph.', '') Publisher = [string]$_.publisher Version = [string]$ver InstalledDeviceCount = if ($s) { $s.inst } else { 0 } FailedDeviceCount = if ($s) { $s.fail } else { 0 } PendingInstallDeviceCount = if ($s) { $s.pend } else { 0 } NotInstalledDeviceCount = if ($s) { $s.notInst } else { 0 } NotApplicableDeviceCount = if ($s) { $s.notAppl } else { 0 } } }) return @{ items = $items; count = $items.Count } } # Intune-Report via asynchronem Export-Job (exportJobs) -> CSV-Zeilen. # Kein 'select' (unbekannte Spalten wuerden 400 ausloesen) — der Aufrufer # greift tolerant auf die tatsaechlich gelieferten Spalten zu. function Get-IntuneReportRows { param( [Parameter(Mandatory=$true)][string]$ReportName, [string]$Filter = '', [int]$TimeoutSec = 120 ) $jobBody = @{ reportName = $ReportName; filter = $Filter } | ConvertTo-Json -Compress $job = Invoke-MgGraphRequestRetry ` -Uri 'https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs' ` -Method POST -Body $jobBody -ContentType 'application/json' $jobId = $job.id $status = $job.status $waited = 0 while ($status -ne 'completed' -and $status -ne 'failed' -and $waited -lt $TimeoutSec) { Start-Sleep -Seconds 3 $waited += 3 $job = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$jobId" $status = $job.status } if ($status -ne 'completed') { throw "Export-Job '$ReportName' nicht abgeschlossen (Status: $status nach $waited s)" } $tmpZip = [System.IO.Path]::GetTempFileName() + '.zip' $tmpDir = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "IntuneReport_$jobId") try { Invoke-WebRequest -Uri $job.url -OutFile $tmpZip -UseBasicParsing Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::ExtractToDirectory($tmpZip, $tmpDir) $csv = Get-ChildItem -Path $tmpDir -Filter '*.csv' | Select-Object -First 1 if (-not $csv) { throw "Keine CSV im Export-ZIP gefunden" } return @(Import-Csv -Path $csv.FullName -Encoding UTF8) } finally { Remove-Item -Path $tmpZip -Force -ErrorAction SilentlyContinue Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue } } function Get-AppDeviceStatusEndpoint { # Per-Device-Installationsstatus einer App via Export-Job 'DeviceInstallStatusByApp'. # (Der frueher genutzte synchrone Endpoint getDeviceInstallStatusReport existiert # nicht mehr -> 400 "Resource not found for the segment".) param([hashtable]$Query) $err = Test-Connected if ($err) { return $err } $appId = [string]$Query['appId'] if ([string]::IsNullOrWhiteSpace($appId)) { return @{ __status = 400; error = "appId fehlt" } } Write-Host "[DEVSTATUS] Export-Job (DeviceInstallStatusByApp) fuer App $appId..." -ForegroundColor DarkCyan try { $rows = @(Get-IntuneReportRows -ReportName 'DeviceInstallStatusByApp' -Filter "(ApplicationId eq '$appId')") } catch { $msg = $_.Exception.Message try { if ($_.ErrorDetails.Message) { $msg = $_.ErrorDetails.Message } } catch {} return @{ __status = 500; error = "Graph-Fehler: $msg" } } Write-Host " -> $($rows.Count) Eintraege" -ForegroundColor DarkGray if ($rows.Count -eq 0) { return @{ items = @(); count = 0 } } Write-Host " [DEVSTATUS] Spalten: $(($rows[0].PSObject.Properties.Name) -join ',')" -ForegroundColor DarkGray # Spaltennamen des Reports koennen variieren -> tolerant mit Fallbacks lesen. $col = { param($row, [string[]]$names) foreach ($n in $names) { $p = $row.PSObject.Properties[$n] if ($p -and $null -ne $p.Value -and [string]$p.Value -ne '') { return [string]$p.Value } } return '' } $result = @($rows | ForEach-Object { $r = $_ [pscustomobject]@{ DeviceName = & $col $r @('DeviceName') UserName = & $col $r @('UserName','UserPrincipalName') InstallState = & $col $r @('InstallState_loc','InstallState','AppInstallState_loc','AppInstallState') InstallStateDetail = & $col $r @('InstallStateDetail_loc','InstallStateDetail','AppInstallStateDetail_loc','AppInstallStateDetail') ErrorCode = & $col $r @('ErrorCode','HexErrorCode') OsVersion = & $col $r @('OSVersion','OsVersion','Platform') LastSyncDateTime = & $col $r @('LastModifiedDateTime','LastSyncDateTime') } }) return @{ items = $result; count = $result.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 } } function Show-OpenFolderDialog { # Wie Show-OpenFileDialog, aber FolderBrowserDialog in einem STA-Prozess. param([string]$Title = "Ordner auswaehlen") $tEsc = $Title -replace "'", "''" $inner = @" `$ProgressPreference = 'SilentlyContinue' Add-Type -AssemblyName System.Windows.Forms `$dlg = New-Object System.Windows.Forms.FolderBrowserDialog `$dlg.Description = '$tEsc' `$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.SelectedPath) } "@ $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 "Ordner-Dialog-Prozess konnte nicht gestartet werden: $($_.Exception.Message)" } return ([string]$path).Trim() } function Invoke-FolderPickerEndpoint { param($Body) $title = if ($Body -and $Body.title) { [string]$Body.title } else { "Git-Repo-Ordner auswaehlen" } try { $path = Show-OpenFolderDialog -Title $title } catch { return @{ __status = 500; error = "Ordner-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" } } # 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 / )" } } # 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 targets=$($targets -join ', ')" -ForegroundColor Yellow try { # 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 {} $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(en) im App-Eintrag ergaenzen. # (Das Frontend laedt danach ohnehin neu; das haelt die UI aber sofort konsistent.) if ($cachedApp) { $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 } } } 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 { 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
Intune Manager · Web Edition v$(_enc $script:ToolVersion) · Build $(_enc $script:BuildStamp)
"@ [IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8) return $path } # HTML-Report fuers Geraete-Offboarding — pro Geraet die ausgefuehrten Loeschungen # (Entra/Intune/Autopilot) mit Status + Meldung. Liegt in Api.ps1 (BOM) wegen Umlauten. function New-OffboardHtmlReport { param($Devices, $Results, [int]$Success, [int]$Errors, [int]$Total, [string]$EntraAction, [bool]$DoIntune, [bool]$DoAutopilot) Add-Type -AssemblyName System.Web function _e { 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 = "offboard-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 { "" } $devCount = @($Devices).Count $bannerCls = if ($Errors -gt 0) { "banner-err" } else { "banner-ok" } $bannerTxt = if ($Errors -gt 0) { "$Errors von $Total Aktion(en) fehlgeschlagen - Details unten" } else { "Alle $Success Aktion(en) erfolgreich" } # Dienst-Zusammenfassung (was angefordert wurde) $svcParts = @() if ($EntraAction -eq 'delete') { $svcParts += "Entra ID: geloescht" } elseif ($EntraAction -eq 'disable') { $svcParts += "Entra ID: deaktiviert" } if ($DoIntune) { $svcParts += "Intune: geloescht" } if ($DoAutopilot) { $svcParts += "Autopilot: geloescht" } $svcTxt = if ($svcParts.Count -gt 0) { ($svcParts -join " · ") } else { "keine" } # Ergebnisse pro Geraet gruppieren (Reihenfolge = Geraeteliste) $byDev = [ordered]@{} foreach ($d in @($Devices)) { $dn = [string]$d.deviceName; if ($dn -and -not $byDev.Contains($dn)) { $byDev[$dn] = @() } } foreach ($r in @($Results)) { $dn = [string]$r.deviceName if (-not $byDev.Contains($dn)) { $byDev[$dn] = @() } $byDev[$dn] += $r } $devBlocks = "" foreach ($dn in @($byDev.Keys)) { $rows = @($byDev[$dn]) $okC = @($rows | Where-Object { $_.success }).Count $errC = @($rows | Where-Object { -not $_.success }).Count $rowsHtml = "" foreach ($r in $rows) { $cls = if ($r.success) { "ok" } else { "err" } $ico = if ($r.success) { "✓" } else { "!" } $lbl = if ($r.success) { "Erfolg" } else { "Fehler" } $msg = if ($r.error) { _e $r.error } else { "" } $rowsHtml += "" + "$ico $lbl" + "$(_e $r.service)" + "$(_e $r.action)" + "$msg" } if (-not $rowsHtml) { $rowsHtml = "Keine ausgefuehrten Aktionen." } $stats = "" if ($okC -gt 0) { $stats += "$okC OK" } if ($errC -gt 0) { $stats += "$errC Fehler" } $devBlocks += "
" + "$(_e $dn)$stats" + "
" + "" + "$rowsHtml
StatusDienstAktionMeldung
" } if (-not $devBlocks) { $devBlocks = "
Keine Ergebnisse.
" } $html = @" Intune Offboarding-Report - $tsHuman
Intune Offboarding-Report
$tsHuman · ausgefuehrt von $(_e $user)
"@ if ($account) { $html += "Account $(_e $account)" } if ($tenant) { $html += "Tenant $(_e $tenant)" } $html += "Host $(_e $machine)" $html += @"
$devCount
Geraete
$Total
Aktionen
$Success
Erfolgreich
$Errors
Fehler
Angeforderte Dienste
$svcTxt
Ergebnisse pro Geraet
Jeder Block zeigt die ausgefuehrten Loeschungen/Aenderungen mit Status und Meldung.
$devBlocks
"@ [IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8) return $path }