From 5c60eacc12018dd5b336d8fa64485e0374346b73 Mon Sep 17 00:00:00 2001 From: Marco Wende Date: Mon, 24 Aug 2026 12:37:41 +0200 Subject: [PATCH] =?UTF-8?q?v0.1.29=20=E2=80=94=20Geraete-Offboarding=20(v1?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neu: - Geraete-Offboarding: Geraete ueber Intune, Autopilot und Entra ID entfernen. Suche + ID-Aufloesung (via $batch), Recovery-Keys (BitLocker/FileVault/LAPS) vor dem Loeschen, Bestaetigungs-Dialog mit Dienst-Auswahl + 403/Multi-Admin-Approval-Handling, im Read-Only gesperrt. Neues Modul src/Offboard.ps1, Endpoints /api/offboard/{search,keys,execute}. Portiert aus Device Offboarding Manager (Ugur Koc, MIT). - Offboarding-Berechtigungen dokumentiert; Setup-Skript -IncludeOffboarding. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 16 +++ Start.ps1 | 3 +- docs/App-Registration.md | 20 +++ docs/Setup-AppRegistration.ps1 | 13 ++ installer/build-local.ps1 | 2 +- src/Api.ps1 | 4 + src/Offboard.ps1 | 238 +++++++++++++++++++++++++++++++++ www/app.js | 174 +++++++++++++++++++++++- www/index.html | 104 ++++++++++++++ 9 files changed, 571 insertions(+), 3 deletions(-) create mode 100644 src/Offboard.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index b96947e..8434746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/) — solan --- +## [0.1.29] - 2026-08-24 + +Geräte-Offboarding (v1). + +### Neu + +- **Geräte-Offboarding** als neue Ansicht: Geräte über **Intune, Autopilot und Entra ID** hinweg entfernen (Decommissioning). Portiert aus dem [Device Offboarding Manager](https://github.com/ugurkocde/DeviceOffboardingManager) von Ugur Koc (MIT-Lizenz). + - Suche per Gerätename/Seriennummer, Auflösung der IDs über alle drei Dienste (`$batch`) + - **Recovery-Keys vor dem Löschen**: BitLocker/FileVault und LAPS-Passwörter werden angezeigt + - Bestätigungs-Dialog mit Dienst-Auswahl (Entra löschen/deaktivieren, Intune, Autopilot), Pflicht-Bestätigung und Ergebnis-Liste; **403/Multi-Admin-Approval** wird abgefangen + - Im **Read-Only-Modus gesperrt** + - Neue Endpoints: `GET /api/offboard/search`, `POST /api/offboard/keys`, `POST /api/offboard/execute` (neues Modul `src/Offboard.ps1`) + - Zusätzliche Berechtigungen dokumentiert; Setup-Skript-Schalter `-IncludeOffboarding`. (MDE-Offboarding bewusst nicht in v1.) + +--- + ## [0.1.28] - 2026-08-24 Umbenennen beim Import, UI-Fixes, Geräte-Export repariert. diff --git a/Start.ps1 b/Start.ps1 index ad1ced2..976e370 100644 --- a/Start.ps1 +++ b/Start.ps1 @@ -137,6 +137,7 @@ if (-not $SkipModuleCheck) { . (Join-Path $root "src/Graph.ps1") . (Join-Path $root "src/Api.ps1") . (Join-Path $root "src/PolicyIO.ps1") +. (Join-Path $root "src/Offboard.ps1") . (Join-Path $root "src/Router.ps1") . (Join-Path $root "src/Server.ps1") @@ -188,7 +189,7 @@ $script:State = [pscustomobject]@{ Session = @() # geplante Zuweisungen } -$script:ToolVersion = "0.1.28" +$script:ToolVersion = "0.1.29" $script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Write-Host "" diff --git a/docs/App-Registration.md b/docs/App-Registration.md index a17854f..b19ece0 100644 --- a/docs/App-Registration.md +++ b/docs/App-Registration.md @@ -42,6 +42,26 @@ Nur nötig, wenn der **Geräte-Tab** (Sync/Reboot/Lock/Diagnose/Wipe/Retire) gen | `DeviceManagementManagedDevices.ReadWrite.All` | Sync, Neustart, Remote-Lock, Diagnose, BitLocker-Key-Rotation | | `DeviceManagementManagedDevices.PrivilegedOperations.All` | Wipe, Retire, Autopilot-Reset | +### Geräte-Offboarding (optional) + +Nur nötig, wenn die **Offboarding**-Ansicht (Geräte aus Intune + Autopilot + Entra +entfernen, Recovery-Keys lesen) genutzt wird: + +| Berechtigung | Wofür | +|---|---| +| `Device.ReadWrite.All` | Gerät aus Entra ID löschen/deaktivieren | +| `DeviceManagementManagedDevices.ReadWrite.All` | Gerät aus Intune löschen | +| `DeviceManagementServiceConfig.ReadWrite.All` | Gerät aus Autopilot löschen | +| `BitlockerKey.Read.All` | BitLocker-Recovery-Keys lesen | +| `DeviceLocalCredential.Read.All` | LAPS-Passwörter lesen | + +> **Wichtig:** Löschen aus Entra/Intune/Autopilot benötigt bei delegierter +> Anmeldung zusätzlich **Verzeichnis-/Intune-Rollen** (Cloud Device Administrator +> bzw. Intune Administrator) — die Scopes allein reichen nicht, sonst `403`. +> Diese Scopes müssen außerdem in den **Read/Write-Scopes** des Tools eingetragen +> sein (Einstellungen → Verbindung), damit sie im Token landen. +> Skript: `.\docs\Setup-AppRegistration.ps1 -IncludeOffboarding -GrantAdminConsent` + ### Read-Only-Variante Für eine reine Anzeige-App (im Tool als `clientIdRo` hinterlegbar) genügen die diff --git a/docs/Setup-AppRegistration.ps1 b/docs/Setup-AppRegistration.ps1 index 5111560..f9d2a97 100644 --- a/docs/Setup-AppRegistration.ps1 +++ b/docs/Setup-AppRegistration.ps1 @@ -61,6 +61,7 @@ param( [switch]$MultiTenant, [switch]$ReadOnly, [switch]$IncludeDeviceActions, + [switch]$IncludeOffboarding, [switch]$GrantAdminConsent, [switch]$EmitManifest ) @@ -85,6 +86,18 @@ if ($IncludeDeviceActions) { 'DeviceManagementManagedDevices.PrivilegedOperations.All') } } +if ($IncludeOffboarding) { + # Geraete ueber Intune/Autopilot/Entra entfernen + Recovery-Keys lesen. + # ACHTUNG: Loeschen braucht zusaetzlich Verzeichnis-/Intune-ROLLEN (Cloud + # Device Administrator / Intune Administrator) — nicht nur diese Scopes. + $perms += @( + 'Device.ReadWrite.All', + 'DeviceManagementManagedDevices.ReadWrite.All', + 'DeviceManagementServiceConfig.ReadWrite.All', + 'BitlockerKey.Read.All', + 'DeviceLocalCredential.Read.All' + ) +} $perms = $perms | Select-Object -Unique # --- Verbinden ---------------------------------------------------------------- diff --git a/installer/build-local.ps1 b/installer/build-local.ps1 index 1774c16..cc875d3 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.28" +$version = "0.1.29" $src = "\\Mac\Home\ClaudePRJ\Intune Manager" $stage = "$env:TEMP\IntuneManagerStage" $installerDir = "$src\installer" diff --git a/src/Api.ps1 b/src/Api.ps1 index e6a3159..5071748 100644 --- a/src/Api.ps1 +++ b/src/Api.ps1 @@ -60,6 +60,10 @@ function Invoke-ApiHandler { "POST /api/policies/assign" { return Invoke-PolicyAssignEndpoint -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/keys" { return Get-OffboardKeysEndpoint -Body $Body } + "POST /api/offboard/execute" { return Invoke-OffboardExecuteEndpoint -Body $Body } } # 2-segment fallbacks (z.B. /api/groups//members) diff --git a/src/Offboard.ps1 b/src/Offboard.ps1 new file mode 100644 index 0000000..3e47103 --- /dev/null +++ b/src/Offboard.ps1 @@ -0,0 +1,238 @@ +# Geraete-Offboarding: ein Geraet ueber Intune, Autopilot und Entra ID hinweg +# entfernen (Decommissioning). Vor dem Loeschen koennen BitLocker-/FileVault-Keys +# und LAPS-Passwoerter ausgelesen werden, damit sie nicht verloren gehen. +# +# Portiert aus dem "Device Offboarding Manager" (Ugur Koc, MIT-Lizenz) — dort +# WPF, hier als Web-Endpoints in den Intune Manager integriert. +# +# Loeschen ist destruktiv -> im Read-Only-Modus gesperrt, harte Bestaetigung im UI. + +# Generischer Microsoft-Graph-$batch-Helper. Nimmt Sub-Requests +# ( @{ id; method; url; body?; headers? } ) und liefert eine Map id -> Response. +function Invoke-GraphBatch { + param([Parameter(Mandatory=$true)][object[]]$Requests) + $map = @{} + for ($i = 0; $i -lt $Requests.Count; $i += 20) { + $chunk = $Requests[$i .. [Math]::Min($i + 19, $Requests.Count - 1)] + $body = @{ requests = @($chunk) } | ConvertTo-Json -Depth 10 -Compress + $resp = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/$batch' -Method POST -Body $body -ContentType 'application/json' + foreach ($r in @($resp.responses)) { $map[[string]$r.id] = $r } + } + return $map +} + +# Graph-Fehlermeldung aus einer Exception ziehen (Body statt generischer Text). +function Get-OffboardGraphErr { + param($ErrorRecord) + $m = $ErrorRecord.Exception.Message + try { if ($ErrorRecord.ErrorDetails.Message) { $m = $ErrorRecord.ErrorDetails.Message } } catch {} + return $m +} + +# Geraete suchen und ueber die drei Dienste hinweg aufloesen. +# Query: query=, type=name|serial +function Search-OffboardDevicesEndpoint { + param([hashtable]$Query) + $err = Test-Connected + if ($err) { return $err } + + $q = [string]$Query['query'] + $type = [string]$Query['type'] + if ([string]::IsNullOrWhiteSpace($q)) { return @{ __status = 400; error = 'Suchbegriff fehlt' } } + $qEsc = $q -replace "'", "''" + + # managedDevices unterstuetzt startswith(deviceName) bzw. serialNumber eq. + $filter = if ($type -eq 'serial') { "serialNumber eq '$qEsc'" } else { "startswith(deviceName,'$qEsc')" } + $sel = 'id,deviceName,serialNumber,operatingSystem,osVersion,userPrincipalName,lastSyncDateTime,azureADDeviceId,managedDeviceOwnerType,managementAgent' + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=$([Uri]::EscapeDataString($filter))&`$select=$sel&`$top=50&`$orderby=deviceName" + try { + $intune = @(Get-GraphPaged -Uri $uri) + } catch { + $m = Get-OffboardGraphErr $_ + if ($m -match '403|Forbidden') { + return @{ __status = 403; error = 'Keine Berechtigung fuer die Geraete-Suche. Scope "DeviceManagementManagedDevices.Read.All" muss in den Read/Write-Scopes stehen UND per Admin-Consent zugestimmt sein (siehe docs/App-Registration.md). Nach dem Ergaenzen: ab- und neu anmelden.' } + } + return @{ __status = 500; error = "Graph-Fehler bei der Suche: $m" } + } + if ($intune.Count -eq 0) { return @{ items = @(); count = 0 } } + + # Entra-Objekt (fuer Delete) + Autopilot-Identity (fuer Delete) per Batch nachladen. + $reqs = @() + $idx = 0 + foreach ($d in $intune) { + $aad = [string]$d.azureADDeviceId + $ser = [string]$d.serialNumber + if ($aad -and $aad -ne '00000000-0000-0000-0000-000000000000') { + $f = [Uri]::EscapeDataString("deviceId eq '$aad'") + $reqs += @{ id = "e$idx"; method = 'GET'; url = "/devices?`$filter=$f&`$select=id,deviceId,displayName,accountEnabled&`$top=1" } + } + if ($ser) { + $sf = [Uri]::EscapeDataString("contains(serialNumber,'$($ser -replace "'","''")')") + $reqs += @{ id = "a$idx"; method = 'GET'; url = "/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=$sf&`$top=1" } + } + $idx++ + } + $batch = if ($reqs.Count -gt 0) { Invoke-GraphBatch -Requests $reqs } else { @{} } + + $items = @() + $idx = 0 + foreach ($d in $intune) { + $entra = $null; $autop = $null + $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] } + $items += [pscustomobject]@{ + deviceName = [string]$d.deviceName + serialNumber = [string]$d.serialNumber + operatingSystem = [string]$d.operatingSystem + osVersion = [string]$d.osVersion + primaryUser = [string]$d.userPrincipalName + lastSync = $d.lastSyncDateTime + ownership = [string]$d.managedDeviceOwnerType + coManaged = ([string]$d.managementAgent -match 'configurationManager') + intuneDeviceId = [string]$d.id + azureADDeviceId = [string]$d.azureADDeviceId + entraObjectId = if ($entra) { [string]$entra.id } else { '' } + entraEnabled = if ($entra) { [bool]$entra.accountEnabled } else { $null } + autopilotId = if ($autop) { [string]$autop.id } else { '' } + inIntune = $true + inEntra = [bool]$entra + inAutopilot = [bool]$autop + } + $idx++ + } + return @{ items = @($items); count = $items.Count } +} + +# Recovery-Keys eines Geraets holen (vor dem Loeschen). Body: +# { intuneDeviceId, azureADDeviceId, operatingSystem } +function Get-OffboardKeysEndpoint { + param($Body) + $err = Test-Connected + if ($err) { return $err } + + $intuneId = [string](Get-PolicyProp $Body 'intuneDeviceId') + $aad = [string](Get-PolicyProp $Body 'azureADDeviceId') + $os = [string](Get-PolicyProp $Body 'operatingSystem') + + $bitlocker = @() + $fileVault = $null + $laps = $null + $notes = @() + + if ($os -eq 'Windows' -and $aad) { + try { + $keyIds = @(Get-GraphPaged -Uri "https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys?`$filter=deviceId eq '$aad'") + foreach ($k in $keyIds) { + try { + $kd = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys/$($k.id)?`$select=key,volumeType" -Method GET + if ($kd.key) { $bitlocker += @{ volumeType = [string]$kd.volumeType; key = [string]$kd.key } } + } catch {} + } + if ($bitlocker.Count -eq 0) { $notes += 'Kein BitLocker-Key gefunden.' } + } catch { + $m = Get-OffboardGraphErr $_ + $notes += if ($m -match '403') { 'BitLocker: Zugriff verweigert (BitlockerKey.Read.All noetig).' } else { "BitLocker: $m" } + } + } + elseif ($os -eq 'macOS' -and $intuneId) { + try { + $fv = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices('$intuneId')/getFileVaultKey" -Method GET + if ($fv.value) { $fileVault = [string]$fv.value } else { $notes += 'Kein FileVault-Key gefunden.' } + } catch { $notes += "FileVault: $(Get-OffboardGraphErr $_)" } + } + + # LAPS (jede Plattform, ueber die Entra-Device-Id). + if ($aad) { + try { + $resp = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/directory/deviceLocalCredentials/$aad`?`$select=credentials" -Method GET + $creds = @($resp.credentials) + if ($creds.Count -gt 0) { + $latest = $creds | Sort-Object -Property backupDateTime -Descending | Select-Object -First 1 + $pw = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([string]$latest.passwordBase64)) + $laps = @{ account = [string]$latest.accountName; password = $pw } + } + } catch { + $m = Get-OffboardGraphErr $_ + if ($m -notmatch '404') { $notes += "LAPS: $m" } + } + } + + return @{ ok = $true; keys = @{ bitlocker = @($bitlocker); fileVault = $fileVault; laps = $laps; notes = @($notes) } } +} + +# Offboarding ausfuehren. Body: +# devices = [ { deviceName, intuneDeviceId, entraObjectId, autopilotId } ] +# services = { entra: 'delete'|'disable'|'none', intune: bool, autopilot: bool } +function Invoke-OffboardExecuteEndpoint { + param($Body) + $err = Test-Connected + if ($err) { return $err } + if ($script:State.ReadOnly) { return @{ __status = 403; error = 'Read-Only-Modus: Offboarding ist deaktiviert.' } } + + $devices = @(Get-PolicyProp $Body 'devices') | Where-Object { $_ } + $svc = Get-PolicyProp $Body 'services' + if (@($devices).Count -eq 0) { return @{ __status = 400; error = 'Keine Geraete uebergeben' } } + + $entraAction = [string](Get-PolicyProp $svc 'entra') + $doIntune = [bool](Get-PolicyProp $svc 'intune') + $doAutopilot = [bool](Get-PolicyProp $svc 'autopilot') + + $reqs = @() + $meta = @{} + $n = 0 + foreach ($d in $devices) { + $name = [string](Get-PolicyProp $d 'deviceName') + $iid = [string](Get-PolicyProp $d 'intuneDeviceId') + $eid = [string](Get-PolicyProp $d 'entraObjectId') + $aid = [string](Get-PolicyProp $d 'autopilotId') + + if ($entraAction -eq 'delete' -and $eid) { + $rid = "r$n"; $reqs += @{ id = $rid; method = 'DELETE'; url = "/devices/$eid" } + $meta[$rid] = @{ name = $name; svc = 'Entra ID'; action = 'geloescht' }; $n++ + } elseif ($entraAction -eq 'disable' -and $eid) { + $rid = "r$n"; $reqs += @{ id = $rid; method = 'PATCH'; url = "/devices/$eid"; body = @{ accountEnabled = $false }; headers = @{ 'Content-Type' = 'application/json' } } + $meta[$rid] = @{ name = $name; svc = 'Entra ID'; action = 'deaktiviert' }; $n++ + } + if ($doIntune -and $iid) { + $rid = "r$n"; $reqs += @{ id = $rid; method = 'DELETE'; url = "/deviceManagement/managedDevices/$iid" } + $meta[$rid] = @{ name = $name; svc = 'Intune'; action = 'geloescht' }; $n++ + } + if ($doAutopilot -and $aid) { + $rid = "r$n"; $reqs += @{ id = $rid; method = 'DELETE'; url = "/deviceManagement/windowsAutopilotDeviceIdentities/$aid" } + $meta[$rid] = @{ name = $name; svc = 'Autopilot'; action = 'geloescht' }; $n++ + } + } + if ($reqs.Count -eq 0) { return @{ __status = 400; error = 'Keine ausfuehrbaren Aktionen (fehlende IDs oder nichts ausgewaehlt).' } } + + Write-Host "[OFFBOARD] Fuehre $($reqs.Count) Aktion(en) fuer $(@($devices).Count) Geraet(e) aus..." -ForegroundColor Yellow + $batch = Invoke-GraphBatch -Requests $reqs + + $results = @() + foreach ($rid in ($meta.Keys | Sort-Object { [int]($_ -replace '\D','') })) { + $m = $meta[$rid] + $r = $batch[$rid] + $status = if ($r) { [int]$r.status } else { 0 } + $ok = $status -in @(200, 204) + $errMsg = '' + if (-not $ok) { + $code = '' + try { $code = [string]$r.body.error.code } catch {} + if ($status -eq 403 -and $code -match 'multipleAdminApproval|protectedOperation') { + $errMsg = 'Erfordert Multi-Admin-Approval' + } elseif ($status -eq 403) { + $errMsg = "403 - fehlende Rolle/Berechtigung fuer $($m.svc)" + } elseif ($status -eq 0) { + $errMsg = 'Keine Antwort' + } else { + $errMsg = "HTTP $status" + $(if ($code) { " ($code)" } else { '' }) + } + } + $results += [pscustomobject]@{ + deviceName = $m.name; service = $m.svc; action = $m.action + success = $ok; error = $errMsg + } + } + $okCount = @($results | Where-Object { $_.success }).Count + Write-Host " -> $okCount/$($results.Count) erfolgreich" -ForegroundColor DarkGray + return @{ ok = $true; successCount = $okCount; total = $results.Count; results = @($results) } +} diff --git a/www/app.js b/www/app.js index 1366068..e1309a4 100644 --- a/www/app.js +++ b/www/app.js @@ -4621,6 +4621,7 @@ function switchMainView(view) { const reportView = document.getElementById('appReportView'); const devView = document.getElementById('devView'); const polView = document.getElementById('policiesView'); + const offView = document.getElementById('offView'); document.querySelectorAll('#topbarNav .nav-tab').forEach(t => { t.classList.toggle('active', t.dataset.view === view); t.setAttribute('aria-selected', t.dataset.view === view ? 'true' : 'false'); @@ -4630,12 +4631,13 @@ function switchMainView(view) { reportView.classList.toggle('hidden', view !== 'report'); devView .classList.toggle('hidden', view !== 'devices'); polView .classList.toggle('hidden', view !== 'policies'); + if (offView) offView.classList.toggle('hidden', view !== 'offboard'); } document.querySelectorAll('#topbarNav .nav-tab').forEach(btn => { btn.addEventListener('click', () => { const view = btn.dataset.view; - if ((view === 'groups' || view === 'report' || view === 'devices' || view === 'policies') && !State.connected) { + if ((view === 'groups' || view === 'report' || view === 'devices' || view === 'policies' || view === 'offboard') && !State.connected) { toast('Bitte zuerst verbinden.', 'warn'); return; } @@ -4644,6 +4646,7 @@ document.querySelectorAll('#topbarNav .nav-tab').forEach(btn => { if (view === 'report' && ReportState.items.length === 0) loadAppReport(); if (view === 'devices') { devLoadAll(); setTimeout(() => document.getElementById('devSearch').focus(), 80); } if (view === 'policies' && PolState.items.length === 0) loadPolicies(); + if (view === 'offboard') setTimeout(() => document.getElementById('offSearch').focus(), 80); }); }); @@ -6215,3 +6218,172 @@ document.querySelectorAll('#polTable th.sortable').forEach(th => { renderPolicies(); }); }); + +// ============================================================= +// OFFBOARDING: Geräte aus Intune / Autopilot / Entra entfernen +// ============================================================= +const OffState = { items: [], selected: new Set() }; +function offKey(d) { return d.intuneDeviceId; } +function offSelectedDevices() { return OffState.items.filter(d => OffState.selected.has(offKey(d))); } + +async function offSearch() { + const q = document.getElementById('offSearch').value.trim(); + const type = document.getElementById('offSearchType').value; + if (q.length < 2) { toast('Mindestens 2 Zeichen.', 'warn'); return; } + const body = document.getElementById('offBody'); + body.innerHTML = ` Suche…`; + try { + const res = await api(`/api/offboard/search?query=${encodeURIComponent(q)}&type=${type}`, { timeoutMs: 60000 }); + OffState.items = res.items || []; + OffState.selected = new Set(); + renderOff(); + } catch (e) { + toast('Suche fehlgeschlagen: ' + e.message, 'err'); + body.innerHTML = `Fehler bei der Suche.`; + } +} + +function offSvcBadges(d) { + const b = []; + if (d.inIntune) b.push('Intune'); + if (d.inEntra) b.push('Entra'); + if (d.inAutopilot) b.push('Autopilot'); + if (d.coManaged) b.push('Co-Mgmt'); + return b.join(' ') || '—'; +} + +function renderOff() { + const body = document.getElementById('offBody'); + if (!OffState.items.length) { + body.innerHTML = `Keine Geräte gefunden.`; + offUpdateSel(); + return; + } + body.innerHTML = OffState.items.map(d => { + const k = offKey(d); + const checked = OffState.selected.has(k) ? 'checked' : ''; + return ` + + ${escapeHtml(d.deviceName || '—')} + ${escapeHtml(d.serialNumber || '—')} + ${escapeHtml(((d.operatingSystem || '') + ' ' + (d.osVersion || '')).trim() || '—')} + ${escapeHtml(d.primaryUser || '—')} + ${polFmtDate(d.lastSync)} + ${offSvcBadges(d)} + `; + }).join(''); + body.querySelectorAll('.off-row-check').forEach(cb => { + cb.addEventListener('change', () => { + if (cb.checked) OffState.selected.add(cb.dataset.key); else OffState.selected.delete(cb.dataset.key); + offUpdateSel(); + }); + }); + body.querySelectorAll('tr[data-key]').forEach(tr => { + tr.addEventListener('click', e => { + if (e.target.classList.contains('off-row-check')) return; + const cb = tr.querySelector('.off-row-check'); + cb.checked = !cb.checked; + cb.dispatchEvent(new Event('change')); + }); + }); + offUpdateSel(); +} + +function offUpdateSel() { + const n = OffState.selected.size; + document.getElementById('offSelCount').textContent = `${n} ausgewählt`; + document.getElementById('offBtn').disabled = n === 0; +} + +function openOffboardModal() { + const devs = offSelectedDevices(); + if (!devs.length) return; + document.getElementById('offConfirmList').innerHTML = devs.map(d => ` +
${escapeHtml(d.deviceName || '—')} + — Intune: ${d.intuneDeviceId ? '✓' : '–'}, Entra: ${d.entraObjectId ? '✓' : '–'}, Autopilot: ${d.autopilotId ? '✓' : '–'}
+ `).join(''); + document.getElementById('offModalInfo').textContent = `${devs.length} Gerät(e)`; + document.getElementById('offConfirmChk').checked = false; + document.getElementById('offExecuteBtn').disabled = true; + document.getElementById('offResults').innerHTML = ''; + document.getElementById('offKeys').innerHTML = '
Recovery-Keys
Wird geladen…
'; + openModal('modalOffboard'); + offLoadKeys(devs); +} + +async function offLoadKeys(devs) { + const box = document.getElementById('offKeys'); + const parts = []; + for (const d of devs) { + try { + const res = await api('/api/offboard/keys', { + method: 'POST', + body: { intuneDeviceId: d.intuneDeviceId, azureADDeviceId: d.azureADDeviceId, operatingSystem: d.operatingSystem }, + timeoutMs: 60000, + }); + const k = res.keys || {}; + let html = `
${escapeHtml(d.deviceName || '—')}`; + (k.bitlocker || []).forEach(b => { html += `
BitLocker${b.volumeType ? ' (' + escapeHtml(b.volumeType) + ')' : ''}: ${escapeHtml(b.key)}
`; }); + if (k.fileVault) html += `
FileVault: ${escapeHtml(k.fileVault)}
`; + if (k.laps) html += `
LAPS${k.laps.account ? ' (' + escapeHtml(k.laps.account) + ')' : ''}: ${escapeHtml(k.laps.password)}
`; + if (k.notes && k.notes.length) html += `
${k.notes.map(escapeHtml).join(' · ')}
`; + html += '
'; + parts.push(html); + } catch (e) { + parts.push(`
${escapeHtml(d.deviceName || '—')} Keys-Fehler: ${escapeHtml(e.message)}
`); + } + } + box.innerHTML = `
🔑 Recovery-Keys — vor dem Löschen sichern!
${parts.join('')}`; +} + +async function offExecute() { + const devs = offSelectedDevices(); + if (!devs.length) return; + const services = { + entra: document.querySelector('input[name="offEntra"]:checked').value, + intune: document.getElementById('offSvcIntune').checked, + autopilot: document.getElementById('offSvcAutopilot').checked, + }; + if (services.entra === 'none' && !services.intune && !services.autopilot) { + toast('Keinen Dienst ausgewählt.', 'warn'); + return; + } + const payload = devs.map(d => ({ + deviceName: d.deviceName, intuneDeviceId: d.intuneDeviceId, + entraObjectId: d.entraObjectId, autopilotId: d.autopilotId, + })); + document.getElementById('offExecuteBtn').disabled = true; + setLoading('Offboarding läuft…'); + try { + const res = await api('/api/offboard/execute', { method: 'POST', body: { devices: payload, services }, timeoutMs: 180000 }); + const results = res.results || []; + document.getElementById('offResults').innerHTML = + `
Ergebnis (${res.successCount}/${res.total})
` + + results.map(r => `
${r.success ? '✓' : '✗'} ${escapeHtml(r.deviceName)} — ${escapeHtml(r.service)} ${escapeHtml(r.action)}${r.error ? ': ' + escapeHtml(r.error) : ''}
`).join(''); + toast(`${res.successCount}/${res.total} Aktionen erfolgreich.`, res.successCount === res.total ? 'ok' : 'warn', 'Offboarding'); + // Erfolgreich vollständig offgeboardete Geräte aus der Liste nehmen. + const doneNames = new Set(results.filter(r => r.success).map(r => r.deviceName)); + if (doneNames.size) { + OffState.items = OffState.items.filter(d => !doneNames.has(d.deviceName) || results.some(r => r.deviceName === d.deviceName && !r.success)); + OffState.selected = new Set(); + renderOff(); + } + } catch (e) { + toast('Offboarding fehlgeschlagen: ' + e.message, 'err'); + document.getElementById('offExecuteBtn').disabled = false; + } finally { + clearLoading(); + } +} + +document.getElementById('offSearchBtn')?.addEventListener('click', offSearch); +document.getElementById('offSearch')?.addEventListener('keydown', e => { if (e.key === 'Enter') offSearch(); }); +document.getElementById('offCheckAll')?.addEventListener('change', e => { + OffState.items.forEach(d => { const k = offKey(d); if (e.target.checked) OffState.selected.add(k); else OffState.selected.delete(k); }); + renderOff(); +}); +document.getElementById('offBtn')?.addEventListener('click', openOffboardModal); +document.getElementById('offConfirmChk')?.addEventListener('change', e => { + document.getElementById('offExecuteBtn').disabled = !e.target.checked; +}); +document.getElementById('offExecuteBtn')?.addEventListener('click', offExecute); diff --git a/www/index.html b/www/index.html index 546a442..b96adfe 100644 --- a/www/index.html +++ b/www/index.html @@ -37,6 +37,7 @@ +
@@ -611,6 +612,44 @@
+ + + + + +