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 += ` + ${escapeHtml(d.DeviceName || '—')} ${escapeHtml(d.UserDisplayName || '—')} ${escapeHtml(d.OS || '—')} @@ -5924,13 +5928,41 @@ function devRender() { } tbody.innerHTML = html; + tbody.querySelectorAll('.dev-row-check').forEach(cb => { + cb.addEventListener('click', e => e.stopPropagation()); + cb.addEventListener('change', () => { + if (cb.checked) DevState.checked.add(cb.dataset.id); + else DevState.checked.delete(cb.dataset.id); + devUpdateCheckSel(); + }); + }); tbody.querySelectorAll('tr').forEach(tr => { - tr.addEventListener('click', () => { + tr.addEventListener('click', e => { + if (e.target.classList.contains('dev-row-check')) return; const id = tr.dataset.id; const device = DevState.filtered.find(d => d.Id === id); if (device) devSelectDevice(device); }); }); + devUpdateCheckSel(); +} + +// Checkbox-Auswahl -> Offboard-Button + Zähler + Header-Checkbox synchronisieren. +function devUpdateCheckSel() { + // Nur sichtbare (gefilterte) Geraete zaehlen. + const visibleIds = DevState.filtered.map(d => d.Id); + const n = visibleIds.filter(id => DevState.checked.has(id)).length; + const btn = document.getElementById('btnDevOffboard'); + if (btn) { + btn.disabled = n === 0; + const c = document.getElementById('devOffboardCount'); + if (c) c.textContent = n; + } + const all = document.getElementById('devCheckAll'); + if (all) { + all.checked = n > 0 && n === visibleIds.length; + all.indeterminate = n > 0 && n < visibleIds.length; + } } async function devSelectDevice(device) { @@ -6193,6 +6225,34 @@ function devExportCsv() { document.getElementById('btnDevExport')?.addEventListener('click', devExportCsv); +// Alle sichtbaren Geraete an-/abwaehlen +document.getElementById('devCheckAll')?.addEventListener('change', e => { + const on = e.target.checked; + DevState.filtered.forEach(d => { if (on) DevState.checked.add(d.Id); else DevState.checked.delete(d.Id); }); + // Checkboxen in der Tabelle spiegeln, ohne komplett neu zu rendern + document.querySelectorAll('#devTableBody .dev-row-check').forEach(cb => { cb.checked = on; }); + devUpdateCheckSel(); +}); + +// Ausgewaehlte Geraete offboarden -> IDs aufloesen (Entra/Autopilot) und Dialog oeffnen +async function devOffboardSelected() { + const ids = DevState.filtered.filter(d => DevState.checked.has(d.Id)).map(d => d.Id); + if (ids.length === 0) { toast('Keine Geräte ausgewählt.', 'warn'); return; } + setLoading(`Löse ${ids.length} Gerät(e) für Offboarding auf…`); + try { + const res = await api('/api/offboard/resolve', { method: 'POST', body: { ids }, timeoutMs: 120000 }); + const items = res.items || []; + if (items.length === 0) { toast('Geräte konnten nicht aufgelöst werden.', 'err'); return; } + // Offboard-Dialog mit genau diesen Geräten öffnen (unabhängig vom Offboard-Tab). + openOffboardModal(items); + } catch (e) { + toast('Offboarding-Auflösung fehlgeschlagen: ' + e.message, 'err'); + } finally { + clearLoading(); + } +} +document.getElementById('btnDevOffboard')?.addEventListener('click', devOffboardSelected); + // Sortierung document.querySelectorAll('#devTable th.sortable').forEach(th => { th.addEventListener('click', () => { @@ -6816,7 +6876,7 @@ document.querySelectorAll('#polTable th.sortable').forEach(th => { // ============================================================= // OFFBOARDING: Geräte aus Intune / Autopilot / Entra entfernen // ============================================================= -const OffState = { items: [], selected: new Set() }; +const OffState = { items: [], selected: new Set(), pending: [] }; function offKey(d) { return d.intuneDeviceId; } function offSelectedDevices() { return OffState.items.filter(d => OffState.selected.has(offKey(d))); } @@ -6889,12 +6949,13 @@ function offUpdateSel() { document.getElementById('offBtn').disabled = n === 0; } -function openOffboardModal() { - const devs = offSelectedDevices(); +function openOffboardModal(devsArg) { + const devs = Array.isArray(devsArg) ? devsArg : offSelectedDevices(); if (!devs.length) return; + OffState.pending = devs; document.getElementById('offConfirmList').innerHTML = devs.map(d => `
${escapeHtml(d.deviceName || '—')} - — Intune: ${d.intuneDeviceId ? '✓' : '–'}, Entra: ${d.entraObjectId ? '✓' : '–'}, Autopilot: ${d.autopilotId ? '✓' : '–'}
+ — Intune: ${d.intuneDeviceId ? '✓' : '–'}, Entra: ${d.entraObjectId ? '✓' : '–'}, Autopilot: ${d.autopilotId ? '✓' : '–'}${(!d.autopilotId && d.autopilotNote) ? ` (${escapeHtml(d.autopilotNote)})` : ''} `).join(''); document.getElementById('offModalInfo').textContent = `${devs.length} Gerät(e)`; document.getElementById('offConfirmChk').checked = false; @@ -6931,7 +6992,7 @@ async function offLoadKeys(devs) { } async function offExecute() { - const devs = offSelectedDevices(); + const devs = (OffState.pending && OffState.pending.length) ? OffState.pending : offSelectedDevices(); if (!devs.length) return; const services = { entra: document.querySelector('input[name="offEntra"]:checked').value, diff --git a/www/index.html b/www/index.html index 6e1b523..641155b 100644 --- a/www/index.html +++ b/www/index.html @@ -507,6 +507,10 @@ CSV +
@@ -515,6 +519,7 @@ + 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);
Gerät Benutzer OS