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>
3924 lines
179 KiB
PowerShell
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 = "✓"; label = "Erfolgreich" }
|
|
"AlreadyMember" = @{ cls = "ok"; icon = "✓"; label = "Bereits Mitglied" }
|
|
"Skipped" = @{ cls = "skip"; icon = "–"; label = "Uebersprungen" }
|
|
"Error" = @{ cls = "err"; icon = "!"; label = "Fehler" }
|
|
}
|
|
|
|
# Detail-Zeilen pro App gruppieren — ergibt eine kompaktere Darstellung
|
|
$byApp = @{}
|
|
foreach ($d in $Details) {
|
|
$key = [string]$d.app
|
|
if (-not $byApp.ContainsKey($key)) { $byApp[$key] = @() }
|
|
$byApp[$key] += $d
|
|
}
|
|
|
|
$appBlocksHtml = ""
|
|
foreach ($appName in ($byApp.Keys | Sort-Object)) {
|
|
$rows = @($byApp[$appName])
|
|
$okCount = @($rows | Where-Object { $_.status -in @('Success','AlreadyMember') }).Count
|
|
$skipCount = @($rows | Where-Object { $_.status -eq 'Skipped' }).Count
|
|
$errCount = @($rows | Where-Object { $_.status -eq 'Error' }).Count
|
|
|
|
# Pro App: Gruppen-Spalte + Empfaenger-Zeilen
|
|
$rowsHtml = ""
|
|
foreach ($d in $rows) {
|
|
$st = $statusMeta[[string]$d.status]
|
|
if (-not $st) { $st = @{ cls = ""; icon = ""; label = [string]$d.status } }
|
|
$msg = if ($d.message) { _enc $d.message } else { "" }
|
|
$errCode = if ($d.code) { " <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 · 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 { '✓' })</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 · $($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 · 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 · Web Edition <code>v$(_enc $script:ToolVersion)</code> · 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 " · ") } else { "keine" }
|
|
|
|
# Ergebnisse pro Geraet gruppieren (Reihenfolge = Geraeteliste)
|
|
$byDev = [ordered]@{}
|
|
foreach ($d in @($Devices)) { $dn = [string]$d.deviceName; if ($dn -and -not $byDev.Contains($dn)) { $byDev[$dn] = @() } }
|
|
foreach ($r in @($Results)) {
|
|
$dn = [string]$r.deviceName
|
|
if (-not $byDev.Contains($dn)) { $byDev[$dn] = @() }
|
|
$byDev[$dn] += $r
|
|
}
|
|
|
|
$devBlocks = ""
|
|
foreach ($dn in @($byDev.Keys)) {
|
|
$rows = @($byDev[$dn])
|
|
$okC = @($rows | Where-Object { $_.success }).Count
|
|
$errC = @($rows | Where-Object { -not $_.success }).Count
|
|
$rowsHtml = ""
|
|
foreach ($r in $rows) {
|
|
$cls = if ($r.success) { "ok" } else { "err" }
|
|
$ico = if ($r.success) { "✓" } else { "!" }
|
|
$lbl = if ($r.success) { "Erfolg" } else { "Fehler" }
|
|
$msg = if ($r.error) { _e $r.error } else { "" }
|
|
$rowsHtml += "<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 · 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 { '✓' })</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 · Web Edition <code>v$(_e $script:ToolVersion)</code> · Offboarding-Report
|
|
</footer>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"@
|
|
[IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8)
|
|
return $path
|
|
}
|