diff --git a/.github/workflows/validate-script.yml b/.github/workflows/validate-script.yml new file mode 100644 index 0000000..9aa5a96 --- /dev/null +++ b/.github/workflows/validate-script.yml @@ -0,0 +1,92 @@ +name: Validate Script + +on: + pull_request: + workflow_dispatch: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Parse main script and playbooks + shell: pwsh + run: | + $failed = $false + foreach ($file in @('./DeviceOffboardingManager.ps1') + (Get-ChildItem ./Playbooks -Filter *.ps1 | ForEach-Object { $_.FullName })) { + $errs = $null + [System.Management.Automation.Language.Parser]::ParseFile($file, [ref]$null, [ref]$errs) | Out-Null + if ($errs.Count) { + $failed = $true + Write-Host "::error file=$file::Parse errors in $file" + $errs | ForEach-Object { Write-Host " line $($_.Extent.StartLineNumber): $($_.Message)" } + } + else { + Write-Host "PARSE OK: $file" + } + } + if ($failed) { exit 1 } + + - name: Validate XAML blocks are well-formed XML + shell: pwsh + run: | + $src = Get-Content ./DeviceOffboardingManager.ps1 + $failed = $false + $checked = 0 + for ($i = 0; $i -lt $src.Count; $i++) { + if ($src[$i] -match '^\s*\[xml\]\$\w+ = @(["''])$') { + $term = if ($Matches[1] -eq '"') { '"@' } else { "'@" } + for ($j = $i + 1; $j -lt $src.Count; $j++) { + if ($src[$j].StartsWith($term)) { break } + } + $body = $src[($i+1)..($j-1)] -join "`n" + try { + [xml]$body | Out-Null + Write-Host "XML OK: line $($i+1)" + $checked++ + } + catch { + $failed = $true + Write-Host "::error::XAML block at line $($i+1) is not well-formed: $($_.Exception.Message)" + } + $i = $j + } + } + Write-Host "Validated $checked XAML blocks" + if ($failed -or $checked -eq 0) { exit 1 } + + - name: Verify embedded playbooks match Playbooks directory + shell: pwsh + run: | + function Write-Log { param($Message, $Severity) } + $script:ConfigDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "dom-ci-extract" + New-Item -Path $script:ConfigDirectory -ItemType Directory -Force | Out-Null + + $src = Get-Content -Raw ./DeviceOffboardingManager.ps1 + $ast = [System.Management.Automation.Language.Parser]::ParseInput($src, [ref]$null, [ref]$null) + $assign = $ast.FindAll({param($n) $n -is [System.Management.Automation.Language.AssignmentStatementAst] -and $n.Left.Extent.Text -match "EmbeddedPlaybooks"}, $false) + Invoke-Expression $assign[0].Extent.Text + + $diskFiles = Get-ChildItem ./Playbooks -Filter *.ps1 | ForEach-Object { $_.Name } | Sort-Object + $embeddedNames = @($script:EmbeddedPlaybooks.Keys) | Sort-Object + $missing = @($diskFiles | Where-Object { $embeddedNames -notcontains $_ }) + if ($missing) { + Write-Host "::error::Playbooks missing from the embedded bundle: $($missing -join ', '). Regenerate the embedded block." + exit 1 + } + + $failed = $false + foreach ($name in $embeddedNames) { + $disk = (Get-Content -Raw (Join-Path ./Playbooks $name)) -replace "`r`n", "`n" + $embedded = $script:EmbeddedPlaybooks[$name] -replace "`r`n", "`n" + if ($disk.TrimEnd() -ne $embedded.TrimEnd()) { + $failed = $true + Write-Host "::error::Embedded copy of $name is out of sync with Playbooks/$name. Regenerate the embedded block." + } + else { + Write-Host "IN SYNC: $name" + } + } + if ($failed) { exit 1 } diff --git a/Changelog.md b/Changelog.md index d0aafe6..67471c7 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,53 @@ +## Version 0.3.0 - 7/7/2026 + +> **Note**: This is the final release of the PowerShell script. Version 0.4 will be a native Windows app (WinUI 3) that replaces the script. See [Issue #60](https://github.com/ugurkocde/DeviceOffboardingManager/issues/60). + +### Bug Fixes +- **Actionable 403 Forbidden Messages** (Issues #52, #53): When an offboarding operation is denied with 403, the summary now explains which directory/Intune role is likely missing (e.g. Cloud Device Administrator, Intune Administrator) instead of showing a raw HTTP error. Multi-Admin Approval requirements are detected and reported separately. The README documents the required roles per operation. +- **Dashboard Card Loading Feedback**: Clicking a dashboard statistic card now shows an immediate loading indicator (wait cursor + toast) before the device list is fetched, instead of the window appearing to freeze. +- **Recovery Key Copy No Longer Crashes the App** (Issue #38): Clipboard failures (e.g. in RDP sessions) while copying a BitLocker/FileVault key are now caught and shown on the button instead of escaping the event handler and destabilizing the offboarding dialogs. +- **OData Filter Escaping**: Search text and device-derived values (names, serials) are now escaped before being interpolated into Graph `$filter` expressions, so device names containing apostrophes no longer break search queries. +- **Fixed 8 Critical Bugs** across device offboarding and playbook execution + - Fixed Autopilot property typo (`lastContactDateTime` -> `lastContactedDateTime`) + - Fixed BitLocker key retrieval array bug: access first element explicitly + - Fixed stale `parsedDevices` not clearing on bulk import cancel + - Fixed status string corruption from counter increment inside PSCustomObject + - Fixed permission scopes: `Device.ReadWrite.All`, added `BitlockerKey.Read.All` + - Fixed export functions to use actual DeviceObject class properties + - Fixed playbook result columns to generate dynamically from actual output schema +- **Local Playbook Execution**: Playbooks now load from local `Playbooks/` directory instead of downloading from GitHub URLs at runtime +- **Playbooks Work with `Install-Script`** (Issue #15): The script now bundles all playbooks internally and extracts them to `%LocalAppData%/DeviceOffboardingManager/Playbooks` when the local `Playbooks/` directory is absent (PowerShell Gallery installs only deliver the single `.ps1`). Also replaces 3-argument `Join-Path` calls that fail on Windows PowerShell 5.1. + +### Graph API Performance and Reliability +- **Retry Logic with Backoff**: New `Invoke-GraphRequestWithRetry` wrapper handles 429 throttling (reads Retry-After header), 5xx transient errors (exponential backoff), and network failures +- **Batch Requests**: New `Invoke-GraphBatchRequest` helper auto-chunks up to 20 sub-requests per `$batch` call with sub-request retry logic + - Search queries batch Entra+Intune (device name) and Intune+Autopilot (serial number) lookups + - Per-device offboarding batches Entra+Intune+Autopilot operations into a single `$batch` call +- **Dashboard Statistics via `$count`**: Single `$batch` call with `$count` sub-requests replaces 3 full-collection fetches and client-side counting, with automatic fallback for tenants without `$count` support +- **Bulk Autopilot Deletion**: Uses `deleteDevices` bulk endpoint when 2+ devices are selected, with individual deletion fallback +- **Migrated All Endpoints to Beta**: All v1.0 Graph API references replaced with beta across the main script and all playbooks +- **Added `$select` to All GET Calls**: Every GET endpoint now specifies only the properties used downstream, reducing API payload size + +### New Features +- **Device Code Authentication** (Issue #59): Added a "Device Code Login (No Browser Redirect)" option to the authentication dialog to avoid browser localhost redirect / WAM issues with interactive Microsoft Graph authentication. +- **Saved Authentication Config** (Issue #48): Save and auto-load certificate and client secret configurations to `%LocalAppData%/DeviceOffboardingManager`. Client secret is never persisted for security. +- **Co-Management Awareness**: Displays `ManagementAgent` property on devices and shows an amber warning banner in the confirmation dialog for co-managed devices +- **Platform Filtering on Dashboard** (Issue #40): ComboBox to filter all dashboard statistics by OS platform +- **Grid Filtering and Shift-Click Range Selection** (Issue #33): Filter TextBoxes above the DataGrid for live column filtering, shift-click on checkboxes to toggle device ranges +- **HTML Offboarding Report Generation**: Professional styled HTML reports with per-device service status and summary statistics, accessible via export buttons in summary and dashboard dialogs +- **Defender for Endpoint (settings-gated)** (Issue #11): Optional Defender for Endpoint offboarding, disabled by default. Enable it from the Prerequisites dialog; only then does Defender appear as an offboarding target and request a separate Defender API token. Supports app-only token acquisition (client secret and certificate auth) plus delegated silent/interactive fallback across both Defender API resource endpoints. Requires `WindowsDefenderATP` permissions (`Machine.ReadWrite.All`, `Machine.Offboard`) and the optional MSAL.PS module. + +### New Playbooks +- **Playbook 6: OS-Specific Devices** -- Filter and list managed devices by operating system +- **Playbook 7: Outdated OS Devices** -- Identify devices running outdated OS versions +- **Playbook 8: End-of-Life OS Devices** -- Detect devices running end-of-life OS versions +- **Playbook 9: BitLocker Key Report** -- Retrieve BitLocker recovery key metadata for Windows devices +- **Playbook 10: FileVault Key Report** -- Check FileVault key availability for macOS devices +- **Playbook 11: Corporate Identifier Stale Report** (Issue #41) -- List imported corporate device identifiers with enrollment state and last contact staleness +- **Shared Playbook Helpers**: Extracted common utility functions (`Get-GraphPagedResults`, `ConvertTo-SafeDateTime`, etc.) into `PlaybookHelpers.ps1` to reduce duplication across playbooks + +--- + ## Version 0.2.2 - 7/26/2025 - **Fixed Autopilot Device Removal by Serial Number**: Enhanced offboarding process to properly retrieve and use serial numbers for Autopilot device removal (Issue #45) diff --git a/DeviceOffboardingManager.ps1 b/DeviceOffboardingManager.ps1 index 7727f1f..603ba8e 100644 --- a/DeviceOffboardingManager.ps1 +++ b/DeviceOffboardingManager.ps1 @@ -1,6 +1,6 @@ <#PSScriptInfo -.VERSION 0.2.2 +.VERSION 0.3.0 .GUID a686724d-588d-472e-b927-c4840c32eed1 @@ -37,7 +37,11 @@ A PowerShell GUI tool for efficiently managing and offboarding devices from Microsoft Intune, Autopilot, and Entra ID, featuring bulk operations and real-time analytics for streamlined device lifecycle management. #> -Param() +Param( + [switch]$Verbose +) + +$script:VerboseMode = $Verbose.IsPresent #Requires -Version 7.0 #Requires -Modules Microsoft.Graph.Authentication @@ -117,13 +121,7 @@ function Update-VersionDisplays { $updateStatus.Text = "Update available" $updateStatus.Foreground = "#4FD1C5" # Highlight newer version $updateStatus.Cursor = "Hand" - - # Remove existing handler if any - $updateStatus.RemoveHandler( - [System.Windows.Controls.TextBlock]::MouseDownEvent, - [System.Windows.Input.MouseButtonEventHandler] { param($s, $e) } - ) - + # Add click handler $updateStatus.AddHandler( [System.Windows.Controls.TextBlock]::MouseDownEvent, @@ -136,24 +134,12 @@ function Update-VersionDisplays { $updateStatus.Text = "No Update available" $updateStatus.Foreground = "#A0A0A0" # Default gray color $updateStatus.Cursor = "Arrow" - - # Remove click handler if exists - $updateStatus.RemoveHandler( - [System.Windows.Controls.TextBlock]::MouseDownEvent, - [System.Windows.Input.MouseButtonEventHandler] { param($s, $e) } - ) } } else { $updateStatus.Text = "Version check unavailable" $updateStatus.Foreground = "#A0A0A0" $updateStatus.Cursor = "Arrow" - - # Remove click handler if exists - $updateStatus.RemoveHandler( - [System.Windows.Controls.TextBlock]::MouseDownEvent, - [System.Windows.Input.MouseButtonEventHandler] { param($s, $e) } - ) } } } @@ -185,6 +171,16 @@ if (-not ([System.Management.Automation.PSTypeName]'DeviceObject').Type) { public DateTime? IntuneLastContact { get; set; } public DateTime? AutopilotLastContact { get; set; } + // Graph IDs captured at search time for safe ID-based offboarding + public string EntraDeviceId { get; set; } // Entra object id for DELETE /devices/{id} + public string EntraDeviceObjectId { get; set; } // deviceId property (for BitLocker lookup) + public string IntuneDeviceId { get; set; } // Intune managed device id + public string AutopilotIdentityId { get; set; } // Autopilot identity id + public string EntraAccountEnabled { get; set; } // "True"/"False"/null for disable feature + public string ComplianceState { get; set; } // Intune compliance state + public string MdeDeviceId { get; set; } // Defender for Endpoint machine id + public string ManagementAgent { get; set; } // mdm, configurationManagerClientMdm, etc. + public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged(string name) @@ -199,15 +195,16 @@ if (-not ([System.Management.Automation.PSTypeName]'DeviceObject').Type) { function Get-GraphPagedResults { param( [Parameter(Mandatory = $true)] - [string]$Uri + [string]$Uri, + [hashtable]$Headers = @{} ) - + $results = @() $nextLink = $Uri - + do { try { - $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET + $response = Invoke-GraphRequestWithRetry -Uri $nextLink -Method GET -Headers $Headers if ($response.value) { $results += $response.value } @@ -218,10 +215,148 @@ function Get-GraphPagedResults { break } } while ($nextLink) - + return $results } +# Retry wrapper for Graph API calls -- handles HTTP 429 (throttling) and transient 5xx errors +function Invoke-GraphRequestWithRetry { + param( + [Parameter(Mandatory = $true)] + [string]$Uri, + [string]$Method = "GET", + [string]$Body, + [string]$ContentType = "application/json", + [hashtable]$Headers = @{}, + [int]$MaxRetries = 3, + [int]$BaseDelaySeconds = 2 + ) + + $attempt = 0 + while ($true) { + try { + $params = @{ + Uri = $Uri + Method = $Method + } + if ($Headers.Count -gt 0) { $params.Headers = $Headers } + if ($Body) { + $params.Body = $Body + $params.ContentType = $ContentType + } + return Invoke-MgGraphRequest @params + } + catch { + $attempt++ + $statusCode = $null + if ($_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + } + + # Throttled (429) + if ($statusCode -eq 429) { + if ($attempt -gt $MaxRetries) { throw } + $retryAfter = $BaseDelaySeconds + if ($_.Exception.Response.Headers -and $_.Exception.Response.Headers['Retry-After']) { + $retryAfter = [int]$_.Exception.Response.Headers['Retry-After'] + } + Write-Log "Throttled (429) on $Method $Uri -- retrying in ${retryAfter}s (attempt $attempt/$MaxRetries)" -Severity "WARN" + Start-Sleep -Seconds $retryAfter + continue + } + + # Transient server errors (500-599) or network-level failures (null status) + if ($null -eq $statusCode -or ($statusCode -ge 500 -and $statusCode -lt 600)) { + if ($attempt -gt $MaxRetries) { throw } + $delay = $BaseDelaySeconds * [Math]::Pow(2, $attempt - 1) + Write-Log "Server error ($statusCode) on $Method $Uri -- retrying in ${delay}s (attempt $attempt/$MaxRetries)" -Severity "WARN" + Start-Sleep -Seconds $delay + continue + } + + # Non-retryable error + throw + } + } +} + +# Batch helper -- sends up to 20 sub-requests per POST /$batch, auto-chunks larger sets +function Invoke-GraphBatchRequest { + param( + [Parameter(Mandatory = $true)] + [array]$Requests + ) + + $allResponses = @() + $chunkSize = 20 + + for ($i = 0; $i -lt $Requests.Count; $i += $chunkSize) { + $end = [Math]::Min($i + $chunkSize, $Requests.Count) - 1 + $chunk = $Requests[$i..$end] + + $batchBody = @{ requests = $chunk } | ConvertTo-Json -Depth 10 + $batchResponse = Invoke-GraphRequestWithRetry -Uri "https://graph.microsoft.com/beta/`$batch" -Method POST -Body $batchBody -ContentType "application/json" + + if ($batchResponse.responses) { + # Retry individual sub-requests that returned 429 or 5xx + $retryable = $batchResponse.responses | Where-Object { $_.status -eq 429 -or ($_.status -ge 500 -and $_.status -lt 600) } + $successful = $batchResponse.responses | Where-Object { $_.status -lt 429 -or ($_.status -gt 429 -and $_.status -lt 500) -or $_.status -ge 600 } + $allResponses += $successful + + $retryAttempt = 0 + while ($retryable -and $retryAttempt -lt 3) { + $retryAttempt++ + $delay = 2 * [Math]::Pow(2, $retryAttempt - 1) + Write-Log "Batch: retrying $($retryable.Count) sub-requests (attempt $retryAttempt/3)" -Severity "WARN" + Start-Sleep -Seconds $delay + + $retryRequests = foreach ($resp in $retryable) { + $chunk | Where-Object { $_.id -eq $resp.id } + } + $retryBody = @{ requests = @($retryRequests) } | ConvertTo-Json -Depth 10 + $retryResponse = Invoke-GraphRequestWithRetry -Uri "https://graph.microsoft.com/beta/`$batch" -Method POST -Body $retryBody -ContentType "application/json" + + if ($retryResponse.responses) { + $retryable = $retryResponse.responses | Where-Object { $_.status -eq 429 -or ($_.status -ge 500 -and $_.status -lt 600) } + $newSuccessful = $retryResponse.responses | Where-Object { $_.status -lt 429 -or ($_.status -gt 429 -and $_.status -lt 500) -or $_.status -ge 600 } + $allResponses += $newSuccessful + } else { + break + } + } + # If still retryable after max attempts, add them as-is + if ($retryable) { + $allResponses += $retryable + } + } + } + + return $allResponses +} + +# Maps a 403 Forbidden response during offboarding to an actionable message. +# Root cause is almost always a missing directory/Intune role rather than a +# missing Graph permission scope (Issues #52, #53). +function Get-Graph403Message { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('EntraID', 'Intune', 'Autopilot')] + [string]$Service + ) + + switch ($Service) { + 'EntraID' { + "Access denied (403). Deleting Entra ID devices requires the Cloud Device Administrator or Intune Administrator directory role in addition to the Device.ReadWrite.All permission. See the 'Required Roles' section in the README." + } + 'Intune' { + "Access denied (403). Deleting Intune devices requires the Intune Administrator role (or an Intune RBAC role with 'Managed devices - Delete') in addition to the DeviceManagementManagedDevices.ReadWrite.All permission. See the 'Required Roles' section in the README." + } + 'Autopilot' { + "Access denied (403). Deleting Autopilot identities requires the Intune Administrator role in addition to the DeviceManagementServiceConfig.ReadWrite.All permission. See the 'Required Roles' section in the README." + } + } +} + # Helper function to safely convert date strings to DateTime objects function ConvertTo-SafeDateTime { param( @@ -285,7 +420,8 @@ function ConvertTo-SafeDateTime { Title="Device Offboarding Manager (Preview)" Height="700" Width="1200" Background="#F0F0F0" WindowStartupLocation="CenterScreen" - ResizeMode="NoResize"> + ResizeMode="CanResize" + MinHeight="600" MinWidth="900"> @@ -307,8 +443,8 @@ function ConvertTo-SafeDateTime { - @@ -331,6 +467,7 @@ function ConvertTo-SafeDateTime { + @@ -374,7 +511,7 @@ function ConvertTo-SafeDateTime { @@ -432,6 +569,9 @@ function ConvertTo-SafeDateTime { Visibility="Collapsed"/> + + + @@ -597,23 +737,6 @@ function ConvertTo-SafeDateTime { - - - - - - - - - + + + + + + @@ -1777,92 +2217,121 @@ function ConvertTo-SafeDateTime { Foreground="#323130" Margin="0,0,0,10"/> + Opacity="0.7" + TextWrapping="Wrap"/> - - + + - -