diff --git a/CHANGELOG.md b/CHANGELOG.md index 578d978..0662fcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/) — solan --- +## [0.1.32] - 2026-09-11 + +Bulk-Offboarding aus dem Geräte-Tab, Geräte-Ladefehler behoben, bessere Graph-Diagnose. + +### Neu + +- **Bulk-Offboarding aus dem Geräte-Tab**: Geräte per Checkbox (inkl. „Alle sichtbaren") auswählen und über den Button **„Offboarden"** direkt in den Offboard-Dialog übergeben — kombinierbar mit dem Gruppen-Filter (z. B. alle Geräte einer Gruppe). Die Auswahl wird server-seitig zu vollständigen Offboard-Objekten aufgelöst (Entra-Object-Id + Autopilot-Id nachgeladen). Neuer Endpoint `POST /api/offboard/resolve`. Im Read-Only-Modus ausgeblendet. + +### Behoben + +- **„Geräte konnten nicht geladen werden" (400 BadRequest)**: Intunes DeviceFE-Backend lehnte die Abfrage ab. Ursachen entfernt: `managementState` ist kein gültiges `$select`-Feld (raus), `$orderby` wird nicht mehr gesendet (Sortierung macht das Frontend), und der Namensfilter fällt bei nicht unterstütztem `startswith` automatisch auf `deviceName`-only zurück. Geräte-Endpoints nutzen jetzt `beta` (wie der Offboard-Abruf). +- **Bessere Graph-Fehler-Diagnose**: Bei Nicht-Retry-Fehlern wird der **Graph-Fehler-Body** an die Meldung gehängt (statt nur „BadRequest") — die eigentliche Ursache steht jetzt im Log/Toast. +- **Offboarding**: Wird eine Autopilot-Zuordnung nicht gefunden, zeigt der Dialog jetzt den **Grund** (fehlende Berechtigung vs. kein Seriennummer-Treffer), statt kommentarlos „Autopilot: –". + +--- + ## [0.1.31] - 2026-09-11 Policy-Konsolidierung, Geräte-Export nach Gruppe, Gruppen-in-Gruppen, Geräte-Fixes. diff --git a/README.md b/README.md index d677134..fbc70cf 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ App-Registrierung automatisch anlegt/konfiguriert:** | App Management | Haupt-Ansicht: Apps laden, Gruppen zuweisen / entfernen | | Group Management | Mitglieder anzeigen/exportieren, Benutzer **und Gruppen** hinzufuegen, CSV-Import | | App Report | Installationszaehler pro App, Geraete-Export als CSV | -| Geräte | Geraete suchen, Filter (auch nach **Gruppe**), CSV-Export, Detail-Panel, Aktionen (Sync, Wipe, …) | +| Geräte | Geraete suchen, Filter (auch nach **Gruppe**), CSV-Export, **Bulk-Offboarding**, Detail-Panel, Aktionen (Sync, Wipe, …) | | Policies | Policies exportieren/importieren (Neuanlage) und **zusammenfuehren** (Settings Catalog) | --- diff --git a/Start.ps1 b/Start.ps1 index e31b3e7..14387f0 100644 --- a/Start.ps1 +++ b/Start.ps1 @@ -189,7 +189,7 @@ $script:State = [pscustomobject]@{ Session = @() # geplante Zuweisungen } -$script:ToolVersion = "0.1.31" +$script:ToolVersion = "0.1.32" $script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Write-Host "" diff --git a/installer/build-local.ps1 b/installer/build-local.ps1 index f44d34a..a1e6947 100644 --- a/installer/build-local.ps1 +++ b/installer/build-local.ps1 @@ -12,7 +12,7 @@ if (Test-Path "$x64Dotnet\dotnet.exe") { $env:DOTNET_ROOT = "C:\Program Files\dotnet" } -$version = "0.1.31" +$version = "0.1.32" $src = "\\Mac\Home\ClaudePRJ\Intune Manager" $stage = "$env:TEMP\IntuneManagerStage" $installerDir = "$src\installer" diff --git a/src/Api.ps1 b/src/Api.ps1 index 6d2dd9f..95e2995 100644 --- a/src/Api.ps1 +++ b/src/Api.ps1 @@ -63,6 +63,7 @@ function Invoke-ApiHandler { "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 } } @@ -1784,12 +1785,14 @@ function Search-DevicesEndpoint { $err = Test-Connected if ($err) { return $err } - $q = [string]$Query.q + $q = ([string]$Query.q) -replace "'", "''" # OData-Escape fuer Apostroph $os = [string]$Query.os $compliance = [string]$Query.compliance $top = 50 - $select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,managementState,serialNumber,model,manufacturer,enrolledDateTime' + # 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' # Ohne Suchbegriff: alle Geräte (erste Seite) # Mit Suchbegriff: Graph unterstuetzt startswith nur auf deviceName/userDisplayName/userPrincipalName. @@ -1798,22 +1801,36 @@ function Search-DevicesEndpoint { $snItems = @() if ($q -and $q.Length -ge 2) { - $filters = @() - $nameFilter = "(startswith(deviceName,'$q') or startswith(userDisplayName,'$q') or startswith(userPrincipalName,'$q'))" - $filters += $nameFilter - if ($os) { $filters += "operatingSystem eq '$os'" } - if ($compliance) { $filters += "complianceState eq '$compliance'" } - $filterStr = '$filter=' + [uri]::EscapeDataString(($filters -join ' and ')) + '&' - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?${filterStr}`$select=$select&`$top=$top&`$orderby=deviceName" - $resp = Invoke-MgGraphRequestRetry -Uri $uri -Method GET - $nameItems = @($resp.value) + $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/v1.0/deviceManagement/managedDevices?${snFilter}`$select=$select&`$top=10" + $snUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?${snFilter}`$select=$select&`$top=10" try { $snResp = Invoke-MgGraphRequestRetry -Uri $snUri -Method GET $snItems = @($snResp.value) @@ -1823,7 +1840,9 @@ function Search-DevicesEndpoint { 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 { '' } - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?${filterStr}`$select=$select&`$top=$top&`$orderby=deviceName" + # 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) } @@ -1859,8 +1878,9 @@ function Get-DeviceEndpoint { $err = Test-Connected if ($err) { return $err } - $select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,managementState,serialNumber,model,manufacturer,enrolledDateTime,imei,wiFiMacAddress,azureADDeviceId,joinType,deviceEnrollmentType,managedDeviceOwnerType,totalStorageSpaceInBytes,freeStorageSpaceInBytes' - $d = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$DeviceId`?`$select=$select" -Method GET + # '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,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 } diff --git a/src/Graph.ps1 b/src/Graph.ps1 index 91445ee..85ba1aa 100644 --- a/src/Graph.ps1 +++ b/src/Graph.ps1 @@ -71,6 +71,11 @@ function Invoke-MgGraphRequestRetry { Start-Sleep -Seconds $wait continue } + # Nicht-Retry-Fehler: den Graph-Fehler-Body (mit dem eigentlichen Grund) + # an die Meldung haengen — sonst steht im Log nur "BadRequest". + $gbody = $null + try { $gbody = [string]$_.ErrorDetails.Message } catch {} + if ($gbody) { throw [System.Exception]::new("$msg | Graph: $gbody", $_.Exception) } throw } } diff --git a/src/Offboard.ps1 b/src/Offboard.ps1 index 3e47103..521e269 100644 --- a/src/Offboard.ps1 +++ b/src/Offboard.ps1 @@ -55,6 +55,15 @@ function Search-OffboardDevicesEndpoint { return @{ __status = 500; error = "Graph-Fehler bei der Suche: $m" } } if ($intune.Count -eq 0) { return @{ items = @(); count = 0 } } + return @{ items = @(Resolve-OffboardItems -IntuneDevices $intune); count = $intune.Count } +} + +# Reichert Intune-managedDevice-Objekte mit Entra-Objekt-Id + Autopilot-Id an +# (per $batch) und liefert die Offboard-Item-Struktur fuers Frontend. +function Resolve-OffboardItems { + param([object[]]$IntuneDevices) + $intune = @($IntuneDevices) + if ($intune.Count -eq 0) { return @() } # Entra-Objekt (fuer Delete) + Autopilot-Identity (fuer Delete) per Batch nachladen. $reqs = @() @@ -77,9 +86,23 @@ function Search-OffboardDevicesEndpoint { $items = @() $idx = 0 foreach ($d in $intune) { - $entra = $null; $autop = $null + $ser = [string]$d.serialNumber + $entra = $null; $autop = $null; $apNote = '' $er = $batch["e$idx"]; if ($er -and [int]$er.status -eq 200) { $entra = @($er.body.value)[0] } - $ar = $batch["a$idx"]; if ($ar -and [int]$ar.status -eq 200) { $autop = @($ar.body.value)[0] } + $ar = $batch["a$idx"] + if ($ar) { + $ast = [int]$ar.status + if ($ast -eq 200) { + $autop = @($ar.body.value)[0] + if (-not $autop) { $apNote = 'Kein Autopilot-Treffer fuer Seriennummer' } + } else { + $acode = ''; try { $acode = [string]$ar.body.error.code } catch {} + $apNote = "Autopilot-Lookup fehlgeschlagen: HTTP $ast" + $(if ($acode) { " ($acode)" } else { '' }) + Write-Host " [OFFBOARD] Autopilot-Lookup ($($d.deviceName), SN=$ser): HTTP $ast $acode" -ForegroundColor DarkYellow + } + } elseif (-not $ser) { + $apNote = 'Keine Seriennummer am Intune-Geraet' + } $items += [pscustomobject]@{ deviceName = [string]$d.deviceName serialNumber = [string]$d.serialNumber @@ -94,13 +117,39 @@ function Search-OffboardDevicesEndpoint { entraObjectId = if ($entra) { [string]$entra.id } else { '' } entraEnabled = if ($entra) { [bool]$entra.accountEnabled } else { $null } autopilotId = if ($autop) { [string]$autop.id } else { '' } + autopilotNote = $apNote inIntune = $true inEntra = [bool]$entra inAutopilot = [bool]$autop } $idx++ } - return @{ items = @($items); count = $items.Count } + return @($items) +} + +# Offboard-Items zu einer Liste von Intune-Device-Ids aufloesen (fuer Bulk- +# Offboarding aus dem Geraete-Tab). Body: { ids: [intuneDeviceId, ...] } +function Get-OffboardResolveEndpoint { + param($Body) + $err = Test-Connected + if ($err) { return $err } + $ids = @(Get-PolicyProp $Body 'ids') | Where-Object { $_ } + if (@($ids).Count -eq 0) { return @{ items = @(); count = 0 } } + + $sel = 'id,deviceName,serialNumber,operatingSystem,osVersion,userPrincipalName,lastSyncDateTime,azureADDeviceId,managedDeviceOwnerType,managementAgent' + $reqs = @() + $i = 0 + foreach ($id in $ids) { + $reqs += @{ id = "d$i"; method = 'GET'; url = "/deviceManagement/managedDevices/$([string]$id)?`$select=$sel" } + $i++ + } + $batch = Invoke-GraphBatch -Requests $reqs + $devs = @() + foreach ($k in @($batch.Keys)) { + $r = $batch[$k] + if ($r -and [int]$r.status -eq 200 -and $r.body) { $devs += $r.body } + } + return @{ items = @(Resolve-OffboardItems -IntuneDevices $devs); count = @($devs).Count } } # Recovery-Keys eines Geraets holen (vor dem Loeschen). Body: diff --git a/www/app.js b/www/app.js index 9b1109f..ea1acdf 100644 --- a/www/app.js +++ b/www/app.js @@ -5776,7 +5776,8 @@ const DevState = { sortAsc: true, loaded: false, groupId: null, // aktiver Gruppen-Filter (null = alle Geraete) - groupName: '' + groupName: '', + checked: new Set() // per Checkbox ausgewaehlte Geraete-Ids (fuer Bulk-Offboarding) }; async function devLoadAll() { @@ -5789,6 +5790,7 @@ function devShowLoading(on) { } async function devFetch() { + DevState.checked.clear(); // neue Datenladung -> Auswahl zuruecksetzen devShowLoading(true); try { if (DevState.groupId) { @@ -5914,7 +5916,9 @@ function devRender() { const cmpCls = d.ComplianceState === 'compliant' ? 'dev-badge-ok' : d.ComplianceState === 'noncompliant' ? 'dev-badge-err' : 'dev-badge-unk'; const sel = DevState.selected && DevState.selected.Id === d.Id ? ' class="dev-row-selected"' : ''; + const chk = DevState.checked.has(d.Id) ? 'checked' : ''; html += `
| Gerät | Benutzer | OS | diff --git a/www/styles.css b/www/styles.css index a2c2b6d..09484f2 100644 --- a/www/styles.css +++ b/www/styles.css @@ -4969,6 +4969,7 @@ body.read-only .gex-add-pane, body.read-only .gex-tab[data-tab="add"], body.read-only .gex-tab[data-tab="import"], body.read-only #gexPaneImport, +body.read-only .dev-offboard-btn, body.read-only .pol-write { display: none !important; } @@ -5373,6 +5374,8 @@ body.read-only .app-row { } .dev-table thead th.sortable { cursor: pointer; } .dev-table thead th.sortable:hover { color: var(--text); } +.dev-col-check { width: 34px; text-align: center; padding-left: 6px; padding-right: 6px; } +.dev-col-check input { cursor: pointer; } .dev-table tbody tr { cursor: pointer; border-bottom: 1px solid var(--hairline-soft);
|---|