Files
Intune-Manager/src/Api.ps1
T
marcoandClaude Opus 4.8 de32f6201c Update-Check: Proxy-Authentifizierung unterstützen
Hinter authentifizierten Unternehmens-Proxys (NTLM/Kerberos) scheiterte der
Update-Check mit HTTP 407, weil Invoke-RestMethod keine Anmeldeinformationen
an den Proxy sendet. Jetzt werden die angemeldeten Windows-Credentials an den
System-Proxy durchgereicht; optionaler expliziter Proxy via settings.json ->
update.proxy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-14 10:59:02 +02:00

3924 lines
179 KiB
PowerShell

# 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/<id>/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<B, 0 gleich. Fuehrendes 'v' und
# Pre-Release/Build-Suffixe werden ignoriert; fehlende Teile zaehlen als 0.
function Compare-SemVer {
param([string]$A, [string]$B)
$clean = {
param($s)
$s = ([string]$s).Trim().TrimStart('v','V')
$s = ($s -split '[-+ ]')[0]
@($s -split '\.' | ForEach-Object { [int]([regex]::Match($_, '\d+').Value -as [int]) })
}
$pa = & $clean $A
$pb = & $clean $B
$len = [Math]::Max($pa.Count, $pb.Count)
for ($i = 0; $i -lt $len; $i++) {
$x = if ($i -lt $pa.Count) { [int]$pa[$i] } else { 0 }
$y = if ($i -lt $pb.Count) { [int]$pb[$i] } else { 0 }
if ($x -gt $y) { return 1 }
if ($x -lt $y) { return -1 }
}
return 0
}
# Prueft die neueste Release-Version. Quelle standardmaessig das Gitea-Repo;
# ueber Settings.update.apiUrl / .token ueberschreibbar (falls Repo privat).
# Braucht KEINE Graph-Verbindung. Fehler werden still zurueckgegeben (kein Banner).
function Get-UpdateCheckEndpoint {
$current = [string]$script:ToolVersion
$out = @{ current = $current; latest = ''; updateAvailable = $false; releaseUrl = ''; error = '' }
$api = 'https://git.wende.it/api/v1/repos/marco/Intune-Manager/releases/latest'
$token = ''
try {
if ($script:Settings.update) {
if ($script:Settings.update.apiUrl) { $api = [string]$script:Settings.update.apiUrl }
if ($script:Settings.update.token) { $token = [string]$script:Settings.update.token }
}
} catch {}
$headers = @{ 'User-Agent' = 'IntuneManager' }
if ($token) { $headers['Authorization'] = "token $token" }
try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch {}
# Proxy: Hinter authentifizierten Unternehmens-Proxys (NTLM/Kerberos) scheitert
# Invoke-RestMethod sonst mit "407 Proxyauthentifizierung erforderlich", weil
# standardmaessig keine Anmeldeinformationen an den Proxy gesendet werden. Die
# angemeldeten Windows-Credentials an den (System-)Proxy durchreichen. Optional
# laesst sich ein expliziter Proxy ueber Settings.update.proxy setzen.
$proxyArgs = @{}
try {
$proxyUrl = ''
if ($script:Settings.update -and $script:Settings.update.proxy) { $proxyUrl = [string]$script:Settings.update.proxy }
if ($proxyUrl) {
$proxyArgs['Proxy'] = $proxyUrl
$proxyArgs['ProxyUseDefaultCredentials'] = $true
} elseif ([System.Net.WebRequest]::DefaultWebProxy) {
[System.Net.WebRequest]::DefaultWebProxy.Credentials = [System.Net.CredentialCache]::DefaultCredentials
}
} catch {}
try {
$rel = Invoke-RestMethod -Uri $api -Headers $headers -Method GET -TimeoutSec 8 @proxyArgs
$latest = ([string]$rel.tag_name).TrimStart('v','V')
$out.latest = $latest
$out.releaseUrl = [string]$rel.html_url
$out.updateAvailable = ((Compare-SemVer $latest $current) -gt 0)
} catch {
$out.error = $_.Exception.Message
Write-Host "[UPDATE] Versions-Check fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkGray
}
return $out
}
# ============================================================
# Status & Connection
# ============================================================
function Get-ConfigEndpoint {
return @{
tenantId = $script:Config.TenantId
clientId = $script:Config.ClientId
scopes = $script:Config.Scopes
}
}
# ============================================================
# Settings: lesen / schreiben / zuruecksetzen
# ============================================================
function Get-SettingsEndpoint {
# cacheTag fuer das Logo: nutzt File-Mtime — so kann das Frontend den
# Browser-Cache zuverlaessig brechen, unabhaengig davon ob das Logo gerade
# erst hochgeladen oder die Seite frisch geladen wurde.
$logoCacheTag = $null
if ($script:Settings.branding -and $script:Settings.branding.logoFile) {
$logoPath = Join-Path (Get-BrandingDir) $script:Settings.branding.logoFile
if (Test-Path $logoPath -PathType Leaf) {
$logoCacheTag = [DateTimeOffset]::new((Get-Item $logoPath).LastWriteTimeUtc).ToUnixTimeSeconds()
} else {
# Datei verschwunden -> Setting zuruecksetzen damit das Frontend
# nicht versucht ein 404-Image zu rendern
$script:Settings.branding.logoFile = $null
}
}
# Klon des Settings-Objekts mit branding.logoCacheTag — damit kein
# zusaetzlicher Outer-Key noetig ist und das Frontend einheitlich nur
# 'branding' liest.
$settings = $script:Settings | ConvertTo-Json -Depth 10 | ConvertFrom-Json
if (-not $settings.branding) {
$settings | Add-Member -NotePropertyName 'branding' -NotePropertyValue ([pscustomobject]@{ logoFile = $null; logoCacheTag = $null }) -Force
} else {
$settings.branding | Add-Member -NotePropertyName 'logoCacheTag' -NotePropertyValue $logoCacheTag -Force
}
return @{ settings = $settings; path = (Get-SettingsPath) }
}
function Sync-ConfigFromSettings {
# $script:Config spiegelt die settings.connection-Werte. Wird nach jedem
# Save aufgerufen damit Connect-Aufrufe sofort die neuen IDs verwenden.
# 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/<name>-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 '<id>' 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"|"<group-guid>" }
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 / <group-guid>)" }
}
# 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 = "&#10003;"; label = "Erfolgreich" }
"AlreadyMember" = @{ cls = "ok"; icon = "&#10003;"; label = "Bereits Mitglied" }
"Skipped" = @{ cls = "skip"; icon = "&#8211;"; label = "Uebersprungen" }
"Error" = @{ cls = "err"; icon = "&#33;"; 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) { " <span class='code'>$(_enc $d.code)</span>" } else { "" }
$rowsHtml += "<tr class='row-$($st.cls)' data-status='$($st.cls)'>" +
"<td class='c-status'><span class='status-pill status-$($st.cls)'>$($st.icon) $($st.label)</span></td>" +
"<td class='c-target'>$(_enc $d.target)</td>" +
"<td class='c-group'><code>$(_enc $d.group)</code></td>" +
"<td class='c-type'><span class='type-tag type-$([string]$d.type | ForEach-Object { $_.ToLower() })'>$(_enc $d.type)</span></td>" +
"<td class='c-msg'>$msg$errCode</td>" +
"</tr>"
}
$appHeadStats = ""
if ($okCount -gt 0) { $appHeadStats += "<span class='hd-pill hd-ok'>$okCount OK</span>" }
if ($skipCount -gt 0) { $appHeadStats += "<span class='hd-pill hd-warn'>$skipCount Skip</span>" }
if ($errCount -gt 0) { $appHeadStats += "<span class='hd-pill hd-err'>$errCount Fehler</span>" }
$openAttr = if ($errCount -gt 0) { " open" } else { "" }
$appBlocksHtml += "<details class='app-block'$openAttr><summary class='app-head'>" +
"<span class='app-name'>$(_enc $appName)</span>" +
"<span class='app-stats'>$appHeadStats</span>" +
"</summary>" +
"<div class='app-body'><table class='res-table'><thead><tr>" +
"<th class='c-status'>Status</th><th class='c-target'>Empfaenger</th>" +
"<th class='c-group'>Intune-Gruppe</th><th class='c-type'>Typ</th>" +
"<th class='c-msg'>Meldung</th></tr></thead>" +
"<tbody>$rowsHtml</tbody></table></div></details>"
}
if (-not $appBlocksHtml) {
$appBlocksHtml = "<div class='empty'>Keine Detail-Eintraege.</div>"
}
# 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 += "<a class='$cls' href='$entraUrl' target='_blank' rel='noopener' title='Im Portal oeffnen'>" +
"<span class='chip-mode'>$modeLbl</span>" +
"<span class='chip-name'>$(_enc $t.displayName)</span></a>"
}
# 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 += "<div class='assign-card'>" +
"<a class='assign-app' href='$intuneUrl' target='_blank' rel='noopener'>$(_enc $a.appName)</a>" +
"<div class='assign-meta'>" +
"<span class='intent-tag intent-$intent'>$(_enc $intentLbl)</span>" +
"<code class='assign-grp'>$(_enc $a.groupName)</code>" +
"</div></div>"
}
if (-not $assignChipsHtml) { $assignChipsHtml = "<div class='empty'>Keine Zuweisungen.</div>" }
$logHtml = ($Log | ForEach-Object {
$line = _enc $_
if ($line -like "`[OK`]*") { "<span class='log-ok'>$line</span>" }
elseif ($line -like "`[OK*`]*") { "<span class='log-ok'>$line</span>" }
elseif ($line -like "`[FAIL`]*") { "<span class='log-err'>$line</span>" }
elseif ($line -like "`[SKIP`]*") { "<span class='log-skip'>$line</span>" }
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 = @"
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Intune Zuweisungs-Report - $tsHuman</title>
<link rel="preconnect" href="https://rsms.me/">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<style>
:root{
/* Shades of black & white — gleiche blau-getoente Skala wie im Tool */
--black-100:#0A0E15; --black-90:#212631; --black-80:#373F4E; --black-70:#4E576A; --black-60:#667085;
--white-100:#FFFFFF; --white-90:#F0F1F5; --white-80:#E0E4EB; --white-70:#D1D6E0; --white-60:#BFC6D4;
/* Primary blue — fuer Interactive / Akzent */
--blue-100:#174082; --blue-80:#2252A0; --blue-60:#3566B6;
--blue-20:#B6CFF7; --blue-15:#CADEFC; --blue-10:#E0ECFF;
/* Semantic — 100 fuer Text auf hellem Hintergrund, 60 fuer dezente Akzente, 10 fuer Backgrounds */
--success-100:#008072; --success-60:#A0E3D8; --success-10:#D0FBF5;
--error-100:#B21919; --error-60:#EA9E9E; --error-10:#FBE0E0;
--warning-100:#8F7200; --warning-60:#F8E081; --warning-10:#FFF3C2;
/* Context — fuer Mode/Type-Badges */
--teal-100:#00665B; --teal-60:#2C968C; --teal-10:#D4F7F2;
--purple-100:#5B2A82; --purple-60:#8B5CF6; --purple-10:#EDE7FE;
--pink-100:#9D174D; --pink-60:#EC4899; --pink-10:#FCE7F3;
/* Surfaces */
--bg:#F7FAFD;
--surface:var(--white-100);
--surface-soft:#F4F7FB;
--surface-strong:var(--white-80);
--hairline:var(--white-70);
--hairline-soft:var(--white-90);
/* Text — drei dedizierte Werte, mehr nutzen wir nicht */
--text-primary:var(--black-100); /* Headlines, App-Namen */
--text-secondary:var(--black-80); /* Body, Tabellen */
--text-muted:var(--black-60); /* Labels, Captions */
/* Geometry */
--r-sm:6px; --r-md:10px; --r-lg:14px;
--tx:0.15s ease-out;
--shadow-soft:0 1px 2px rgba(23,64,130,0.04), 0 2px 6px rgba(23,64,130,0.04);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{font-family:'Inter',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;font-feature-settings:'cv02','cv03','cv04','cv11';background:var(--bg);color:var(--text-secondary);line-height:1.5;-webkit-font-smoothing:antialiased}
body{padding:32px 16px 80px 16px}
.container{max-width:1180px;margin:0 auto}
::selection{background:var(--blue-15);color:var(--text-primary)}
a{color:inherit}
/* Header */
.head{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-lg);padding:24px 28px;margin-bottom:20px;box-shadow:var(--shadow-soft)}
.head-top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px}
.brand{display:flex;align-items:center;gap:12px}
.logo{width:40px;height:40px;border-radius:9px;background:var(--blue-60);color:var(--white-100);display:grid;place-items:center;font-size:20px;font-weight:700;letter-spacing:-0.02em}
.title{font-size:22px;font-weight:700;letter-spacing:-0.015em;color:var(--text-primary)}
.subtitle{font-size:13px;color:var(--text-muted);margin-top:2px}
.head-meta{display:flex;flex-wrap:wrap;gap:6px}
.meta-pill{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;background:var(--surface-soft);border:1px solid var(--hairline);padding:5px 10px;border-radius:999px;color:var(--text-secondary);font-weight:500}
.meta-pill .lbl{color:var(--text-muted);text-transform:uppercase;font-size:10px;letter-spacing:0.04em;font-weight:600}
.meta-pill code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
/* Banner */
.banner{margin-top:18px;padding:12px 16px;border-radius:var(--r-md);font-size:13.5px;font-weight:500;display:flex;align-items:center;gap:10px;border:1px solid transparent}
.banner-ok {background:var(--success-10); border-color:var(--success-60); color:var(--success-100)}
.banner-warn{background:var(--warning-10); border-color:var(--warning-60); color:var(--warning-100)}
.banner-err {background:var(--error-10); border-color:var(--error-60); color:var(--error-100)}
.banner-icon{width:22px;height:22px;border-radius:50%;display:inline-grid;place-items:center;font-size:13px;font-weight:700}
.banner-ok .banner-icon{background:var(--success-60);color:var(--success-100)}
.banner-warn .banner-icon{background:var(--warning-60);color:var(--warning-100)}
.banner-err .banner-icon{background:var(--error-60); color:var(--error-100)}
/* KPI Strip */
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:20px}
.kpi{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-md);padding:18px 18px 16px 18px;display:flex;flex-direction:column;gap:6px}
.kpi-num{font-size:30px;font-weight:700;line-height:1;letter-spacing:-0.02em;font-variant-numeric:tabular-nums;color:var(--text-primary)}
.kpi-lbl{font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;font-weight:600}
.kpi-sub{font-size:11.5px;color:var(--text-muted);margin-top:2px}
.kpi.ok .kpi-num{color:var(--success-100)}
.kpi.warn .kpi-num{color:var(--warning-100)}
.kpi.err .kpi-num{color:var(--error-100)}
.kpi.rate-ok .kpi-num{color:var(--success-100)}
.kpi.rate-warn .kpi-num{color:var(--warning-100)}
.kpi.rate-err .kpi-num{color:var(--error-100)}
.kpi.rate-muted .kpi-num{color:var(--text-muted)}
/* Sections */
.section{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-lg);padding:22px 24px;margin-bottom:18px;box-shadow:var(--shadow-soft)}
.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}
.section-title{font-size:15px;font-weight:600;letter-spacing:-0.005em;color:var(--text-primary)}
.section-sub{font-size:12px;color:var(--text-muted)}
/* Recipients & Assignments */
.chip-grid{display:flex;flex-wrap:wrap;gap:6px}
.chip{display:inline-flex;align-items:center;gap:8px;padding:5px 10px 5px 6px;border-radius:999px;border:1px solid var(--hairline);background:var(--surface);text-decoration:none;color:var(--text-secondary);font-size:12.5px;transition:all var(--tx)}
.chip:hover{border-color:var(--blue-60);background:var(--blue-10);color:var(--text-primary)}
.chip-mode{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;padding:1px 6px;border-radius:6px}
.chip-group .chip-mode{background:var(--teal-10);color:var(--teal-100)}
.chip-user .chip-mode{background:var(--purple-10);color:var(--purple-100)}
.assign-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px}
.assign-card{border:1px solid var(--hairline);border-radius:var(--r-md);padding:12px 14px;background:var(--surface);display:flex;flex-direction:column;gap:6px;transition:all var(--tx)}
.assign-card:hover{border-color:var(--blue-20);background:var(--blue-10)}
.assign-app{font-size:13.5px;font-weight:600;color:var(--text-primary);text-decoration:none}
.assign-app:hover{color:var(--blue-80);text-decoration:underline;text-underline-offset:2px}
.assign-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.intent-tag{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;padding:2px 7px;border-radius:6px}
.intent-tag.intent-avail{background:var(--blue-10);color:var(--blue-80)}
.intent-tag.intent-req {background:var(--pink-10);color:var(--pink-100)}
.assign-grp{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--text-muted);background:var(--surface-soft);padding:1px 6px;border-radius:4px;border:1px solid var(--hairline-soft)}
/* App-grouped results */
.app-block{border:1px solid var(--hairline);border-radius:var(--r-md);margin-bottom:8px;background:var(--surface);overflow:hidden;transition:border-color var(--tx)}
.app-block[open]{border-color:var(--blue-20)}
.app-head{cursor:pointer;display:flex;align-items:center;justify-content:space-between;padding:12px 16px;list-style:none;user-select:none;gap:12px}
.app-head::-webkit-details-marker{display:none}
.app-head::before{content:'\203A';display:inline-block;color:var(--text-muted);font-size:18px;line-height:1;margin-right:6px;transition:transform var(--tx)}
.app-block[open] .app-head::before{transform:rotate(90deg);color:var(--blue-60)}
.app-head:hover{background:var(--surface-soft)}
.app-block[open] .app-head{background:var(--blue-10)}
.app-name{flex:1;font-size:13.5px;font-weight:600;letter-spacing:-0.005em;min-width:0;word-break:break-word;color:var(--text-primary)}
.app-stats{display:flex;gap:5px;flex-wrap:wrap}
.hd-pill{font-size:10.5px;font-weight:600;padding:2px 8px;border-radius:999px;letter-spacing:0.02em}
.hd-ok {background:var(--success-10);color:var(--success-100)}
.hd-warn {background:var(--warning-10);color:var(--warning-100)}
.hd-err {background:var(--error-10); color:var(--error-100)}
.app-body{border-top:1px solid var(--hairline-soft)}
table.res-table{width:100%;border-collapse:collapse;font-size:12.5px}
.res-table th{text-align:left;padding:8px 14px;background:var(--surface-soft);color:var(--text-muted);text-transform:uppercase;font-size:10.5px;letter-spacing:0.04em;font-weight:600;border-bottom:1px solid var(--hairline)}
.res-table td{padding:10px 14px;border-bottom:1px solid var(--hairline-soft);vertical-align:top;color:var(--text-secondary)}
.res-table tr:last-child td{border-bottom:none}
.res-table tr:hover td{background:var(--surface-soft)}
.c-status{width:170px;white-space:nowrap}
.c-type{width:100px}
.c-target{color:var(--text-primary)}
.c-group{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;color:var(--text-muted)}
.c-group code{font-family:inherit;font-size:inherit}
.c-msg{color:var(--text-muted)}
.c-msg .code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:var(--error-10);color:var(--error-100);padding:1px 5px;border-radius:4px;font-size:11px;margin-left:6px;border:1px solid var(--error-60)}
.row-err td{background:var(--error-10)}
.row-err:hover td{background:#F7CECE}
.row-skip td{background:var(--warning-10)}
.row-skip:hover td{background:#FFEAA1}
.status-pill{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:3px 9px;border-radius:999px;letter-spacing:0.01em;border:1px solid transparent}
.status-ok {background:var(--success-10);color:var(--success-100);border-color:var(--success-60)}
.status-warn,
.status-skip {background:var(--warning-10);color:var(--warning-100);border-color:var(--warning-60)}
.status-err {background:var(--error-10); color:var(--error-100); border-color:var(--error-60)}
.type-tag{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;padding:2px 7px;border-radius:6px}
.type-tag.type-available{background:var(--blue-10);color:var(--blue-80)}
.type-tag.type-required {background:var(--pink-10);color:var(--pink-100)}
/* Log */
details.log-block{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-md);overflow:hidden}
details.log-block summary{cursor:pointer;padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-primary);list-style:none;display:flex;align-items:center;gap:8px}
details.log-block summary::-webkit-details-marker{display:none}
details.log-block summary::before{content:'\203A';display:inline-block;color:var(--text-muted);font-size:18px;line-height:1;margin-right:6px;transition:transform var(--tx)}
details.log-block[open] summary::before{transform:rotate(90deg)}
details.log-block summary:hover{background:var(--surface-soft)}
details.log-block pre{margin:0;border-top:1px solid var(--hairline-soft);background:var(--surface-soft);color:var(--text-secondary);padding:14px 18px;overflow-x:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;line-height:1.6;white-space:pre-wrap;word-break:break-word}
.log-ok {color:var(--success-100)}
.log-err {color:var(--error-100)}
.log-skip{color:var(--warning-100)}
/* Empty */
.empty{padding:18px;text-align:center;color:var(--text-muted);font-size:13px;background:var(--surface-soft);border-radius:var(--r-md);border:1px dashed var(--hairline)}
footer{text-align:center;color:var(--text-muted);font-size:11.5px;margin-top:24px}
footer code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:var(--surface);padding:1px 6px;border-radius:4px;color:var(--text-secondary);border:1px solid var(--hairline)}
@media (max-width: 720px){
.head-top{flex-direction:column;align-items:flex-start}
.c-status{width:auto}
.c-type{width:auto}
}
@media print{
body{background:var(--white-100);padding:0}
.section,.head,.kpi,.app-block,details.log-block{box-shadow:none;break-inside:avoid}
details:not([open]) > *:not(summary){display:block !important}
details > summary::before{display:none !important}
}
</style>
</head>
<body>
<div class="container">
<header class="head">
<div class="head-top">
<div class="brand">
<div class="logo">I</div>
<div>
<div class="title">Intune Zuweisungs-Report</div>
<div class="subtitle">$tsHuman &middot; ausgefuehrt von $(_enc $user)</div>
</div>
</div>
<div class="head-meta">
"@
if ($account) { $html += "<span class='meta-pill'><span class='lbl'>Account</span> $(_enc $account)</span>" }
if ($tenant) { $html += "<span class='meta-pill'><span class='lbl'>Tenant</span> <code>$(_enc $tenant)</code></span>" }
$html += "<span class='meta-pill'><span class='lbl'>Host</span> $(_enc $machine)</span>"
$html += @"
</div>
</div>
<div class="banner $bannerCls">
<span class="banner-icon">$(if ($Errors -gt 0) { '!' } elseif ($Skipped -gt 0 -and $Success -gt 0) { '~' } else { '&#10003;' })</span>
<span>$bannerTxt</span>
</div>
</header>
<div class="kpis">
<div class="kpi tot"><div class="kpi-num">$Total</div><div class="kpi-lbl">Vorgaenge</div></div>
<div class="kpi ok"><div class="kpi-num">$Success</div><div class="kpi-lbl">Erfolgreich</div></div>
<div class="kpi warn"><div class="kpi-num">$Skipped</div><div class="kpi-lbl">Uebersprungen</div></div>
<div class="kpi err"><div class="kpi-num">$Errors</div><div class="kpi-lbl">Fehler</div></div>
<div class="kpi rate-$rateStatus"><div class="kpi-num">$rate%</div><div class="kpi-lbl">Erfolgsquote</div><div class="kpi-sub">$Success von $effective effektiv</div></div>
</div>
<div class="section">
<div class="section-head">
<div>
<div class="section-title">Empfaenger</div>
<div class="section-sub">$($targetGroups.Count) Gruppe$grpSuffix &middot; $($targetUsers.Count) Benutzer</div>
</div>
</div>
<div class="chip-grid">$recipChipsHtml</div>
</div>
<div class="section">
<div class="section-head">
<div>
<div class="section-title">Zuweisungen</div>
<div class="section-sub">$($Assignments.Count) eindeutige App-Gruppen-Zuweisung$assignSuffix</div>
</div>
</div>
<div class="assign-grid">$assignChipsHtml</div>
</div>
<div class="section">
<div class="section-head">
<div>
<div class="section-title">Detail-Ergebnisse</div>
<div class="section-sub">Gruppiert nach App &middot; Bloecke mit Fehlern sind aufgeklappt</div>
</div>
</div>
$appBlocksHtml
</div>
<details class="log-block">
<summary>Debug-Log <span style="color:var(--muted);font-weight:400">($($Log.Count) Zeilen)</span></summary>
<pre>$logHtml</pre>
</details>
<footer>
Intune Manager &middot; Web Edition <code>v$(_enc $script:ToolVersion)</code> &middot; Build <code>$(_enc $script:BuildStamp)</code>
</footer>
</div>
</body>
</html>
"@
[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 " &middot; ") } 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) { "&#10003;" } else { "&#33;" }
$lbl = if ($r.success) { "Erfolg" } else { "Fehler" }
$msg = if ($r.error) { _e $r.error } else { "" }
$rowsHtml += "<tr class='row-$cls'>" +
"<td class='c-status'><span class='status-pill status-$cls'>$ico $lbl</span></td>" +
"<td class='c-svc'>$(_e $r.service)</td>" +
"<td class='c-act'>$(_e $r.action)</td>" +
"<td class='c-msg'>$msg</td></tr>"
}
if (-not $rowsHtml) { $rowsHtml = "<tr><td colspan='4' class='c-msg'>Keine ausgefuehrten Aktionen.</td></tr>" }
$stats = ""
if ($okC -gt 0) { $stats += "<span class='hd-pill hd-ok'>$okC OK</span>" }
if ($errC -gt 0) { $stats += "<span class='hd-pill hd-err'>$errC Fehler</span>" }
$devBlocks += "<details class='dev-block' open><summary class='dev-head'>" +
"<span class='dev-name'>$(_e $dn)</span><span class='dev-stats'>$stats</span></summary>" +
"<div class='dev-body'><table class='res-table'><thead><tr>" +
"<th class='c-status'>Status</th><th class='c-svc'>Dienst</th><th class='c-act'>Aktion</th><th class='c-msg'>Meldung</th>" +
"</tr></thead><tbody>$rowsHtml</tbody></table></div></details>"
}
if (-not $devBlocks) { $devBlocks = "<div class='empty'>Keine Ergebnisse.</div>" }
$html = @"
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Intune Offboarding-Report - $tsHuman</title>
<link rel="preconnect" href="https://rsms.me/">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<style>
:root{
--black-100:#0A0E15;--black-80:#373F4E;--black-60:#667085;
--white-100:#FFFFFF;--white-90:#F0F1F5;--white-80:#E0E4EB;--white-70:#D1D6E0;
--blue-60:#3566B6;--blue-10:#E0ECFF;
--success-100:#008072;--success-60:#A0E3D8;--success-10:#D0FBF5;
--error-100:#B21919;--error-60:#EA9E9E;--error-10:#FBE0E0;
--warning-100:#8F7200;--warning-60:#F8E081;--warning-10:#FFF3C2;
--bg:#F7FAFD;--surface:#FFF;--surface-soft:#F4F7FB;--hairline:var(--white-70);--hairline-soft:var(--white-90);
--text-primary:var(--black-100);--text-secondary:var(--black-80);--text-muted:var(--black-60);
--r-sm:6px;--r-md:10px;--r-lg:14px;--shadow-soft:0 1px 2px rgba(23,64,130,.04),0 2px 6px rgba(23,64,130,.04);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{font-family:'Inter',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--bg);color:var(--text-secondary);line-height:1.5;-webkit-font-smoothing:antialiased}
body{padding:32px 16px 80px}
.container{max-width:1100px;margin:0 auto}
.head{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-lg);padding:24px 28px;margin-bottom:20px;box-shadow:var(--shadow-soft)}
.head-top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px}
.brand{display:flex;align-items:center;gap:12px}
.logo{width:40px;height:40px;border-radius:9px;background:var(--blue-60);color:#fff;display:grid;place-items:center;font-size:20px;font-weight:700}
.title{font-size:22px;font-weight:700;letter-spacing:-.015em;color:var(--text-primary)}
.subtitle{font-size:13px;color:var(--text-muted);margin-top:2px}
.head-meta{display:flex;flex-wrap:wrap;gap:6px}
.meta-pill{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;background:var(--surface-soft);border:1px solid var(--hairline);padding:5px 10px;border-radius:999px;color:var(--text-secondary);font-weight:500}
.meta-pill .lbl{color:var(--text-muted);text-transform:uppercase;font-size:10px;letter-spacing:.04em;font-weight:600}
.meta-pill code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px}
.banner{margin-top:18px;padding:12px 16px;border-radius:var(--r-md);font-size:13.5px;font-weight:500;display:flex;align-items:center;gap:10px;border:1px solid transparent}
.banner-ok{background:var(--success-10);border-color:var(--success-60);color:var(--success-100)}
.banner-err{background:var(--error-10);border-color:var(--error-60);color:var(--error-100)}
.banner-icon{width:22px;height:22px;border-radius:50%;display:inline-grid;place-items:center;font-size:13px;font-weight:700}
.banner-ok .banner-icon{background:var(--success-60);color:var(--success-100)}
.banner-err .banner-icon{background:var(--error-60);color:var(--error-100)}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:20px}
.kpi{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-md);padding:18px}
.kpi-num{font-size:30px;font-weight:700;line-height:1;letter-spacing:-.02em;font-variant-numeric:tabular-nums;color:var(--text-primary)}
.kpi-lbl{font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.04em;font-weight:600;margin-top:6px}
.kpi.ok .kpi-num{color:var(--success-100)}
.kpi.err .kpi-num{color:var(--error-100)}
.section{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-lg);padding:22px 24px;margin-bottom:18px;box-shadow:var(--shadow-soft)}
.section-title{font-size:15px;font-weight:600;color:var(--text-primary);margin-bottom:4px}
.section-sub{font-size:12px;color:var(--text-muted);margin-bottom:14px}
.dev-block{border:1px solid var(--hairline);border-radius:var(--r-md);overflow:hidden;margin-bottom:10px;background:var(--surface)}
.dev-head{cursor:pointer;padding:12px 16px;display:flex;align-items:center;justify-content:space-between;gap:12px;list-style:none}
.dev-head::-webkit-details-marker{display:none}
.dev-name{font-size:14px;font-weight:600;color:var(--text-primary)}
.dev-stats{display:flex;gap:6px}
.hd-pill{font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:999px}
.hd-ok{background:var(--success-10);color:var(--success-100)}
.hd-err{background:var(--error-10);color:var(--error-100)}
.dev-body{border-top:1px solid var(--hairline-soft)}
.res-table{width:100%;border-collapse:collapse;font-size:13px}
.res-table th{text-align:left;font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);font-weight:600;padding:8px 16px;background:var(--surface-soft);border-bottom:1px solid var(--hairline-soft)}
.res-table td{padding:9px 16px;border-bottom:1px solid var(--hairline-soft);vertical-align:top}
.res-table tr:last-child td{border-bottom:none}
.c-status{width:120px}.c-svc{width:120px;font-weight:600;color:var(--text-primary)}.c-act{width:130px}
.c-msg{color:var(--error-100)}
.status-pill{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;padding:3px 9px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
.status-ok{background:var(--success-10);color:var(--success-100);border-color:var(--success-60)}
.status-err{background:var(--error-10);color:var(--error-100);border-color:var(--error-60)}
.empty{padding:18px;text-align:center;color:var(--text-muted);font-size:13px;background:var(--surface-soft);border-radius:var(--r-md);border:1px dashed var(--hairline)}
footer{text-align:center;color:var(--text-muted);font-size:11.5px;margin-top:24px}
footer code{font-family:ui-monospace,Menlo,Consolas,monospace;background:var(--surface);padding:1px 6px;border-radius:4px;border:1px solid var(--hairline)}
@media print{body{background:#fff;padding:0}.section,.head,.kpi,.dev-block{box-shadow:none;break-inside:avoid}}
</style>
</head>
<body>
<div class="container">
<header class="head">
<div class="head-top">
<div class="brand">
<div class="logo">I</div>
<div>
<div class="title">Intune Offboarding-Report</div>
<div class="subtitle">$tsHuman &middot; ausgefuehrt von $(_e $user)</div>
</div>
</div>
<div class="head-meta">
"@
if ($account) { $html += "<span class='meta-pill'><span class='lbl'>Account</span> $(_e $account)</span>" }
if ($tenant) { $html += "<span class='meta-pill'><span class='lbl'>Tenant</span> <code>$(_e $tenant)</code></span>" }
$html += "<span class='meta-pill'><span class='lbl'>Host</span> $(_e $machine)</span>"
$html += @"
</div>
</div>
<div class="banner $bannerCls">
<span class="banner-icon">$(if ($Errors -gt 0) { '!' } else { '&#10003;' })</span>
<span>$bannerTxt</span>
</div>
</header>
<div class="kpis">
<div class="kpi tot"><div class="kpi-num">$devCount</div><div class="kpi-lbl">Geraete</div></div>
<div class="kpi tot"><div class="kpi-num">$Total</div><div class="kpi-lbl">Aktionen</div></div>
<div class="kpi ok"><div class="kpi-num">$Success</div><div class="kpi-lbl">Erfolgreich</div></div>
<div class="kpi err"><div class="kpi-num">$Errors</div><div class="kpi-lbl">Fehler</div></div>
</div>
<div class="section">
<div class="section-title">Angeforderte Dienste</div>
<div class="section-sub">$svcTxt</div>
</div>
<div class="section">
<div class="section-title">Ergebnisse pro Geraet</div>
<div class="section-sub">Jeder Block zeigt die ausgefuehrten Loeschungen/Aenderungen mit Status und Meldung.</div>
$devBlocks
</div>
<footer>
Intune Manager &middot; Web Edition <code>v$(_e $script:ToolVersion)</code> &middot; Offboarding-Report
</footer>
</div>
</body>
</html>
"@
[IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8)
return $path
}