From 2ebe9cf64d055829bf72564102cf6fbdbfbc74dd Mon Sep 17 00:00:00 2001 From: Ugur Koc Date: Fri, 13 Mar 2026 19:39:25 +0100 Subject: [PATCH 01/16] Fix 8 critical bugs in device offboarding and playbook execution - Fix Autopilot property typo: lastContactDateTime -> lastContactedDateTime - Fix BitLocker key retrieval array bug: access first element explicitly - Clear stale parsedDevices on bulk import cancel - Fix status string corruption from $deviceSuccess++ inside PSCustomObject - Fix permission scopes: Device.ReadWrite.All, add BitlockerKey.Read.All - Fix export functions to use actual DeviceObject class properties - Load playbooks from local Playbooks/ directory instead of remote GitHub URLs - Generate playbook result columns dynamically from actual output schema Co-Authored-By: Claude Opus 4.6 (1M context) --- DeviceOffboardingManager.ps1 | 206 +++++++++++++++++------------------ Playbooks/Playbook_1.ps1 | 2 +- 2 files changed, 102 insertions(+), 106 deletions(-) diff --git a/DeviceOffboardingManager.ps1 b/DeviceOffboardingManager.ps1 index 7727f1f..ea37e2d 100644 --- a/DeviceOffboardingManager.ps1 +++ b/DeviceOffboardingManager.ps1 @@ -2749,12 +2749,16 @@ $script:requiredPermissions = @( Reason = "Required to read and modify managed device information and compliance policies" }, @{ - Permission = "Device.Read.All" - Reason = "Needed to read device information from Entra ID" + Permission = "Device.ReadWrite.All" + Reason = "Needed to read and delete device objects from Entra ID" }, @{ Permission = "DeviceManagementServiceConfig.ReadWrite.All" Reason = "Required for Autopilot configuration and management" + }, + @{ + Permission = "BitlockerKey.Read.All" + Reason = "Required to read BitLocker recovery keys for device offboarding" } ) @@ -3096,6 +3100,7 @@ LAPTOP-XYZ789 # Cancel button handler $cancelButton.Add_Click({ + $script:parsedDevices = @() $bulkImportWindow.DialogResult = $false $bulkImportWindow.Close() }) @@ -4039,7 +4044,7 @@ $OffboardButton.Add_Click({ if ($keyIdResponse.Count -gt 0) { # Get the actual key using the key ID from the first recovery key - $recoveryKeyId = $keyIdResponse.id + $recoveryKeyId = $keyIdResponse[0].id $uri = "https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys/$($recoveryKeyId)?`$select=key" $recoveryKeyData = Invoke-MgGraphRequest -Uri $uri -Method GET @@ -4472,18 +4477,16 @@ $ExportSearchResultsButton.Add_Click({ $exportData = @() foreach ($device in $results) { $exportData += [PSCustomObject]@{ - DeviceName = $device.DeviceName - SerialNumber = $device.SerialNumber - LastContact = $device.LastContact - OperatingSystem = $device.OperatingSystem - OSVersion = $device.OSVersion - PrimaryUser = $device.PrimaryUser - IntuneStatus = $device.IntuneStatus - AutopilotStatus = $device.AutopilotStatus - EntraIDStatus = $device.EntraIDStatus + DeviceName = $device.DeviceName + SerialNumber = $device.SerialNumber + OperatingSystem = $device.OperatingSystem + PrimaryUser = $device.PrimaryUser + AzureADLastContact = $device.AzureADLastContact + IntuneLastContact = $device.IntuneLastContact + AutopilotLastContact = $device.AutopilotLastContact } } - + Export-DeviceListToCSV -DeviceList $exportData -DefaultFileName $fileName } else { @@ -4508,18 +4511,16 @@ $ExportSelectedButton.Add_Click({ $exportData = @() foreach ($device in $selectedDevices) { $exportData += [PSCustomObject]@{ - DeviceName = $device.DeviceName - SerialNumber = $device.SerialNumber - LastContact = $device.LastContact - OperatingSystem = $device.OperatingSystem - OSVersion = $device.OSVersion - PrimaryUser = $device.PrimaryUser - IntuneStatus = $device.IntuneStatus - AutopilotStatus = $device.AutopilotStatus - EntraIDStatus = $device.EntraIDStatus + DeviceName = $device.DeviceName + SerialNumber = $device.SerialNumber + OperatingSystem = $device.OperatingSystem + PrimaryUser = $device.PrimaryUser + AzureADLastContact = $device.AzureADLastContact + IntuneLastContact = $device.IntuneLastContact + AutopilotLastContact = $device.AutopilotLastContact } } - + Export-DeviceListToCSV -DeviceList $exportData -DefaultFileName $fileName } else { @@ -4689,51 +4690,60 @@ function Show-OffboardingSummary { foreach ($result in $Results) { $deviceSuccess = 0 $deviceTotal = 0 - + + # Pre-compute skip flags and count successes outside PSCustomObject to avoid $deviceSuccess++ polluting the pipeline + $entraIDSkipped = $script:serviceCheckboxes -and $script:serviceCheckboxes["Entra ID"] -and -not $script:serviceCheckboxes["Entra ID"].IsChecked + $intuneSkipped = $script:serviceCheckboxes -and $script:serviceCheckboxes["Intune"] -and -not $script:serviceCheckboxes["Intune"].IsChecked + $autopilotSkipped = $script:serviceCheckboxes -and $script:serviceCheckboxes["Autopilot"] -and -not $script:serviceCheckboxes["Autopilot"].IsChecked + + if (-not $entraIDSkipped -and $result.EntraID.Found -and $result.EntraID.Success) { $deviceSuccess++ } + if (-not $intuneSkipped -and $result.Intune.Found -and $result.Intune.Success) { $deviceSuccess++ } + if (-not $autopilotSkipped -and $result.Autopilot.Found -and $result.Autopilot.Success) { $deviceSuccess++ } + # Create display object for this device $displayResult = [PSCustomObject]@{ DeviceName = $result.DeviceName SerialNumber = if ($result.SerialNumber) { $result.SerialNumber } else { "N/A" } - + # Entra ID - EntraIDStatus = if ($script:serviceCheckboxes -and $script:serviceCheckboxes["Entra ID"] -and -not $script:serviceCheckboxes["Entra ID"].IsChecked) { + EntraIDStatus = if ($entraIDSkipped) { "Skipped" } elseif ($result.EntraID.Found) { - if ($result.EntraID.Success) { "✓ Removed"; $deviceSuccess++ } else { "✗ Failed" } + if ($result.EntraID.Success) { "✓ Removed" } else { "✗ Failed" } } else { "Not Found" } - EntraIDColor = if ($script:serviceCheckboxes -and $script:serviceCheckboxes["Entra ID"] -and -not $script:serviceCheckboxes["Entra ID"].IsChecked) { + EntraIDColor = if ($entraIDSkipped) { "#A0AEC0" } elseif (!$result.EntraID.Found) { "#718096" } elseif ($result.EntraID.Success) { "#48BB78" } else { "#F56565" } EntraIDError = $result.EntraID.Error EntraIDErrorVisibility = if ($result.EntraID.Error) { "Visible" } else { "Collapsed" } - + # Intune - IntuneStatus = if ($script:serviceCheckboxes -and $script:serviceCheckboxes["Intune"] -and -not $script:serviceCheckboxes["Intune"].IsChecked) { + IntuneStatus = if ($intuneSkipped) { "Skipped" } elseif ($result.Intune.Found) { - if ($result.Intune.Success) { "✓ Removed"; $deviceSuccess++ } else { "✗ Failed" } + if ($result.Intune.Success) { "✓ Removed" } else { "✗ Failed" } } else { "Not Found" } - IntuneColor = if ($script:serviceCheckboxes -and $script:serviceCheckboxes["Intune"] -and -not $script:serviceCheckboxes["Intune"].IsChecked) { + IntuneColor = if ($intuneSkipped) { "#A0AEC0" } elseif (!$result.Intune.Found) { "#718096" } elseif ($result.Intune.Success) { "#48BB78" } else { "#F56565" } IntuneError = $result.Intune.Error IntuneErrorVisibility = if ($result.Intune.Error) { "Visible" } else { "Collapsed" } - + # Autopilot - AutopilotStatus = if ($script:serviceCheckboxes -and $script:serviceCheckboxes["Autopilot"] -and -not $script:serviceCheckboxes["Autopilot"].IsChecked) { + AutopilotStatus = if ($autopilotSkipped) { "Skipped" } elseif ($result.Autopilot.Found) { - if ($result.Autopilot.Success) { "✓ Removed"; $deviceSuccess++ } else { "✗ Failed" } + if ($result.Autopilot.Success) { "✓ Removed" } else { "✗ Failed" } } else { "Not Found" } - AutopilotColor = if ($script:serviceCheckboxes -and $script:serviceCheckboxes["Autopilot"] -and -not $script:serviceCheckboxes["Autopilot"].IsChecked) { + AutopilotColor = if ($autopilotSkipped) { "#A0AEC0" } elseif (!$result.Autopilot.Found) { "#718096" } elseif ($result.Autopilot.Success) { "#48BB78" } else { "#F56565" } @@ -5646,24 +5656,24 @@ foreach ($button in $PlaybookButtons) { switch ($playbookName) { "Autopilot Devices Not in Intune" { - $playbookUrl = "https://raw.githubusercontent.com/ugurkocde/DeviceOffboardingManager/refs/heads/main/Playbooks/Playbook_1.ps1" - Invoke-Playbook -PlaybookName $playbookName -PlaybookUrl $playbookUrl -Description $playbookDescription + $playbookPath = Join-Path $PSScriptRoot "Playbooks" "Playbook_1.ps1" + Invoke-Playbook -PlaybookName $playbookName -PlaybookPath $playbookPath -Description $playbookDescription } "Intune Devices Not in Autopilot" { - $playbookUrl = "https://raw.githubusercontent.com/ugurkocde/DeviceOffboardingManager/refs/heads/main/Playbooks/Playbook_2.ps1" - Invoke-Playbook -PlaybookName $playbookName -PlaybookUrl $playbookUrl -Description $playbookDescription + $playbookPath = Join-Path $PSScriptRoot "Playbooks" "Playbook_2.ps1" + Invoke-Playbook -PlaybookName $playbookName -PlaybookPath $playbookPath -Description $playbookDescription } "Corporate Device Inventory" { - $playbookUrl = "https://raw.githubusercontent.com/ugurkocde/DeviceOffboardingManager/refs/heads/main/Playbooks/Playbook_3.ps1" - Invoke-Playbook -PlaybookName $playbookName -PlaybookUrl $playbookUrl -Description $playbookDescription + $playbookPath = Join-Path $PSScriptRoot "Playbooks" "Playbook_3.ps1" + Invoke-Playbook -PlaybookName $playbookName -PlaybookPath $playbookPath -Description $playbookDescription } "Personal Device Inventory" { - $playbookUrl = "https://raw.githubusercontent.com/ugurkocde/DeviceOffboardingManager/refs/heads/main/Playbooks/Playbook_4.ps1" - Invoke-Playbook -PlaybookName $playbookName -PlaybookUrl $playbookUrl -Description $playbookDescription + $playbookPath = Join-Path $PSScriptRoot "Playbooks" "Playbook_4.ps1" + Invoke-Playbook -PlaybookName $playbookName -PlaybookPath $playbookPath -Description $playbookDescription } "Stale Device Report" { - $playbookUrl = "https://raw.githubusercontent.com/ugurkocde/DeviceOffboardingManager/refs/heads/main/Playbooks/Playbook_5.ps1" - Invoke-Playbook -PlaybookName $playbookName -PlaybookUrl $playbookUrl -Description $playbookDescription + $playbookPath = Join-Path $PSScriptRoot "Playbooks" "Playbook_5.ps1" + Invoke-Playbook -PlaybookName $playbookName -PlaybookPath $playbookPath -Description $playbookDescription } default { [System.Windows.MessageBox]::Show( @@ -5871,39 +5881,38 @@ function Show-PlaybookProgressModal { function Invoke-Playbook { param( [string]$PlaybookName, - [string]$PlaybookUrl, + [string]$PlaybookPath, [string]$Description ) - + try { Write-Log "Starting execution of playbook: $PlaybookName" - + # Show progress modal $progressWindow = Show-PlaybookProgressModal -PlaybookName $PlaybookName -Description $Description $status = $progressWindow.FindName('StatusMessage') $errorSection = $progressWindow.FindName('ErrorSection') $errorMessage = $progressWindow.FindName('ErrorMessage') $closeButton = $progressWindow.FindName('CloseButton') - + # Show the progress window and bring it to front $progressWindow.Show() $progressWindow.Activate() - - # Download playbook - $status.Text = "Downloading playbook script..." - Write-Log "Downloading playbook from: $PlaybookUrl" - - $playbookPath = ".\Playbook_1.ps1" - + + # Verify playbook exists locally + $status.Text = "Loading playbook script..." + Write-Log "Loading playbook from: $PlaybookPath" + try { - Invoke-WebRequest -Uri $PlaybookUrl -OutFile $playbookPath -ErrorAction Stop - Write-Log "Playbook downloaded successfully to: $playbookPath" - + if (-not (Test-Path $PlaybookPath)) { + throw "Playbook file not found: $PlaybookPath" + } + # Execute playbook $status.Text = "Executing playbook..." - Write-Log "Executing playbook: $playbookPath" - - $rawResults = & $playbookPath + Write-Log "Executing playbook: $PlaybookPath" + + $rawResults = & $PlaybookPath # Filter out only the actual device objects $results = $rawResults | Where-Object { @@ -5916,55 +5925,42 @@ function Invoke-Playbook { $status.Text = "Processing results..." if ($results) { - # Create device objects - $deviceObjects = $results | ForEach-Object { - [PSCustomObject]@{ - DeviceName = $_.DeviceName - SerialNumber = $_.SerialNumber - OperatingSystem = $_.OperatingSystem - PrimaryUser = $_.PrimaryUser - AutopilotLastContact = $_.AutopilotLastContact - } - } - - # Update the DataGrid with results + # Update the DataGrid with results -- pass playbook output directly $PlaybookResultsDataGrid.Dispatcher.Invoke([Action] { - + # Clear existing results $PlaybookResultsDataGrid.ItemsSource = $null - - # Add each device to the collection + + # Add each result to the collection $collection = New-Object System.Collections.ObjectModel.ObservableCollection[object] - foreach ($device in $deviceObjects) { + foreach ($device in $results) { $collection.Add($device) } - # Configure DataGrid columns for playbook results + + # Build columns dynamically from the first result's properties $PlaybookResultsDataGrid.Columns.Clear() - $PlaybookResultsDataGrid.Columns.Add((New-Object System.Windows.Controls.DataGridTextColumn -Property @{ - Header = "Device Name" - Binding = New-Object System.Windows.Data.Binding("DeviceName") - Width = "Auto" - })) - $PlaybookResultsDataGrid.Columns.Add((New-Object System.Windows.Controls.DataGridTextColumn -Property @{ - Header = "Serial Number" - Binding = New-Object System.Windows.Data.Binding("SerialNumber") - Width = "Auto" - })) - $PlaybookResultsDataGrid.Columns.Add((New-Object System.Windows.Controls.DataGridTextColumn -Property @{ - Header = "Operating System" - Binding = New-Object System.Windows.Data.Binding("OperatingSystem") - Width = "Auto" - })) - $PlaybookResultsDataGrid.Columns.Add((New-Object System.Windows.Controls.DataGridTextColumn -Property @{ - Header = "Primary User" - Binding = New-Object System.Windows.Data.Binding("PrimaryUser") - Width = "Auto" - })) - $PlaybookResultsDataGrid.Columns.Add((New-Object System.Windows.Controls.DataGridTextColumn -Property @{ - Header = "Last Contact" - Binding = New-Object System.Windows.Data.Binding("AutopilotLastContact") - Width = "Auto" - })) + $columnHeaders = @{ + DeviceName = "Device Name" + SerialNumber = "Serial Number" + OperatingSystem = "Operating System" + PrimaryUser = "Primary User" + AzureADLastContact = "Entra ID Last Contact" + IntuneLastContact = "Intune Last Contact" + AutopilotLastContact = "Autopilot Last Contact" + ComplianceState = "Compliance State" + EnrollmentDate = "Enrollment Date" + LastSyncDateTime = "Last Sync" + Ownership = "Ownership" + } + $firstResult = $results | Select-Object -First 1 + foreach ($prop in $firstResult.PSObject.Properties) { + $header = if ($columnHeaders.ContainsKey($prop.Name)) { $columnHeaders[$prop.Name] } else { $prop.Name } + $PlaybookResultsDataGrid.Columns.Add((New-Object System.Windows.Controls.DataGridTextColumn -Property @{ + Header = $header + Binding = New-Object System.Windows.Data.Binding($prop.Name) + Width = "Auto" + })) + } # Set the ItemsSource $PlaybookResultsDataGrid.ItemsSource = $collection diff --git a/Playbooks/Playbook_1.ps1 b/Playbooks/Playbook_1.ps1 index 2b80742..510e7c9 100644 --- a/Playbooks/Playbook_1.ps1 +++ b/Playbooks/Playbook_1.ps1 @@ -113,7 +113,7 @@ function Get-AutopilotNotIntuneDevices { SerialNumber = $_.serialNumber OperatingSystem = "$($_.model) ($($_.manufacturer))" PrimaryUser = "Not enrolled" - AutopilotLastContact = ConvertTo-SafeDateTime -dateString $_.lastContactDateTime + AutopilotLastContact = ConvertTo-SafeDateTime -dateString $_.lastContactedDateTime } } From b5e8c64e7ef33c42bf918cf44b88bc6a8d7a65f5 Mon Sep 17 00:00:00 2001 From: Ugur Koc Date: Fri, 13 Mar 2026 20:44:59 +0100 Subject: [PATCH 02/16] Phase 3: Graph API performance and reliability improvements - Add Invoke-GraphRequestWithRetry wrapper for 429 throttling (reads Retry-After header), 5xx transient errors (exponential backoff), and network-level failures - Add Invoke-GraphBatchRequest helper that auto-chunks up to 20 sub-requests per POST /$batch with sub-request retry logic - Update Get-GraphPagedResults to use retry wrapper and accept optional Headers parameter - Migrate all v1.0 endpoints to beta with $select across main script and Playbooks 1-5 to reduce API payload size - Rewrite dashboard statistics to use $count batch (1 API call replaces 3 full-collection fetches + client-side counting), with automatic fallback to full-fetch for tenants without $count support - Batch Entra+Intune search queries for device name lookups and Intune+Autopilot queries for serial number lookups - Hoist Autopilot full-collection fetch out of per-search-term loop - Batch per-device offboarding operations (Entra+Intune+Autopilot) into single $batch call per device - Add bulk Autopilot deletion via deleteDevices endpoint using serial numbers when 2+ devices selected, with individual deletion fallback and per-device status parsing from API response --- DeviceOffboardingManager.ps1 | 1386 +++++++++++++++++++++------------- Playbooks/Playbook_1.ps1 | 4 +- Playbooks/Playbook_2.ps1 | 4 +- Playbooks/Playbook_3.ps1 | 2 +- Playbooks/Playbook_4.ps1 | 2 +- Playbooks/Playbook_5.ps1 | 2 +- tasks/todo.md | 100 +++ 7 files changed, 961 insertions(+), 539 deletions(-) create mode 100644 tasks/todo.md diff --git a/DeviceOffboardingManager.ps1 b/DeviceOffboardingManager.ps1 index ea37e2d..8629ffd 100644 --- a/DeviceOffboardingManager.ps1 +++ b/DeviceOffboardingManager.ps1 @@ -185,6 +185,13 @@ 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 event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged(string name) @@ -199,15 +206,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 +226,125 @@ 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 +} + # Helper function to safely convert date strings to DateTime objects function ConvertTo-SafeDateTime { param( @@ -3200,14 +3323,22 @@ function Connect-ToGraph { throw "Failed to get Microsoft Graph context after connection" } + # Capture admin identity for audit logging + if ($context.Account) { + $script:AdminUPN = $context.Account + } else { + $script:AdminUPN = "AppId:$($context.ClientId)" + } + Write-Log "Authenticated as $($script:AdminUPN)" -Severity "AUDIT" + # Get tenant details and update UI try { Write-Log "Retrieving tenant information..." - $tenantInfo = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/organization" -Method GET + $tenantInfo = Invoke-GraphRequestWithRetry -Uri "https://graph.microsoft.com/beta/organization?`$select=displayName,id,verifiedDomains" -Method GET if ($tenantInfo.value) { $org = $tenantInfo.value[0] Write-Log "Found tenant: $($org.displayName)" - + # Update UI elements $Window.FindName('TenantDisplayName').Text = $org.displayName $Window.FindName('TenantId').Text = $org.id @@ -3287,16 +3418,23 @@ catch { $scriptVersion = Get-ScriptVersion $Window.Title = "Device Offboarding Manager (Preview) - $scriptVersion" -$script:LogFilePath = [System.IO.Path]::Combine([Environment]::GetFolderPath("Desktop"), "IntuneOffboardingTool_Log.txt") +$script:LogDirectory = [System.IO.Path]::Combine([Environment]::GetFolderPath("Desktop"), "DOM_Logs") +if (-not (Test-Path $script:LogDirectory)) { New-Item -Path $script:LogDirectory -ItemType Directory -Force | Out-Null } +$script:LogFilePath = [System.IO.Path]::Combine($script:LogDirectory, "DOM_$(Get-Date -Format 'yyyyMMdd_HHmmss').log") +$script:AdminUPN = $null function Write-Log { param( [Parameter(Mandatory = $true)] - [string] $Message + [string] $Message, + [Parameter(Mandatory = $false)] + [ValidateSet("INFO", "WARN", "ERROR", "AUDIT")] + [string] $Severity = "INFO" ) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - $logMessage = "$timestamp - $Message" + $admin = if ($script:AdminUPN) { $script:AdminUPN } else { "N/A" } + $logMessage = "$timestamp | $Severity | $admin | $Message" Add-Content -Path $script:LogFilePath -Value $logMessage } @@ -3363,39 +3501,48 @@ function Invoke-DeviceSearch { $IntuneCount = 0 $AutopilotCount = 0 + # Pre-fetch Autopilot devices once for devicename search (API doesn't support displayName filtering) + $allAutopilotDevices = $null + if ($SearchOption -eq "Devicename") { + try { + $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$select=id,displayName,serialNumber,lastContactedDateTime" + $allAutopilotDevices = Get-GraphPagedResults -Uri $uri + Write-Log "Pre-fetched $($allAutopilotDevices.Count) Autopilot devices for display name matching" + } + catch { + Write-Log "Error pre-fetching Autopilot devices: $_" + $allAutopilotDevices = @() + } + } + foreach ($SearchText in $SearchTexts) { # Trim whitespace and newlines $SearchText = $SearchText.Trim() - + if ([string]::IsNullOrWhiteSpace($SearchText)) { continue } - + if ($SearchOption -eq "Devicename") { - # Get devices from all services independently - $uri = "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$SearchText'" - $AADDevices = Get-GraphPagedResults -Uri $uri - - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=deviceName eq '$SearchText'" - $IntuneDevices = Get-GraphPagedResults -Uri $uri - - # Search Autopilot devices by displayName (using client-side filtering) - try { - # Get all Autopilot devices and filter client-side since API doesn't support displayName filtering - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities" - $allAutopilotDevices = Get-GraphPagedResults -Uri $uri - - # Filter by display name (case-insensitive partial match) - $AutopilotDevices = $allAutopilotDevices | Where-Object { - $_.displayName -and $_.displayName -like "*$SearchText*" + # Batch Entra + Intune queries together + $batchRequests = @( + @{ id = "entra"; method = "GET"; url = "/devices?`$filter=displayName eq '$SearchText'&`$select=id,deviceId,displayName,operatingSystem,approximateLastSignInDateTime,accountEnabled,physicalIds" } + @{ id = "intune"; method = "GET"; url = "/deviceManagement/managedDevices?`$filter=deviceName eq '$SearchText'&`$select=id,deviceName,serialNumber,operatingSystem,userDisplayName,lastSyncDateTime,azureADDeviceId" } + ) + $batchResponses = Invoke-GraphBatchRequest -Requests $batchRequests + $entraResp = $batchResponses | Where-Object { $_.id -eq "entra" } + $intuneResp = $batchResponses | Where-Object { $_.id -eq "intune" } + $AADDevices = if ($entraResp -and $entraResp.status -eq 200 -and $entraResp.body.value) { $entraResp.body.value } else { @() } + $IntuneDevices = if ($intuneResp -and $intuneResp.status -eq 200 -and $intuneResp.body.value) { $intuneResp.body.value } else { @() } + + # Filter pre-fetched Autopilot devices by display name (exact match) + $AutopilotDevices = @() + if ($allAutopilotDevices) { + $AutopilotDevices = $allAutopilotDevices | Where-Object { + $_.displayName -and $_.displayName -eq $SearchText } - - Write-Log "Found $($AutopilotDevices.Count) Autopilot devices matching display name: $SearchText" - } - catch { - Write-Log "Error searching Autopilot devices by display name: $_" - $AutopilotDevices = @() } + Write-Log "Found $(@($AutopilotDevices).Count) Autopilot devices matching display name: $SearchText" # Process Entra ID devices if ($AADDevices) { @@ -3405,7 +3552,7 @@ function Invoke-DeviceSearch { # If no Autopilot match by displayName and we have Intune device with serial, try serial number if (-not $matchingAutopilotDevice -and $matchingIntuneDevice -and $matchingIntuneDevice.serialNumber) { - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$($matchingIntuneDevice.serialNumber)')" + $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$($matchingIntuneDevice.serialNumber)')&`$select=id,displayName,serialNumber,lastContactedDateTime" $matchingAutopilotDevice = (Get-GraphPagedResults -Uri $uri) | Select-Object -First 1 } @@ -3430,7 +3577,12 @@ function Invoke-DeviceSearch { $CombinedDevice.AzureADLastContact = ConvertTo-SafeDateTime -dateString $AADDevice.approximateLastSignInDateTime $CombinedDevice.IntuneLastContact = ConvertTo-SafeDateTime -dateString $matchingIntuneDevice.lastSyncDateTime $CombinedDevice.AutopilotLastContact = ConvertTo-SafeDateTime -dateString $matchingAutopilotDevice.lastContactedDateTime - + $CombinedDevice.EntraDeviceId = $AADDevice.id + $CombinedDevice.EntraDeviceObjectId = $AADDevice.deviceId + $CombinedDevice.IntuneDeviceId = $matchingIntuneDevice?.id + $CombinedDevice.AutopilotIdentityId = $matchingAutopilotDevice?.id + $CombinedDevice.EntraAccountEnabled = if ($null -ne $AADDevice.accountEnabled) { $AADDevice.accountEnabled.ToString() } else { $null } + $searchResults.Add($CombinedDevice) $AADCount++ if ($matchingIntuneDevice) { $IntuneCount++ } @@ -3451,7 +3603,7 @@ function Invoke-DeviceSearch { # If no match by displayName and we have serial number, try serial number if (-not $matchingAutopilotDevice -and $IntuneDevice.serialNumber) { - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$($IntuneDevice.serialNumber)')" + $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$($IntuneDevice.serialNumber)')&`$select=id,displayName,serialNumber,lastContactedDateTime" $matchingAutopilotDevice = (Get-GraphPagedResults -Uri $uri) | Select-Object -First 1 } @@ -3463,7 +3615,9 @@ function Invoke-DeviceSearch { $CombinedDevice.PrimaryUser = $IntuneDevice.userDisplayName $CombinedDevice.IntuneLastContact = ConvertTo-SafeDateTime -dateString $IntuneDevice.lastSyncDateTime $CombinedDevice.AutopilotLastContact = ConvertTo-SafeDateTime -dateString $matchingAutopilotDevice.lastContactedDateTime - + $CombinedDevice.IntuneDeviceId = $IntuneDevice.id + $CombinedDevice.AutopilotIdentityId = $matchingAutopilotDevice?.id + $searchResults.Add($CombinedDevice) $IntuneCount++ if ($matchingAutopilotDevice) { $AutopilotCount++ } @@ -3486,26 +3640,31 @@ function Invoke-DeviceSearch { $CombinedDevice.DeviceName = $AutopilotDevice.displayName $CombinedDevice.SerialNumber = $AutopilotDevice.serialNumber $CombinedDevice.AutopilotLastContact = ConvertTo-SafeDateTime -dateString $AutopilotDevice.lastContactedDateTime - + $CombinedDevice.AutopilotIdentityId = $AutopilotDevice.id + $searchResults.Add($CombinedDevice) $AutopilotCount++ } } } elseif ($SearchOption -eq "Serialnumber") { - # Get devices from all services independently - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=serialNumber eq '$SearchText'" - $IntuneDevices = Get-GraphPagedResults -Uri $uri - - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$SearchText')" - $AutopilotDevices = Get-GraphPagedResults -Uri $uri + # Batch Intune + Autopilot queries together + $batchRequests = @( + @{ id = "intune"; method = "GET"; url = "/deviceManagement/managedDevices?`$filter=serialNumber eq '$SearchText'&`$select=id,deviceName,serialNumber,operatingSystem,userDisplayName,lastSyncDateTime,azureADDeviceId" } + @{ id = "autopilot"; method = "GET"; url = "/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$SearchText')&`$select=id,displayName,serialNumber,lastContactedDateTime" } + ) + $batchResponses = Invoke-GraphBatchRequest -Requests $batchRequests + $intuneResp = $batchResponses | Where-Object { $_.id -eq "intune" } + $autopilotResp = $batchResponses | Where-Object { $_.id -eq "autopilot" } + $IntuneDevices = if ($intuneResp -and $intuneResp.status -eq 200 -and $intuneResp.body.value) { $intuneResp.body.value } else { @() } + $AutopilotDevices = if ($autopilotResp -and $autopilotResp.status -eq 200 -and $autopilotResp.body.value) { $autopilotResp.body.value } else { @() } if ($IntuneDevices -or $AutopilotDevices) { # If device is in Intune if ($IntuneDevices) { foreach ($IntuneDevice in $IntuneDevices) { # Get Entra ID Device - $uri = "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$($IntuneDevice.deviceName)'" + $uri = "https://graph.microsoft.com/beta/devices?`$filter=displayName eq '$($IntuneDevice.deviceName)'&`$select=id,deviceId,displayName,operatingSystem,approximateLastSignInDateTime,accountEnabled,physicalIds" $AADDevice = (Get-GraphPagedResults -Uri $uri) | Select-Object -First 1 # Get Autopilot Device @@ -3520,7 +3679,12 @@ function Invoke-DeviceSearch { $CombinedDevice.AzureADLastContact = ConvertTo-SafeDateTime -dateString $AADDevice.approximateLastSignInDateTime $CombinedDevice.IntuneLastContact = ConvertTo-SafeDateTime -dateString $IntuneDevice.lastSyncDateTime $CombinedDevice.AutopilotLastContact = ConvertTo-SafeDateTime -dateString $matchingAutopilotDevice.lastContactedDateTime - + $CombinedDevice.EntraDeviceId = $AADDevice?.id + $CombinedDevice.EntraDeviceObjectId = $AADDevice?.deviceId + $CombinedDevice.IntuneDeviceId = $IntuneDevice.id + $CombinedDevice.AutopilotIdentityId = $matchingAutopilotDevice?.id + $CombinedDevice.EntraAccountEnabled = if ($null -ne $AADDevice -and $null -ne $AADDevice.accountEnabled) { $AADDevice.accountEnabled.ToString() } else { $null } + $searchResults.Add($CombinedDevice) if ($AADDevice) { $AADCount++ } $IntuneCount++ @@ -3541,13 +3705,74 @@ function Invoke-DeviceSearch { $CombinedDevice.DeviceName = $AutopilotDevice.displayName $CombinedDevice.SerialNumber = $AutopilotDevice.serialNumber $CombinedDevice.AutopilotLastContact = ConvertTo-SafeDateTime -dateString $AutopilotDevice.lastContactedDateTime - + $CombinedDevice.AutopilotIdentityId = $AutopilotDevice.id + $searchResults.Add($CombinedDevice) $AutopilotCount++ } } } } + elseif ($SearchOption -eq "Device ID") { + # Direct lookup by Entra device object ID + try { + $uri = "https://graph.microsoft.com/beta/devices/$SearchText" + $AADDevice = Invoke-MgGraphRequest -Uri $uri -Method GET + } + catch { + Write-Log "Device ID '$SearchText' not found in Entra ID: $_" + $AADDevice = $null + } + + if ($AADDevice) { + $AADCount++ + # Cross-reference Intune by azureADDeviceId for accurate matching + $IntuneDevice = $null + if ($AADDevice.deviceId) { + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$($AADDevice.deviceId)'&`$select=id,deviceName,serialNumber,operatingSystem,userDisplayName,lastSyncDateTime,azureADDeviceId" + $IntuneDevice = (Get-GraphPagedResults -Uri $uri) | Select-Object -First 1 + if ($IntuneDevice) { $IntuneCount++ } + } + + # Cross-reference Autopilot by serial from physicalIds + $AutopilotDevice = $null + $serialFromPhysicalIds = $null + if ($AADDevice.physicalIds) { + foreach ($physicalId in $AADDevice.physicalIds) { + if ($physicalId -match '\[SerialNumber\]:(.+)') { + $serialFromPhysicalIds = $matches[1].Trim() + break + } + } + } + $serial = $IntuneDevice?.serialNumber ?? $serialFromPhysicalIds + if ($serial) { + $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$serial')" + $AutopilotDevice = (Get-GraphPagedResults -Uri $uri) | Select-Object -First 1 + if ($AutopilotDevice) { $AutopilotCount++ } + } + + $CombinedDevice = New-Object DeviceObject + $CombinedDevice.IsSelected = $false + $CombinedDevice.DeviceName = $AADDevice.displayName + $CombinedDevice.SerialNumber = $serial + $CombinedDevice.OperatingSystem = $AADDevice.operatingSystem + $CombinedDevice.PrimaryUser = $IntuneDevice?.userDisplayName + $CombinedDevice.AzureADLastContact = ConvertTo-SafeDateTime -dateString $AADDevice.approximateLastSignInDateTime + $CombinedDevice.IntuneLastContact = ConvertTo-SafeDateTime -dateString $IntuneDevice?.lastSyncDateTime + $CombinedDevice.AutopilotLastContact = ConvertTo-SafeDateTime -dateString $AutopilotDevice?.lastContactedDateTime + $CombinedDevice.EntraDeviceId = $AADDevice.id + $CombinedDevice.EntraDeviceObjectId = $AADDevice.deviceId + $CombinedDevice.IntuneDeviceId = $IntuneDevice?.id + $CombinedDevice.AutopilotIdentityId = $AutopilotDevice?.id + $CombinedDevice.EntraAccountEnabled = if ($null -ne $AADDevice.accountEnabled) { $AADDevice.accountEnabled.ToString() } else { $null } + + $searchResults.Add($CombinedDevice) + } + else { + Write-Log "No device found with ID: $SearchText" + } + } } # Update UI status @@ -3605,6 +3830,7 @@ $SearchInputText.Add_LostFocus({ $Window.Add_Loaded({ $Dropdown.Items.Add("Devicename") $Dropdown.Items.Add("Serialnumber") + $Dropdown.Items.Add("Device ID") $Dropdown.SelectedIndex = 0 }) @@ -3631,6 +3857,12 @@ $Window.Add_Loaded({ } else { Write-Log "Successfully connected to MS Graph" + # Capture admin identity for audit logging on existing connection + if ($context.Account) { + $script:AdminUPN = $context.Account + } else { + $script:AdminUPN = "AppId:$($context.ClientId)" + } $AuthenticateButton.Content = "Successfully connected" $AuthenticateButton.IsEnabled = $false $Disconnect.IsEnabled = $true @@ -3644,7 +3876,7 @@ $Window.Add_Loaded({ # Get tenant details for existing connection try { Write-Log "Retrieving tenant information for existing connection..." - $tenantInfo = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/organization" -Method GET + $tenantInfo = Invoke-GraphRequestWithRetry -Uri "https://graph.microsoft.com/beta/organization?`$select=displayName,id,verifiedDomains" -Method GET if ($tenantInfo.value) { $org = $tenantInfo.value[0] Write-Log "Found tenant: $($org.displayName)" @@ -3919,15 +4151,58 @@ $OffboardButton.Add_Click({ } $selectedDevices = $SearchResultsDataGrid.ItemsSource | Where-Object { $_.IsSelected } - + if (-not $selectedDevices) { [System.Windows.MessageBox]::Show("Please select at least one device to offboard.") return } + # Resolve missing IDs for selected devices before showing confirmation + foreach ($device in $selectedDevices) { + try { + # If we have a device name but no Entra ID, try to resolve it + if ($device.DeviceName -and -not $device.EntraDeviceId) { + $uri = "https://graph.microsoft.com/beta/devices?`$filter=displayName eq '$($device.DeviceName)'" + $entraDevices = Get-GraphPagedResults -Uri $uri + if ($entraDevices -and @($entraDevices).Count -eq 1) { + $entraDevice = $entraDevices | Select-Object -First 1 + $device.EntraDeviceId = $entraDevice.id + $device.EntraDeviceObjectId = $entraDevice.deviceId + $device.EntraAccountEnabled = if ($null -ne $entraDevice.accountEnabled) { $entraDevice.accountEnabled.ToString() } else { $null } + } + elseif ($entraDevices -and @($entraDevices).Count -gt 1) { + Write-Log "Multiple Entra ID devices found for name '$($device.DeviceName)' - skipping auto-resolution to prevent wrong-device match" -Severity "WARN" + } + } + # If we have a device name but no Intune ID, try to resolve it + if ($device.DeviceName -and -not $device.IntuneDeviceId) { + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=deviceName eq '$($device.DeviceName)'" + $intuneDevices = Get-GraphPagedResults -Uri $uri + if ($intuneDevices -and @($intuneDevices).Count -eq 1) { + $device.IntuneDeviceId = ($intuneDevices | Select-Object -First 1).id + } + elseif ($intuneDevices -and @($intuneDevices).Count -gt 1) { + Write-Log "Multiple Intune devices found for name '$($device.DeviceName)' - skipping auto-resolution to prevent wrong-device match" -Severity "WARN" + } + } + # If we have a serial number but no Autopilot ID, try to resolve it + if ($device.SerialNumber -and -not $device.AutopilotIdentityId) { + $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$($device.SerialNumber)')" + $autopilotDevice = (Get-GraphPagedResults -Uri $uri) | Select-Object -First 1 + if ($autopilotDevice) { + $device.AutopilotIdentityId = $autopilotDevice.id + } + } + Write-Log "Resolved IDs for $($device.DeviceName): Entra=$($device.EntraDeviceId), Intune=$($device.IntuneDeviceId), Autopilot=$($device.AutopilotIdentityId)" + } + catch { + Write-Log "Error resolving IDs for device $($device.DeviceName): $_" -Severity "WARN" + } + } + # Show confirmation modal [xml]$confirmationModalXaml = @' - + @@ -3955,6 +4230,48 @@ $OffboardButton.Add_Click({ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4017,40 +4334,63 @@ $OffboardButton.Add_Click({ $cancelButton = $confirmationWindow.FindName('CancelButton') $confirmButton = $confirmationWindow.FindName('ConfirmButton') $encryptionKeysList = $confirmationWindow.FindName('EncryptionKeysList') + $devicePreviewList = $confirmationWindow.FindName('DevicePreviewList') + + # Populate Device Identity Preview + $previewItems = New-Object System.Collections.ObjectModel.ObservableCollection[Object] + foreach ($device in $selectedDevices) { + $resolvedColor = "#48BB78" + $notFoundColor = "#F56565" + $previewItems.Add([PSCustomObject]@{ + DeviceName = if ($device.DeviceName) { $device.DeviceName } else { "Unknown" } + SerialText = if ($device.SerialNumber) { "S/N: $($device.SerialNumber)" } else { "S/N: N/A" } + EntraIdText = if ($device.EntraDeviceId) { $device.EntraDeviceId.Substring(0, [Math]::Min(8, $device.EntraDeviceId.Length)) + "..." } else { "Not found" } + EntraIdColor = if ($device.EntraDeviceId) { $resolvedColor } else { $notFoundColor } + IntuneIdText = if ($device.IntuneDeviceId) { $device.IntuneDeviceId.Substring(0, [Math]::Min(8, $device.IntuneDeviceId.Length)) + "..." } else { "Not found" } + IntuneIdColor = if ($device.IntuneDeviceId) { $resolvedColor } else { $notFoundColor } + AutopilotIdText = if ($device.AutopilotIdentityId) { $device.AutopilotIdentityId.Substring(0, [Math]::Min(8, $device.AutopilotIdentityId.Length)) + "..." } else { "Not found" } + AutopilotIdColor = if ($device.AutopilotIdentityId) { $resolvedColor } else { $notFoundColor } + }) + } + $devicePreviewList.ItemsSource = $previewItems # Create a list to store encryption key information $encryptionKeys = New-Object System.Collections.ObjectModel.ObservableCollection[Object] - # Get encryption keys for all selected devices + # Get encryption keys for all selected devices using cached IDs foreach ($selectedDevice in $selectedDevices) { try { - # Get device details from Intune - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=deviceName eq '$($selectedDevice.DeviceName)'" - $intuneDevice = (Get-GraphPagedResults -Uri $uri) | Select-Object -First 1 - $keyInfo = @{ DeviceName = $selectedDevice.DeviceName KeyText = "Loading encryption key..." Key = $null } + # Use cached Intune ID to get device details if needed + $intuneDevice = $null + if ($selectedDevice.IntuneDeviceId) { + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($selectedDevice.IntuneDeviceId)?`$select=operatingSystem,azureADDeviceId,serialNumber" + try { $intuneDevice = Invoke-GraphRequestWithRetry -Uri $uri -Method GET } catch { $intuneDevice = $null } + } + if ($intuneDevice) { # Check OS type and get appropriate encryption key if ($intuneDevice.operatingSystem -eq "Windows") { try { - # First get the key ID using Azure AD device ID - $uri = "https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys?`$filter=deviceId eq '$($intuneDevice.azureADDeviceId)'" + # Use cached EntraDeviceObjectId for BitLocker lookup + $bitlockerDeviceId = $selectedDevice.EntraDeviceObjectId ?? $intuneDevice.azureADDeviceId + $uri = "https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys?`$filter=deviceId eq '$bitlockerDeviceId'" $keyIdResponse = Get-GraphPagedResults -Uri $uri - + if ($keyIdResponse.Count -gt 0) { - # Get the actual key using the key ID from the first recovery key $recoveryKeyId = $keyIdResponse[0].id $uri = "https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys/$($recoveryKeyId)?`$select=key" $recoveryKeyData = Invoke-MgGraphRequest -Uri $uri -Method GET - + if ($recoveryKeyData.key) { $keyInfo.KeyText = "BitLocker Recovery Key: $($recoveryKeyData.key)" $keyInfo.Key = $recoveryKeyData.key + Write-Log "SENSITIVE: BitLocker recovery key retrieved for device $($selectedDevice.DeviceName)" -Severity "AUDIT" } else { $keyInfo.KeyText = "Error retrieving BitLocker key details." @@ -4061,7 +4401,7 @@ $OffboardButton.Add_Click({ } } catch { - Write-Log "Error retrieving BitLocker key: $_" + Write-Log "Error retrieving BitLocker key: $_" -Severity "ERROR" if ($_.Exception.Response.StatusCode -eq 'Forbidden') { $keyInfo.KeyText = "BitLocker key access denied. Ensure BitlockerKey.Read.All permission is granted." } @@ -4071,20 +4411,21 @@ $OffboardButton.Add_Click({ } } elseif ($intuneDevice.operatingSystem -eq "macOS") { - # Get FileVault key using the dedicated endpoint for macOS - $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices('$($intuneDevice.id)')/getFileVaultKey" + # Get FileVault key using cached Intune ID + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices('$($selectedDevice.IntuneDeviceId)')/getFileVaultKey" try { $fileVaultKey = Invoke-MgGraphRequest -Uri $uri -Method GET if ($fileVaultKey.value) { $keyInfo.KeyText = "FileVault Recovery Key: $($fileVaultKey.value)" $keyInfo.Key = $fileVaultKey.value + Write-Log "SENSITIVE: FileVault recovery key retrieved for device $($selectedDevice.DeviceName)" -Severity "AUDIT" } else { $keyInfo.KeyText = "No FileVault recovery key found for this device." } } catch { - #Write-Log "Error retrieving FileVault key: $_" + Write-Log "Error retrieving FileVault key: $_" -Severity "ERROR" $keyInfo.KeyText = "Error retrieving FileVault key details." } } @@ -4097,7 +4438,7 @@ $OffboardButton.Add_Click({ } } catch { - #Write-Log "Error retrieving encryption key: $_" + Write-Log "Error retrieving encryption key for $($selectedDevice.DeviceName): $_" -Severity "ERROR" $keyInfo.KeyText = "Error retrieving encryption key. Please check logs for details." } @@ -4134,9 +4475,10 @@ $OffboardButton.Add_Click({ # Add services to the list with checkboxes $services = @( - @{ Name = "Entra ID"; Icon = "M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z" }, - @{ Name = "Intune"; Icon = "M21,14V4H3V14H21M21,2A2,2 0 0,1 23,4V16A2,2 0 0,1 21,18H14L16,21V22H8V21L10,18H3C1.89,18 1,17.1 1,16V4C1,2.89 1.89,2 3,2H21M4,5H20V13H4V5Z" }, - @{ Name = "Autopilot"; Icon = "M12,3L1,9L12,15L21,10.09V17H23V9M5,13.18V17.18L12,21L19,17.18V13.18L12,17L5,13.18Z" } + @{ Name = "Entra ID"; Icon = "M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"; DefaultChecked = $true }, + @{ Name = "Disable in Entra ID"; Icon = "M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"; DefaultChecked = $false }, + @{ Name = "Intune"; Icon = "M21,14V4H3V14H21M21,2A2,2 0 0,1 23,4V16A2,2 0 0,1 21,18H14L16,21V22H8V21L10,18H3C1.89,18 1,17.1 1,16V4C1,2.89 1.89,2 3,2H21M4,5H20V13H4V5Z"; DefaultChecked = $true }, + @{ Name = "Autopilot"; Icon = "M12,3L1,9L12,15L21,10.09V17H23V9M5,13.18V17.18L12,21L19,17.18V13.18L12,17L5,13.18Z"; DefaultChecked = $true } ) # Create hashtable to store checkbox references @@ -4157,7 +4499,7 @@ $OffboardButton.Add_Click({ # Checkbox $checkbox = New-Object System.Windows.Controls.CheckBox - $checkbox.IsChecked = $true + $checkbox.IsChecked = $service.DefaultChecked $checkbox.VerticalAlignment = "Center" $checkbox.Margin = New-Object System.Windows.Thickness(0, 0, 12, 0) $script:serviceCheckboxes[$service.Name] = $checkbox @@ -4185,7 +4527,15 @@ $OffboardButton.Add_Click({ $serviceItem.Child = $stackPanel $servicesList.Children.Add($serviceItem) } - + + # Mutual exclusivity: "Entra ID" (delete) vs "Disable in Entra ID" + $script:serviceCheckboxes["Entra ID"].Add_Checked({ + $script:serviceCheckboxes["Disable in Entra ID"].IsChecked = $false + }.GetNewClosure()) + $script:serviceCheckboxes["Disable in Entra ID"].Add_Checked({ + $script:serviceCheckboxes["Entra ID"].IsChecked = $false + }.GetNewClosure()) + # Add button handlers $cancelButton.Add_Click({ $confirmationWindow.DialogResult = $false @@ -4240,202 +4590,193 @@ $OffboardButton.Add_Click({ # Create results collection to track all operations $offboardingResults = @() - + $bulkAutopilotIds = @() + try { + # Determine which services are selected + $disableEntra = $script:serviceCheckboxes.ContainsKey("Disable in Entra ID") -and $script:serviceCheckboxes["Disable in Entra ID"].IsChecked + $deleteEntra = (-not $disableEntra) -and $script:serviceCheckboxes["Entra ID"].IsChecked + $deleteIntune = $script:serviceCheckboxes["Intune"].IsChecked + $deleteAutopilot = $script:serviceCheckboxes["Autopilot"].IsChecked + + # Collect serial numbers and Autopilot IDs for potential bulk deletion (2+ devices) + $bulkAutopilotSerials = @() + if ($deleteAutopilot) { + $bulkAutopilotIds = @($selectedDevices | Where-Object { $_.AutopilotIdentityId } | ForEach-Object { $_.AutopilotIdentityId }) + $bulkAutopilotSerials = @($selectedDevices | Where-Object { $_.AutopilotIdentityId -and $_.SerialNumber } | ForEach-Object { $_.SerialNumber }) + } + $useBulkAutopilot = $bulkAutopilotSerials.Count -ge 2 + foreach ($device in $selectedDevices) { $deviceName = $device.DeviceName $serialNumber = $device.SerialNumber $deviceResult = @{ DeviceName = $deviceName SerialNumber = $serialNumber - EntraID = @{ Found = $false; Success = $false; Error = $null } + EntraID = @{ Found = $false; Success = $false; Error = $null; Action = $null } Intune = @{ Found = $false; Success = $false; Error = $null } Autopilot = @{ Found = $false; Success = $false; Error = $null } } - Write-Log "Starting offboarding for device: $deviceName (Serial: $serialNumber)" + Write-Log "Starting offboarding for device: $deviceName (Serial: $serialNumber, EntraId: $($device.EntraDeviceId), IntuneId: $($device.IntuneDeviceId), AutopilotId: $($device.AutopilotIdentityId))" -Severity "AUDIT" - # Get Entra ID Device(s) - Handle potential duplicates - if ($script:serviceCheckboxes["Entra ID"].IsChecked -and $deviceName) { - $uri = "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$deviceName'" - $AADDevices = (Invoke-MgGraphRequest -Uri $uri -Method GET).value - - if ($AADDevices -and $AADDevices.Count -gt 0) { + # Build batch requests for this device + $batchRequests = @() + + if ($disableEntra) { + if ($device.EntraDeviceId) { $deviceResult.EntraID.Found = $true - - # Log if we found duplicates - if ($AADDevices.Count -gt 1) { - Write-Log "Found $($AADDevices.Count) devices with name '$deviceName' in Entra ID. Will process all duplicates." - } - - $deletedCount = 0 - $failedCount = 0 - $allErrors = @() - - foreach ($AADDevice in $AADDevices) { - # Try to extract serial number from physicalIds if we don't have it (only from first device) - if (-not $serialNumber -and $AADDevice.physicalIds -and $deletedCount -eq 0) { - foreach ($physicalId in $AADDevice.physicalIds) { - if ($physicalId -match '\[SerialNumber\]:(.+)') { - $serialNumber = $matches[1].Trim() - Write-Log "Retrieved serial number from Entra ID device: $serialNumber" - break - } - } - } - - # Check if this device matches our serial number (if we have one) - $deviceSerial = $null - if ($AADDevice.physicalIds) { - foreach ($physicalId in $AADDevice.physicalIds) { - if ($physicalId -match '\[SerialNumber\]:(.+)') { - $deviceSerial = $matches[1].Trim() - break - } - } - } - - # If we have a serial number to match, skip devices that don't match - if ($serialNumber -and $deviceSerial -and $deviceSerial -ne $serialNumber) { - Write-Log "Skipping Entra ID device with ID $($AADDevice.id) - serial number mismatch (Device: $deviceSerial, Expected: $serialNumber)" - continue - } - - try { - $uri = "https://graph.microsoft.com/v1.0/devices/$($AADDevice.id)" - Invoke-MgGraphRequest -Uri $uri -Method DELETE - $deletedCount++ - Write-Log "Successfully removed device $deviceName (ID: $($AADDevice.id), Serial: $deviceSerial) from Entra ID." - } - catch { - $failedCount++ - $allErrors += $_.Exception.Message - Write-Log "Error removing device $deviceName (ID: $($AADDevice.id)) from Entra ID: $_" - } - } - - # Set overall success/failure status - if ($deletedCount -gt 0 -and $failedCount -eq 0) { - $deviceResult.EntraID.Success = $true - if ($deletedCount -gt 1) { - Write-Log "Successfully removed all $deletedCount duplicate devices named '$deviceName' from Entra ID." - } - } - elseif ($deletedCount -gt 0 -and $failedCount -gt 0) { - $deviceResult.EntraID.Success = $false - $deviceResult.EntraID.Error = "Partial success: Deleted $deletedCount device(s), failed to delete $failedCount device(s). Errors: " + ($allErrors -join "; ") - } - else { - $deviceResult.EntraID.Success = $false - $deviceResult.EntraID.Error = "Failed to delete any devices. Errors: " + ($allErrors -join "; ") - } + $deviceResult.EntraID.Action = "Disabled" + $batchRequests += @{ id = "entra"; method = "PATCH"; url = "/devices/$($device.EntraDeviceId)"; body = @{ accountEnabled = $false }; headers = @{ "Content-Type" = "application/json" } } + } else { + Write-Log "Skipping Entra ID disable for $deviceName - no Entra Device ID resolved" -Severity "WARN" } - else { - Write-Log "Device $deviceName not found in Entra ID." + } elseif ($deleteEntra) { + if ($device.EntraDeviceId) { + $deviceResult.EntraID.Found = $true + $deviceResult.EntraID.Action = "Removed" + $batchRequests += @{ id = "entra"; method = "DELETE"; url = "/devices/$($device.EntraDeviceId)" } + } else { + Write-Log "Skipping Entra ID deletion for $deviceName - no Entra Device ID resolved" -Severity "WARN" } + } else { + Write-Log "Skipping Entra ID operation for device $deviceName (not selected)" } - elseif ($deviceName -and -not $script:serviceCheckboxes["Entra ID"].IsChecked) { - Write-Log "Skipping Entra ID removal for device $deviceName (not selected)" + + if ($deleteIntune) { + if ($device.IntuneDeviceId) { + $deviceResult.Intune.Found = $true + $batchRequests += @{ id = "intune"; method = "DELETE"; url = "/deviceManagement/managedDevices/$($device.IntuneDeviceId)" } + } else { + Write-Log "Skipping Intune deletion for $deviceName - no Intune Device ID resolved" -Severity "WARN" + } + } else { + Write-Log "Skipping Intune removal for device $deviceName (not selected)" } - # Get Intune Device - if ($script:serviceCheckboxes["Intune"].IsChecked) { - if ($deviceName) { - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=deviceName eq '$deviceName'" - $IntuneDevice = (Invoke-MgGraphRequest -Uri $uri -Method GET).value | Select-Object -First 1 + # Include Autopilot in per-device batch only if not using bulk deletion + if ($deleteAutopilot -and -not $useBulkAutopilot) { + if ($device.AutopilotIdentityId) { + $deviceResult.Autopilot.Found = $true + $batchRequests += @{ id = "autopilot"; method = "DELETE"; url = "/deviceManagement/windowsAutopilotDeviceIdentities/$($device.AutopilotIdentityId)" } + } else { + Write-Log "Skipping Autopilot deletion for $deviceName - no Autopilot Identity ID resolved" -Severity "WARN" } - if (-not $IntuneDevice -and $serialNumber) { - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=serialNumber eq '$serialNumber'" - $IntuneDevice = (Invoke-MgGraphRequest -Uri $uri -Method GET).value | Select-Object -First 1 + } elseif ($deleteAutopilot -and $useBulkAutopilot) { + if ($device.AutopilotIdentityId) { + $deviceResult.Autopilot.Found = $true + # Will be handled by bulk deletion after the loop + } else { + Write-Log "Skipping Autopilot deletion for $deviceName - no Autopilot Identity ID resolved" -Severity "WARN" } - if ($IntuneDevice) { - $deviceResult.Intune.Found = $true - - # Capture the serial number from Intune device if we don't have it - if (-not $serialNumber -and $IntuneDevice.serialNumber) { - $serialNumber = $IntuneDevice.serialNumber - Write-Log "Retrieved serial number from Intune device: $serialNumber" + } else { + Write-Log "Skipping Autopilot removal for device $deviceName (not selected)" + } + + # Execute batch if there are requests + if ($batchRequests.Count -gt 0) { + try { + $batchResponses = Invoke-GraphBatchRequest -Requests $batchRequests + + # Parse Entra response + $entraResp = $batchResponses | Where-Object { $_.id -eq "entra" } + if ($entraResp) { + if ($entraResp.status -in @(200, 204)) { + $deviceResult.EntraID.Success = $true + Write-Log "Successfully $($deviceResult.EntraID.Action.ToLower()) device $deviceName in Entra ID (ID: $($device.EntraDeviceId))" -Severity "AUDIT" + } else { + $deviceResult.EntraID.Error = "HTTP $($entraResp.status)" + Write-Log "Error with Entra ID operation for $deviceName`: HTTP $($entraResp.status)" -Severity "ERROR" + } } - - try { - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$($IntuneDevice.id)" - Invoke-MgGraphRequest -Uri $uri -Method DELETE - $deviceResult.Intune.Success = $true - Write-Log "Successfully removed device $deviceName from Intune." + + # Parse Intune response + $intuneResp = $batchResponses | Where-Object { $_.id -eq "intune" } + if ($intuneResp) { + if ($intuneResp.status -in @(200, 204)) { + $deviceResult.Intune.Success = $true + Write-Log "Successfully removed device $deviceName from Intune (ID: $($device.IntuneDeviceId))" -Severity "AUDIT" + } else { + $deviceResult.Intune.Error = "HTTP $($intuneResp.status)" + Write-Log "Error removing device $deviceName from Intune: HTTP $($intuneResp.status)" -Severity "ERROR" + } } - catch { - $deviceResult.Intune.Error = $_.Exception.Message - Write-Log "Error removing device $deviceName from Intune: $_" + + # Parse Autopilot response (only if not using bulk) + $autopilotResp = $batchResponses | Where-Object { $_.id -eq "autopilot" } + if ($autopilotResp) { + if ($autopilotResp.status -in @(200, 204)) { + $deviceResult.Autopilot.Success = $true + Write-Log "Successfully removed device $deviceName from Autopilot (ID: $($device.AutopilotIdentityId))" -Severity "AUDIT" + } else { + $deviceResult.Autopilot.Error = "HTTP $($autopilotResp.status)" + Write-Log "Error removing device $deviceName from Autopilot: HTTP $($autopilotResp.status)" -Severity "ERROR" + } } } - else { - Write-Log "Device $deviceName not found in Intune." + catch { + Write-Log "Batch request failed for device $deviceName`: $_" -Severity "ERROR" + if ($deviceResult.EntraID.Found -and -not $deviceResult.EntraID.Success) { $deviceResult.EntraID.Error = $_.Exception.Message } + if ($deviceResult.Intune.Found -and -not $deviceResult.Intune.Success) { $deviceResult.Intune.Error = $_.Exception.Message } + if ($deviceResult.Autopilot.Found -and -not $deviceResult.Autopilot.Success) { $deviceResult.Autopilot.Error = $_.Exception.Message } } } - else { - Write-Log "Skipping Intune removal for device $deviceName (not selected)" - } - # Get Autopilot Device - if ($script:serviceCheckboxes["Autopilot"].IsChecked) { - $AutopilotDevice = $null - - # Try to find by serial number first if available - if ($serialNumber) { - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$serialNumber')" - $AutopilotDevice = (Invoke-MgGraphRequest -Uri $uri -Method GET).value | Select-Object -First 1 - - if (-not $AutopilotDevice) { - Write-Log "Device with serial $serialNumber not found in Autopilot, trying by display name..." - } - } - - # If not found by serial number or no serial number available, try by display name - if (-not $AutopilotDevice -and $deviceName) { - Write-Log "Searching Autopilot by display name: $deviceName (using client-side filtering)" - try { - # Get all Autopilot devices and filter client-side since API doesn't support displayName filtering - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities" - $allAutopilotDevices = Get-GraphPagedResults -Uri $uri - - # Filter by display name (case-insensitive partial match) - $AutopilotDevice = $allAutopilotDevices | Where-Object { - $_.displayName -and $_.displayName -like "*$deviceName*" - } | Select-Object -First 1 - - if ($AutopilotDevice) { - Write-Log "Found Autopilot device matching display name: $($AutopilotDevice.displayName)" + $offboardingResults += $deviceResult + Write-Log "Completed offboarding attempt for device: $deviceName" -Severity "AUDIT" + } + + # Bulk Autopilot deletion when 2+ devices have serial numbers + if ($useBulkAutopilot -and $bulkAutopilotSerials.Count -ge 2) { + Write-Log "Executing bulk Autopilot deletion for $($bulkAutopilotSerials.Count) devices by serial number" -Severity "AUDIT" + try { + $bulkBody = @{ + serialNumbers = $bulkAutopilotSerials + } | ConvertTo-Json -Depth 5 + $bulkResponse = Invoke-GraphRequestWithRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities/deleteDevices" -Method POST -Body $bulkBody -ContentType "application/json" + + # Parse per-device deletion status from response + if ($bulkResponse.value) { + foreach ($deleteState in $bulkResponse.value) { + $matchingResult = $offboardingResults | Where-Object { $_.SerialNumber -eq $deleteState.serialNumber } + if ($matchingResult) { + if ($deleteState.deletionState -eq "failed") { + $matchingResult.Autopilot.Error = $deleteState.errorMessage + Write-Log "Bulk Autopilot deletion failed for serial $($deleteState.serialNumber): $($deleteState.errorMessage)" -Severity "ERROR" + } else { + $matchingResult.Autopilot.Success = $true + } } } - catch { - Write-Log "Error searching Autopilot devices by display name: $_" + } else { + # No detailed response -- set optimistic success + foreach ($result in $offboardingResults) { + if ($result.Autopilot.Found) { + $result.Autopilot.Success = $true + } } } - - if ($AutopilotDevice) { - $deviceResult.Autopilot.Found = $true - try { - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities/$($AutopilotDevice.id)" - Invoke-MgGraphRequest -Uri $uri -Method DELETE - $deviceResult.Autopilot.Success = $true - Write-Log "Successfully removed device $deviceName from Autopilot (ID: $($AutopilotDevice.id))." - } - catch { - $deviceResult.Autopilot.Error = $_.Exception.Message - Write-Log "Error removing device $deviceName from Autopilot: $_" + Write-Log "Bulk Autopilot deletion completed for $($bulkAutopilotSerials.Count) devices" -Severity "AUDIT" + } + catch { + Write-Log "Bulk Autopilot deletion failed: $_ -- falling back to individual deletion" -Severity "ERROR" + foreach ($result in $offboardingResults) { + if ($result.Autopilot.Found -and -not $result.Autopilot.Success) { + $matchingDevice = $selectedDevices | Where-Object { $_.DeviceName -eq $result.DeviceName -and $_.AutopilotIdentityId } + if ($matchingDevice) { + try { + Invoke-GraphRequestWithRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities/$($matchingDevice.AutopilotIdentityId)" -Method DELETE + $result.Autopilot.Success = $true + Write-Log "Successfully removed device $($result.DeviceName) from Autopilot (fallback)" -Severity "AUDIT" + } + catch { + $result.Autopilot.Error = $_.Exception.Message + Write-Log "Error removing device $($result.DeviceName) from Autopilot (fallback): $_" -Severity "ERROR" + } + } } } - else { - $searchCriteria = if ($serialNumber) { "serial $serialNumber or name $deviceName" } else { "name $deviceName" } - Write-Log "Device with $searchCriteria not found in Autopilot." - } } - else { - Write-Log "Skipping Autopilot removal for device $deviceName (not selected)" - } - - $offboardingResults += $deviceResult - Write-Log "Completed offboarding attempt for device: $deviceName" } # Show summary of all operations @@ -4446,7 +4787,12 @@ $OffboardButton.Add_Click({ $allIntuneSuccess = $offboardingResults | Where-Object { $_.Intune.Found -and $_.Intune.Success } | Measure-Object | Select-Object -ExpandProperty Count $allAutopilotSuccess = $offboardingResults | Where-Object { $_.Autopilot.Found -and $_.Autopilot.Success } | Measure-Object | Select-Object -ExpandProperty Count - if ($allEntraSuccess -gt 0) { + $allEntraDisabled = $offboardingResults | Where-Object { $_.EntraID.Found -and $_.EntraID.Success -and $_.EntraID.Action -eq "Disabled" } | Measure-Object | Select-Object -ExpandProperty Count + if ($allEntraDisabled -gt 0) { + $Window.FindName('aad_status').Text = "Entra ID: Devices Disabled" + $Window.FindName('aad_status').Foreground = "#ECC94B" + } + elseif ($allEntraSuccess -gt 0) { $Window.FindName('aad_status').Text = "Entra ID: Devices Removed" $Window.FindName('aad_status').Foreground = "#FC8181" } @@ -4692,7 +5038,7 @@ function Show-OffboardingSummary { $deviceTotal = 0 # Pre-compute skip flags and count successes outside PSCustomObject to avoid $deviceSuccess++ polluting the pipeline - $entraIDSkipped = $script:serviceCheckboxes -and $script:serviceCheckboxes["Entra ID"] -and -not $script:serviceCheckboxes["Entra ID"].IsChecked + $entraIDSkipped = $script:serviceCheckboxes -and $script:serviceCheckboxes["Entra ID"] -and -not $script:serviceCheckboxes["Entra ID"].IsChecked -and -not ($script:serviceCheckboxes.ContainsKey("Disable in Entra ID") -and $script:serviceCheckboxes["Disable in Entra ID"].IsChecked) $intuneSkipped = $script:serviceCheckboxes -and $script:serviceCheckboxes["Intune"] -and -not $script:serviceCheckboxes["Intune"].IsChecked $autopilotSkipped = $script:serviceCheckboxes -and $script:serviceCheckboxes["Autopilot"] -and -not $script:serviceCheckboxes["Autopilot"].IsChecked @@ -4700,6 +5046,9 @@ function Show-OffboardingSummary { if (-not $intuneSkipped -and $result.Intune.Found -and $result.Intune.Success) { $deviceSuccess++ } if (-not $autopilotSkipped -and $result.Autopilot.Found -and $result.Autopilot.Success) { $deviceSuccess++ } + # Determine Entra ID action label + $entraActionLabel = if ($result.EntraID.Action -eq "Disabled") { "Disabled" } else { "Removed" } + # Create display object for this device $displayResult = [PSCustomObject]@{ DeviceName = $result.DeviceName @@ -4710,13 +5059,13 @@ function Show-OffboardingSummary { "Skipped" } elseif ($result.EntraID.Found) { - if ($result.EntraID.Success) { "✓ Removed" } else { "✗ Failed" } + if ($result.EntraID.Success) { "✓ $entraActionLabel" } else { "✗ Failed" } } else { "Not Found" } EntraIDColor = if ($entraIDSkipped) { "#A0AEC0" } - elseif (!$result.EntraID.Found) { "#718096" } elseif ($result.EntraID.Success) { "#48BB78" } else { "#F56565" } + elseif (!$result.EntraID.Found) { "#718096" } elseif ($result.EntraID.Success -and $result.EntraID.Action -eq "Disabled") { "#ECC94B" } elseif ($result.EntraID.Success) { "#48BB78" } else { "#F56565" } EntraIDError = $result.EntraID.Error EntraIDErrorVisibility = if ($result.EntraID.Error) { "Visible" } else { "Collapsed" } @@ -4752,8 +5101,9 @@ function Show-OffboardingSummary { } # Count total services device was found in (only for selected services) - if ($script:serviceCheckboxes -and $script:serviceCheckboxes["Entra ID"] -and $script:serviceCheckboxes["Entra ID"].IsChecked -and $result.EntraID.Found) { - $deviceTotal++ + $entraSelected = ($script:serviceCheckboxes -and $script:serviceCheckboxes["Entra ID"] -and $script:serviceCheckboxes["Entra ID"].IsChecked) -or ($script:serviceCheckboxes -and $script:serviceCheckboxes.ContainsKey("Disable in Entra ID") -and $script:serviceCheckboxes["Disable in Entra ID"].IsChecked) + if ($entraSelected -and $result.EntraID.Found) { + $deviceTotal++ } if ($script:serviceCheckboxes -and $script:serviceCheckboxes["Intune"] -and $script:serviceCheckboxes["Intune"].IsChecked -and $result.Intune.Found) { $deviceTotal++ @@ -5185,12 +5535,11 @@ $PrerequisitesButton.Add_Click({ }) $logs_button.Add_Click({ - $logFilePath = [System.IO.Path]::Combine([Environment]::GetFolderPath("Desktop"), "IntuneOffboardingTool_Log.txt") - if (Test-Path $logFilePath) { - Invoke-Item $logFilePath + if (Test-Path $script:LogDirectory) { + Invoke-Item $script:LogDirectory } else { - Write-Host "Log file not found." + [System.Windows.MessageBox]::Show("Log directory not found.", "Logs", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information) } }) @@ -5262,357 +5611,330 @@ function Update-DashboardStatistics { try { Write-Log "Updating dashboard statistics..." $startTime = Get-Date - Write-Log "Starting parallel API calls at $startTime" - - # Run each call in a separate thread job with timing - Write-Log "Starting Intune devices job..." - $intuneJobStart = Get-Date - $intuneJob = Start-ThreadJob -ScriptBlock { - function Get-GraphPagedResults { - param([string]$Uri) - $results = @() - $nextLink = $Uri - do { - $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET - if ($response.value) { $results += $response.value } - $nextLink = $response.'@odata.nextLink' - } while ($nextLink) - return $results + + # Try $count batch approach first (single API call instead of fetching all devices) + $countSuccess = $false + try { + $thirtyDaysAgo = (Get-Date).AddDays(-30).ToString('yyyy-MM-ddTHH:mm:ssZ') + $ninetyDaysAgo = (Get-Date).AddDays(-90).ToString('yyyy-MM-ddTHH:mm:ssZ') + $oneEightyDaysAgo = (Get-Date).AddDays(-180).ToString('yyyy-MM-ddTHH:mm:ssZ') + + $batchBody = @{ + requests = @( + @{ id = "intune"; method = "GET"; url = "/deviceManagement/managedDevices/`$count"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "autopilot"; method = "GET"; url = "/deviceManagement/windowsAutopilotDeviceIdentities/`$count"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "entra"; method = "GET"; url = "/devices/`$count"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "stale30"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=lastSyncDateTime lt $thirtyDaysAgo"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "stale90"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=lastSyncDateTime lt $ninetyDaysAgo"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "stale180"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=lastSyncDateTime lt $oneEightyDaysAgo"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "personal"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=managedDeviceOwnerType eq 'personal'"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "corporate"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=managedDeviceOwnerType eq 'company'"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "osWindows"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=startswith(operatingSystem,'Windows')"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "osmacOS"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=operatingSystem eq 'macOS'"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "osiOS"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=operatingSystem eq 'iOS'"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "osAndroid"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=operatingSystem eq 'Android'"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "osLinux"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=operatingSystem eq 'Linux'"; headers = @{ "ConsistencyLevel" = "eventual" } } + ) + } | ConvertTo-Json -Depth 5 + + $batchResponse = Invoke-GraphRequestWithRetry -Uri "https://graph.microsoft.com/beta/`$batch" -Method POST -Body $batchBody -ContentType "application/json" + $batchResponses = $batchResponse.responses + + # Helper to extract count from batch response (handles both raw int and hashtable wrapper) + $getCount = { + param([string]$id) + $resp = $batchResponses | Where-Object { $_.id -eq $id } + if ($resp -and $resp.status -eq 200) { + $rawBody = $resp.body + if ($rawBody -is [int] -or $rawBody -is [long]) { + return [int]$rawBody + } elseif ($rawBody -is [System.Collections.IDictionary] -and $rawBody.ContainsKey('value')) { + return [int]$rawBody['value'] + } else { + return [int]$rawBody + } + } + return $null } - # Pull Intune devices - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices" - $devices = Get-GraphPagedResults -Uri $uri - # Ensure we return an array - return @($devices) - } - - Write-Log "Starting Autopilot devices job..." - $autopilotJobStart = Get-Date - $autopilotJob = Start-ThreadJob -ScriptBlock { - function Get-GraphPagedResults { - param([string]$Uri) - $results = @() - $nextLink = $Uri - do { - $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET - if ($response.value) { $results += $response.value } - $nextLink = $response.'@odata.nextLink' - } while ($nextLink) - return $results + + $intuneCount = & $getCount "intune" + $autopilotCount = & $getCount "autopilot" + $entraCount = & $getCount "entra" + $stale30 = & $getCount "stale30" + $stale90 = & $getCount "stale90" + $stale180 = & $getCount "stale180" + $personalDevices = & $getCount "personal" + $corporateDevices = & $getCount "corporate" + $osWindows = & $getCount "osWindows" + $osmacOS = & $getCount "osmacOS" + $osiOS = & $getCount "osiOS" + $osAndroid = & $getCount "osAndroid" + $osLinux = & $getCount "osLinux" + + # Verify all counts were retrieved + $allCounts = @($intuneCount, $autopilotCount, $entraCount, $stale30, $stale90, $stale180, $personalDevices, $corporateDevices) + if ($allCounts -contains $null) { + throw "One or more $count queries returned an error" } - # Pull Autopilot devices - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities" - $devices = Get-GraphPagedResults -Uri $uri - # Ensure we return an array - return @($devices) - } - - Write-Log "Starting Entra ID devices job..." - $entraJobStart = Get-Date - $entraJob = Start-ThreadJob -ScriptBlock { - function Get-GraphPagedResults { - param([string]$Uri) - $results = @() - $nextLink = $Uri - do { - $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET - if ($response.value) { $results += $response.value } - $nextLink = $response.'@odata.nextLink' - } while ($nextLink) - return $results + + $countSuccess = $true + $duration = (Get-Date) - $startTime + Write-Log "Dashboard $count batch completed in $($duration.TotalSeconds) seconds" + + # Update top row counts + $Window.FindName('IntuneDevicesCount').Text = $intuneCount + $Window.FindName('AutopilotDevicesCount').Text = $autopilotCount + $Window.FindName('EntraIDDevicesCount').Text = $entraCount + + Write-Log "Stale device counts - 30 days: $stale30, 90 days: $stale90, 180 days: $stale180" + $Window.FindName('StaleDevices30Count').Text = $stale30 + $Window.FindName('StaleDevices90Count').Text = $stale90 + $Window.FindName('StaleDevices180Count').Text = $stale180 + + # Update personal/corporate counts and progress bars + $Window.FindName('PersonalDevicesCount').Text = $personalDevices + $Window.FindName('CorporateDevicesCount').Text = $corporateDevices + + $totalDevices = $intuneCount + if ($totalDevices -gt 0) { + $personalProgress = [Math]::Round(($personalDevices / $totalDevices) * 100) + $corporateProgress = [Math]::Round(($corporateDevices / $totalDevices) * 100) + $Window.FindName('PersonalDevicesProgress').Value = $personalProgress + $Window.FindName('CorporateDevicesProgress').Value = $corporateProgress } - # Pull Entra ID devices - $uri = "https://graph.microsoft.com/v1.0/devices" - $devices = Get-GraphPagedResults -Uri $uri - # Ensure we return an array - return @($devices) - } - - # Wait for jobs to finish and grab results with timing - Write-Log "Waiting for all jobs to complete..." - Wait-Job -Job $intuneJob, $autopilotJob, $entraJob | Out-Null - - # Check for job errors and get results - $intuneJobResult = try { - Receive-Job -Job $intuneJob -ErrorAction Stop - } catch { - Write-Log "Error receiving Intune devices job: $_" - $null - } - $intuneJobDuration = (Get-Date) - $intuneJobStart - Write-Log "Intune devices job completed in $($intuneJobDuration.TotalSeconds) seconds" - - $autopilotJobResult = try { - Receive-Job -Job $autopilotJob -ErrorAction Stop - } catch { - Write-Log "Error receiving Autopilot devices job: $_" - $null - } - $autopilotJobDuration = (Get-Date) - $autopilotJobStart - Write-Log "Autopilot devices job completed in $($autopilotJobDuration.TotalSeconds) seconds" - - $entraJobResult = try { - Receive-Job -Job $entraJob -ErrorAction Stop - } catch { - Write-Log "Error receiving Entra ID devices job: $_" - $null - } - $entraJobDuration = (Get-Date) - $entraJobStart - Write-Log "Entra ID devices job completed in $($entraJobDuration.TotalSeconds) seconds" - - # Convert results to arrays, handling various return types - $intuneDevices = if ($null -eq $intuneJobResult) { - @() - } elseif ($intuneJobResult -is [System.Collections.Hashtable]) { - Write-Log "WARNING: Intune job returned a hashtable instead of array. Converting..." - @($intuneJobResult.value) - } elseif ($intuneJobResult -is [System.Array]) { - $intuneJobResult - } else { - @($intuneJobResult) - } - - $autopilotDevices = if ($null -eq $autopilotJobResult) { - @() - } elseif ($autopilotJobResult -is [System.Collections.Hashtable]) { - Write-Log "WARNING: Autopilot job returned a hashtable instead of array. Converting..." - @($autopilotJobResult.value) - } elseif ($autopilotJobResult -is [System.Array]) { - $autopilotJobResult - } else { - @($autopilotJobResult) + + # Build platform groups from $count results for pie chart + # Compute "Other" as total minus known OS counts + if ($null -eq $osWindows) { $osWindows = 0 } + if ($null -eq $osmacOS) { $osmacOS = 0 } + if ($null -eq $osiOS) { $osiOS = 0 } + if ($null -eq $osAndroid) { $osAndroid = 0 } + if ($null -eq $osLinux) { $osLinux = 0 } + $osOther = [Math]::Max(0, $intuneCount - ($osWindows + $osmacOS + $osiOS + $osAndroid + $osLinux)) + + $platformGroups = @() + if ($osWindows -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'Windows'; Count = $osWindows } } + if ($osmacOS -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'macOS'; Count = $osmacOS } } + if ($osiOS -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'iOS'; Count = $osiOS } } + if ($osAndroid -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'Android'; Count = $osAndroid } } + if ($osLinux -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'Linux'; Count = $osLinux } } + if ($osOther -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'Other'; Count = $osOther } } + $platformGroups = $platformGroups | Sort-Object Count -Descending } - - $entraDevices = if ($null -eq $entraJobResult) { - @() - } elseif ($entraJobResult -is [System.Collections.Hashtable]) { - Write-Log "WARNING: Entra job returned a hashtable instead of array. Converting..." - @($entraJobResult.value) - } elseif ($entraJobResult -is [System.Array]) { - $entraJobResult - } else { - @($entraJobResult) + catch { + Write-Log "Dashboard `$count batch failed, falling back to full fetch: $_" -Severity "WARN" } - - # Clean up jobs - Remove-Job -Job $intuneJob, $autopilotJob, $entraJob -Force - - Write-Log "Total devices - Intune: $($intuneDevices.Count), Autopilot: $($autopilotDevices.Count), Entra: $($entraDevices.Count)" - - # Update top row counts - $Window.FindName('IntuneDevicesCount').Text = $intuneDevices.Count - $Window.FindName('AutopilotDevicesCount').Text = $autopilotDevices.Count - $Window.FindName('EntraIDDevicesCount').Text = $entraDevices.Count - - # Calculate stale devices - $thirtyDaysAgo = (Get-Date).AddDays(-30) - $ninetyDaysAgo = (Get-Date).AddDays(-90) - $onehundredEightyDaysAgo = (Get-Date).AddDays(-180) - - Write-Log "Total Intune devices to check: $($intuneDevices.Count)" - - $stale30 = ($intuneDevices | Where-Object { - if ($_.lastSyncDateTime) { - try { - $lastSync = ConvertTo-SafeDateTime -dateString $_.lastSyncDateTime - if (-not $lastSync) { return $false } - return $lastSync -lt $thirtyDaysAgo - } - catch { - Write-Log "Error parsing date: $($_.lastSyncDateTime). Error: $_" - return $false - } - } - else { return $false } - }).Count - - # Calculate 90-day stale devices - Write-Log "Calculating 90-day stale devices from $($intuneDevices.Count) Intune devices" - $stale90Count = 0 - foreach ($device in $intuneDevices) { - if ($device.lastSyncDateTime) { - try { - $lastSync = ConvertTo-SafeDateTime -dateString $device.lastSyncDateTime - if ($lastSync -and ($lastSync -lt $ninetyDaysAgo)) { - Write-Log "90-day stale device found: $($device.deviceName), LastSync: $lastSync" - $stale90Count++ - } - } - catch { - Write-Log "Error parsing date for device $($device.deviceName): $_" + + # Fallback: full-fetch approach if $count batch failed + if (-not $countSuccess) { + $intuneJob = Start-ThreadJob -ScriptBlock { + function Get-GraphPagedResults { + param([string]$Uri) + $results = @() + $nextLink = $Uri + do { + $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET + if ($response.value) { $results += $response.value } + $nextLink = $response.'@odata.nextLink' + } while ($nextLink) + return $results } + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,managedDeviceOwnerType" + return @(Get-GraphPagedResults -Uri $uri) } - } - $stale90 = $stale90Count - Write-Log "90-day stale devices found: $stale90" - - # Calculate 180-day stale devices - Write-Log "Calculating 180-day stale devices from $($intuneDevices.Count) Intune devices" - $stale180Count = 0 - foreach ($device in $intuneDevices) { - if ($device.lastSyncDateTime) { - try { - $lastSync = ConvertTo-SafeDateTime -dateString $device.lastSyncDateTime - if ($lastSync -and ($lastSync -lt $onehundredEightyDaysAgo)) { - Write-Log "180-day stale device found: $($device.deviceName), LastSync: $lastSync" - $stale180Count++ - } + $autopilotJob = Start-ThreadJob -ScriptBlock { + function Get-GraphPagedResults { + param([string]$Uri) + $results = @() + $nextLink = $Uri + do { + $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET + if ($response.value) { $results += $response.value } + $nextLink = $response.'@odata.nextLink' + } while ($nextLink) + return $results } - catch { - Write-Log "Error parsing date for device $($device.deviceName): $_" + $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$select=id,displayName,serialNumber,lastContactedDateTime" + return @(Get-GraphPagedResults -Uri $uri) + } + $entraJob = Start-ThreadJob -ScriptBlock { + function Get-GraphPagedResults { + param([string]$Uri) + $results = @() + $nextLink = $Uri + do { + $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET + if ($response.value) { $results += $response.value } + $nextLink = $response.'@odata.nextLink' + } while ($nextLink) + return $results } + $uri = "https://graph.microsoft.com/beta/devices?`$select=displayName,operatingSystem,operatingSystemVersion" + return @(Get-GraphPagedResults -Uri $uri) } - } - $stale180 = $stale180Count - - Write-Log "Stale device counts - 30 days: $stale30, 90 days: $stale90, 180 days: $stale180" - $Window.FindName('StaleDevices30Count').Text = $stale30 - $Window.FindName('StaleDevices90Count').Text = $stale90 - $Window.FindName('StaleDevices180Count').Text = $stale180 - - # Update personal/corporate counts and progress bars - $personalDevices = ($intuneDevices | Where-Object { $_.managedDeviceOwnerType -eq 'personal' }).Count - $corporateDevices = ($intuneDevices | Where-Object { $_.managedDeviceOwnerType -eq 'company' }).Count - $totalDevices = if ($intuneDevices) { $intuneDevices.Count } else { 0 } - - # Update counts - $Window.FindName('PersonalDevicesCount').Text = $personalDevices - $Window.FindName('CorporateDevicesCount').Text = $corporateDevices - - # Update progress bars - if ($totalDevices -gt 0) { - $personalProgress = [Math]::Round(($personalDevices / $totalDevices) * 100) - $corporateProgress = [Math]::Round(($corporateDevices / $totalDevices) * 100) - - $Window.FindName('PersonalDevicesProgress').Value = $personalProgress - $Window.FindName('CorporateDevicesProgress').Value = $corporateProgress - } - - # Group platform distribution - $platformGroups = $intuneDevices | Group-Object -Property { - $os = $_.operatingSystem - if ([string]::IsNullOrWhiteSpace($os)) { return "Unknown" } - - switch -Regex ($os.ToLower()) { - 'windows' { "Windows" } - 'macos|mac os' { "macOS" } - 'linux' { "Linux" } - 'ios' { "iOS" } - 'android' { "Android" } - default { "Other" } + + Wait-Job -Job $intuneJob, $autopilotJob, $entraJob | Out-Null + + $intuneDevices = try { @(Receive-Job -Job $intuneJob -ErrorAction Stop) } catch { @() } + $autopilotDevices = try { @(Receive-Job -Job $autopilotJob -ErrorAction Stop) } catch { @() } + $entraDevices = try { @(Receive-Job -Job $entraJob -ErrorAction Stop) } catch { @() } + Remove-Job -Job $intuneJob, $autopilotJob, $entraJob -Force + + # Handle hashtable returns + if ($intuneDevices.Count -eq 1 -and $intuneDevices[0] -is [System.Collections.Hashtable]) { $intuneDevices = @($intuneDevices[0].value) } + if ($autopilotDevices.Count -eq 1 -and $autopilotDevices[0] -is [System.Collections.Hashtable]) { $autopilotDevices = @($autopilotDevices[0].value) } + if ($entraDevices.Count -eq 1 -and $entraDevices[0] -is [System.Collections.Hashtable]) { $entraDevices = @($entraDevices[0].value) } + + Write-Log "Fallback: Total devices - Intune: $($intuneDevices.Count), Autopilot: $($autopilotDevices.Count), Entra: $($entraDevices.Count)" + + $Window.FindName('IntuneDevicesCount').Text = $intuneDevices.Count + $Window.FindName('AutopilotDevicesCount').Text = $autopilotDevices.Count + $Window.FindName('EntraIDDevicesCount').Text = $entraDevices.Count + + # Calculate stale devices client-side + $thirtyDaysAgo = (Get-Date).AddDays(-30) + $ninetyDaysAgo = (Get-Date).AddDays(-90) + $onehundredEightyDaysAgo = (Get-Date).AddDays(-180) + + $stale30 = ($intuneDevices | Where-Object { + if ($_.lastSyncDateTime) { + try { $lastSync = ConvertTo-SafeDateTime -dateString $_.lastSyncDateTime; return $lastSync -and $lastSync -lt $thirtyDaysAgo } + catch { return $false } + } else { return $false } + }).Count + $stale90 = ($intuneDevices | Where-Object { + if ($_.lastSyncDateTime) { + try { $lastSync = ConvertTo-SafeDateTime -dateString $_.lastSyncDateTime; return $lastSync -and $lastSync -lt $ninetyDaysAgo } + catch { return $false } + } else { return $false } + }).Count + $stale180 = ($intuneDevices | Where-Object { + if ($_.lastSyncDateTime) { + try { $lastSync = ConvertTo-SafeDateTime -dateString $_.lastSyncDateTime; return $lastSync -and $lastSync -lt $onehundredEightyDaysAgo } + catch { return $false } + } else { return $false } + }).Count + + $Window.FindName('StaleDevices30Count').Text = $stale30 + $Window.FindName('StaleDevices90Count').Text = $stale90 + $Window.FindName('StaleDevices180Count').Text = $stale180 + + $personalDevices = ($intuneDevices | Where-Object { $_.managedDeviceOwnerType -eq 'personal' }).Count + $corporateDevices = ($intuneDevices | Where-Object { $_.managedDeviceOwnerType -eq 'company' }).Count + $totalDevices = if ($intuneDevices) { $intuneDevices.Count } else { 0 } + + $Window.FindName('PersonalDevicesCount').Text = $personalDevices + $Window.FindName('CorporateDevicesCount').Text = $corporateDevices + + if ($totalDevices -gt 0) { + $personalProgress = [Math]::Round(($personalDevices / $totalDevices) * 100) + $corporateProgress = [Math]::Round(($corporateDevices / $totalDevices) * 100) + $Window.FindName('PersonalDevicesProgress').Value = $personalProgress + $Window.FindName('CorporateDevicesProgress').Value = $corporateProgress } - } | Sort-Object Count -Descending - # Define platform colors + # Group platform distribution client-side + $platformGroups = $intuneDevices | Group-Object -Property { + $os = $_.operatingSystem + if ([string]::IsNullOrWhiteSpace($os)) { return "Unknown" } + switch -Regex ($os.ToLower()) { + 'windows' { "Windows" } + 'macos|mac os' { "macOS" } + 'linux' { "Linux" } + 'ios' { "iOS" } + 'android' { "Android" } + default { "Other" } + } + } | Sort-Object Count -Descending + } + + # Draw pie chart from $platformGroups (works for both $count and fallback paths) $platformColors = @{ - 'Windows' = '#0078D4' # Microsoft Blue - 'iOS' = '#48BB78' # Green - 'Android' = '#9F7AEA' # Purple - 'macOS' = '#F6AD55' # Orange - 'Linux' = '#FC8181' # Red - 'Other' = '#718096' # Gray - 'Unknown' = '#718096' # Gray + 'Windows' = '#0078D4' + 'iOS' = '#48BB78' + 'Android' = '#9F7AEA' + 'macOS' = '#F6AD55' + 'Linux' = '#FC8181' + 'Other' = '#718096' + 'Unknown' = '#718096' } - # Get the canvas and legend panel $canvas = $Window.FindName('PlatformDistributionCanvas') $legendPanel = $Window.FindName('PlatformDistributionLegend') - - # Clear existing content $canvas.Children.Clear() $legendPanel.Children.Clear() - # Calculate total for percentages $total = ($platformGroups | Measure-Object Count -Sum).Sum if ($total -eq 0) { return } - # Initialize variables for pie chart $centerX = 100 $centerY = 100 $radius = 80 $startAngle = 0 - # Draw each platform segment foreach ($platform in $platformGroups) { $percentage = $platform.Count / $total $sweepAngle = 360 * $percentage - - # Convert angles to radians for calculation + $startRad = $startAngle * [Math]::PI / 180 $endRad = ($startAngle + $sweepAngle) * [Math]::PI / 180 - - # Calculate arc points + $startX = $centerX + $radius * [Math]::Cos($startRad) $startY = $centerY + $radius * [Math]::Sin($startRad) $endX = $centerX + $radius * [Math]::Cos($endRad) $endY = $centerY + $radius * [Math]::Sin($endRad) - - # Create path geometry + $path = New-Object System.Windows.Shapes.Path $pathGeometry = New-Object System.Windows.Media.PathGeometry $pathFigure = New-Object System.Windows.Media.PathFigure - - # Start at center + $pathFigure.StartPoint = New-Object System.Windows.Point($centerX, $centerY) - - # Add line to arc start + $lineSegment = New-Object System.Windows.Media.LineSegment( (New-Object System.Windows.Point($startX, $startY)), $true) $pathFigure.Segments.Add($lineSegment) - - # Add arc + $arcSegment = New-Object System.Windows.Media.ArcSegment( (New-Object System.Windows.Point($endX, $endY)), (New-Object System.Windows.Size($radius, $radius)), - 0, # RotationAngle - ($sweepAngle -gt 180), # IsLargeArc + 0, + ($sweepAngle -gt 180), [System.Windows.Media.SweepDirection]::Clockwise, - $true) # IsStroked + $true) $pathFigure.Segments.Add($arcSegment) - - # Close path + $lineSegment = New-Object System.Windows.Media.LineSegment( (New-Object System.Windows.Point($centerX, $centerY)), $true) $pathFigure.Segments.Add($lineSegment) - - # Add figure to geometry + $pathGeometry.Figures.Add($pathFigure) $path.Data = $pathGeometry - - # Set color - $color = if ($platformColors.ContainsKey($platform.Name)) { - $platformColors[$platform.Name] - } - else { - $platformColors['Unknown'] - } + + $color = if ($platformColors.ContainsKey($platform.Name)) { $platformColors[$platform.Name] } else { $platformColors['Unknown'] } $path.Fill = New-Object System.Windows.Media.SolidColorBrush( [System.Windows.Media.ColorConverter]::ConvertFromString($color)) - - # Add to canvas + $canvas.Children.Add($path) - - # Add to legend + $legendItem = New-Object System.Windows.Controls.StackPanel $legendItem.Orientation = "Horizontal" $legendItem.Margin = New-Object System.Windows.Thickness(0, 0, 0, 5) - + $colorBox = New-Object System.Windows.Shapes.Rectangle $colorBox.Width = 12 $colorBox.Height = 12 $colorBox.Fill = $path.Fill $colorBox.Margin = New-Object System.Windows.Thickness(0, 0, 5, 0) - + $label = New-Object System.Windows.Controls.TextBlock $label.Text = "$($platform.Name) ($([Math]::Round($percentage * 100))%)" $label.Foreground = "White" $label.VerticalAlignment = "Center" - + $legendItem.Children.Add($colorBox) $legendItem.Children.Add($label) $legendPanel.Children.Add($legendItem) - - # Update start angle for next segment + $startAngle += $sweepAngle } @@ -6225,7 +6547,7 @@ $StaleDevices30Card.Add_MouseLeftButtonUp({ try { Write-Log "Fetching 30-day stale devices..." $thirtyDaysAgo = (Get-Date).AddDays(-30) - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=lastSyncDateTime lt $($thirtyDaysAgo.ToString('yyyy-MM-ddTHH:mm:ssZ'))" + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=lastSyncDateTime lt $($thirtyDaysAgo.ToString('yyyy-MM-ddTHH:mm:ssZ'))&`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,osVersion,userPrincipalName,managedDeviceOwnerType" $staleDevices = Get-GraphPagedResults -Uri $uri # Ensure we have a valid array @@ -6266,7 +6588,7 @@ $StaleDevices90Card.Add_MouseLeftButtonUp({ try { Write-Log "Fetching 90-day stale devices..." $ninetyDaysAgo = (Get-Date).AddDays(-90) - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=lastSyncDateTime lt $($ninetyDaysAgo.ToString('yyyy-MM-ddTHH:mm:ssZ'))" + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=lastSyncDateTime lt $($ninetyDaysAgo.ToString('yyyy-MM-ddTHH:mm:ssZ'))&`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,osVersion,userPrincipalName,managedDeviceOwnerType" $staleDevices = Get-GraphPagedResults -Uri $uri @@ -6304,7 +6626,7 @@ $StaleDevices180Card.Add_MouseLeftButtonUp({ try { Write-Log "Fetching 180-day stale devices..." $hundredEightyDaysAgo = (Get-Date).AddDays(-180) - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=lastSyncDateTime lt $($hundredEightyDaysAgo.ToString('yyyy-MM-ddTHH:mm:ssZ'))" + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=lastSyncDateTime lt $($hundredEightyDaysAgo.ToString('yyyy-MM-ddTHH:mm:ssZ'))&`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,osVersion,userPrincipalName,managedDeviceOwnerType" $staleDevices = Get-GraphPagedResults -Uri $uri @@ -6341,7 +6663,7 @@ $PersonalDevicesCard.Add_MouseLeftButtonUp({ if (-not $AuthenticateButton.IsEnabled) { try { Write-Log "Fetching personal devices..." - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=managedDeviceOwnerType eq 'personal'" + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=managedDeviceOwnerType eq 'personal'&`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,osVersion,userPrincipalName,managedDeviceOwnerType" $personalDevices = Get-GraphPagedResults -Uri $uri $deviceList = @() @@ -6377,7 +6699,7 @@ $CorporateDevicesCard.Add_MouseLeftButtonUp({ if (-not $AuthenticateButton.IsEnabled) { try { Write-Log "Fetching corporate devices..." - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=managedDeviceOwnerType eq 'company'" + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=managedDeviceOwnerType eq 'company'&`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,osVersion,userPrincipalName,managedDeviceOwnerType" $corporateDevices = Get-GraphPagedResults -Uri $uri $deviceList = @() @@ -6414,9 +6736,9 @@ $IntuneDevicesCard.Add_MouseLeftButtonUp({ if (-not $AuthenticateButton.IsEnabled) { try { Write-Log "Fetching all Intune devices..." - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices" + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,osVersion,userPrincipalName,managedDeviceOwnerType" $intuneDevices = Get-GraphPagedResults -Uri $uri - + $deviceList = @() foreach ($device in $intuneDevices) { $deviceList += [PSCustomObject]@{ @@ -6450,9 +6772,9 @@ $AutopilotDevicesCard.Add_MouseLeftButtonUp({ if (-not $AuthenticateButton.IsEnabled) { try { Write-Log "Fetching all Autopilot devices..." - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities" + $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$select=displayName,serialNumber,lastContactedDateTime,model,manufacturer,userPrincipalName,systemFamily,managedDeviceOwnerType" $autopilotDevices = Get-GraphPagedResults -Uri $uri - + $deviceList = @() foreach ($device in $autopilotDevices) { $deviceList += [PSCustomObject]@{ @@ -6486,7 +6808,7 @@ $EntraIDDevicesCard.Add_MouseLeftButtonUp({ if (-not $AuthenticateButton.IsEnabled) { try { Write-Log "Fetching all Entra ID devices..." - $uri = "https://graph.microsoft.com/v1.0/devices" + $uri = "https://graph.microsoft.com/beta/devices?`$select=displayName,operatingSystem,operatingSystemVersion,approximateLastSignInDateTime,deviceOwnership" $entraDevices = Get-GraphPagedResults -Uri $uri $deviceList = @() diff --git a/Playbooks/Playbook_1.ps1 b/Playbooks/Playbook_1.ps1 index 510e7c9..1bd1cc3 100644 --- a/Playbooks/Playbook_1.ps1 +++ b/Playbooks/Playbook_1.ps1 @@ -86,13 +86,13 @@ function Get-AutopilotNotIntuneDevices { try { # Get all Autopilot devices Write-Host "Fetching Autopilot devices..." -ForegroundColor Cyan - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities" + $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$select=id,displayName,serialNumber,lastContactedDateTime,model,manufacturer" $autopilotDevices = Get-GraphPagedResults -Uri $uri Write-Host "Found $($autopilotDevices.Count) Autopilot devices" -ForegroundColor Green # Get all Intune devices Write-Host "Fetching Intune devices..." -ForegroundColor Cyan - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices" + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=serialNumber" $intuneDevices = Get-GraphPagedResults -Uri $uri Write-Host "Found $($intuneDevices.Count) Intune devices" -ForegroundColor Green diff --git a/Playbooks/Playbook_2.ps1 b/Playbooks/Playbook_2.ps1 index 645cc0c..045653c 100644 --- a/Playbooks/Playbook_2.ps1 +++ b/Playbooks/Playbook_2.ps1 @@ -86,13 +86,13 @@ function Get-IntuneNotAutopilotDevices { try { # Get all Autopilot devices Write-Host "Fetching Autopilot devices..." -ForegroundColor Cyan - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities" + $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$select=serialNumber" $autopilotDevices = Get-GraphPagedResults -Uri $uri Write-Host "Found $($autopilotDevices.Count) Autopilot devices" -ForegroundColor Green # Get all Intune devices Write-Host "Fetching Intune devices..." -ForegroundColor Cyan - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices" + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=id,deviceName,serialNumber,operatingSystem,model,userDisplayName,lastSyncDateTime" $intuneDevices = Get-GraphPagedResults -Uri $uri Write-Host "Found $($intuneDevices.Count) Intune devices" -ForegroundColor Green diff --git a/Playbooks/Playbook_3.ps1 b/Playbooks/Playbook_3.ps1 index 43df9fd..4237a80 100644 --- a/Playbooks/Playbook_3.ps1 +++ b/Playbooks/Playbook_3.ps1 @@ -86,7 +86,7 @@ function Get-CorporateDevices { try { # Get all corporate devices from Intune Write-Host "Fetching corporate devices from Intune..." -ForegroundColor Cyan - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=managedDeviceOwnerType eq 'company'" + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=managedDeviceOwnerType eq 'company'&`$select=deviceName,serialNumber,operatingSystem,model,managedDeviceOwnerType,lastSyncDateTime" $corporateDevices = Get-GraphPagedResults -Uri $uri Write-Host "Found $($corporateDevices.Count) corporate devices" -ForegroundColor Green diff --git a/Playbooks/Playbook_4.ps1 b/Playbooks/Playbook_4.ps1 index 8321ea3..2f1f803 100644 --- a/Playbooks/Playbook_4.ps1 +++ b/Playbooks/Playbook_4.ps1 @@ -86,7 +86,7 @@ function Get-PersonalDevices { try { # Get all personal devices from Intune Write-Host "Fetching personal devices from Intune..." -ForegroundColor Cyan - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=managedDeviceOwnerType eq 'personal'" + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=managedDeviceOwnerType eq 'personal'&`$select=deviceName,serialNumber,operatingSystem,model,managedDeviceOwnerType,lastSyncDateTime" $personalDevices = Get-GraphPagedResults -Uri $uri Write-Host "Found $($personalDevices.Count) personal devices" -ForegroundColor Green diff --git a/Playbooks/Playbook_5.ps1 b/Playbooks/Playbook_5.ps1 index 0665d47..ff69665 100644 --- a/Playbooks/Playbook_5.ps1 +++ b/Playbooks/Playbook_5.ps1 @@ -98,7 +98,7 @@ function Get-StaleDevices { # Get all stale devices from Intune Write-Host "Fetching devices not synced since $staleDateString..." -ForegroundColor Cyan - $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=lastSyncDateTime lt $staleDateString" + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=lastSyncDateTime lt $staleDateString&`$select=deviceName,serialNumber,operatingSystem,model,managedDeviceOwnerType,lastSyncDateTime" $staleDevices = Get-GraphPagedResults -Uri $uri Write-Host "Found $($staleDevices.Count) stale devices" -ForegroundColor Green diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 0000000..3390974 --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,100 @@ +# v0.3 Release Plan + +Branch: `feature/v0.3` +Previous version: 0.2.2 + +--- + +## Phase 1: Critical Bug Fixes + +These are broken today and must be fixed before any new features. + +- [x] **Fix export functions** — Replaced non-existent properties with actual `DeviceObject` class properties. +- [x] **Fix playbook download path** — Load from local `$PSScriptRoot/Playbooks/` instead of downloading from GitHub. +- [x] **Fix BitLocker key retrieval** — Use `$keyIdResponse[0].id` to access first element. +- [x] **Fix Playbook_1 field name** — `lastContactDateTime` -> `lastContactedDateTime`. +- [x] **Fix `$deviceSuccess++` in PSCustomObject** — Extracted counter logic before PSCustomObject. +- [x] **Fix permission scopes** — `Device.ReadWrite.All`, added `BitlockerKey.Read.All`. Kept unused scopes for future phases. +- [x] **Fix stale `$script:parsedDevices`** — Clear on cancel button handler. +- [x] **Fix playbook result columns** — Dynamic column generation from playbook output schema. + +--- + +## Phase 2: Safety & Identity (Core v0.3 Theme) + +Addresses wrong-device deletion (Issues #47, #49) and audit requirements (#35). + +- [ ] **Strict device identity matching** — Replace `-like "*$deviceName*"` partial matching with exact match for Autopilot client-side filtering. Use serial number as primary correlation key. When multiple matches exist, show all and require explicit selection. +- [ ] **Dry run / preview mode** — Before any delete, resolve all Graph object IDs and display them in the confirmation dialog. Show exact Entra ID object ID, Intune device ID, and Autopilot identity ID for each device being removed. +- [ ] **Search by Entra Device ID** — Add "Device ID" as a third option in the search dropdown (Issue #54). Use IDs internally for all delete operations. +- [ ] **Disable-before-delete option** — Add "Disable in Entra ID" action (`PATCH /devices/{id}` with `accountEnabled: false`) as an alternative to immediate deletion. +- [ ] **Persistent audit logging** — Timestamped log filenames (e.g., `DOM_20260313_143022.log`). Log admin UPN, device identifiers, Graph object IDs, action taken, and success/failure. Log recovery keys before deletion (with sensitivity warning). + +--- + +## Phase 3: Graph API Improvements + +- [x] **Add retry logic with backoff** — `Invoke-GraphRequestWithRetry` handles 429 throttling (reads Retry-After header), 5xx transient errors (exponential backoff), and network-level failures. +- [x] **Update `Get-GraphPagedResults`** — Uses retry wrapper, accepts optional `$Headers` parameter for `ConsistencyLevel: eventual`. +- [x] **Implement batch requests** — `Invoke-GraphBatchRequest` helper: auto-chunks >20 requests, retries failed sub-requests. Used for search (Entra+Intune), offboarding (Entra+Intune+Autopilot per device), and dashboard `$count`. +- [x] **Migrate all v1.0 endpoints to beta** — Zero v1.0 references remaining across main script and all 5 playbooks. +- [x] **Add `$select` to all GET calls** — Every GET endpoint now specifies only the properties accessed downstream. +- [x] **Add `$count` for dashboard statistics** — Single `$batch` call with 13 `$count` sub-requests replaces 3 full-collection fetches + client-side counting. Includes per-OS counts for pie chart. Falls back to full-fetch if `$count` fails. +- [x] **Batch search queries** — Devicename search batches Entra+Intune; serial search batches Intune+Autopilot. Autopilot full-fetch for devicename search hoisted out of per-term loop. +- [x] **Batch offboarding operations** — Per-device batch combines Entra+Intune+Autopilot operations into single `$batch` call. +- [x] **Implement bulk Autopilot deletion** — When 2+ devices selected, uses `deleteDevices` bulk endpoint. Falls back to individual deletion on failure. + +--- + +## Phase 4: New Features + +### Offboarding Actions +- [ ] **Retire/Wipe before delete** — Add optional pre-offboarding actions: Retire (`POST .../retire`), Wipe (`POST .../wipe`), or Delete-only. Enforce correct ordering. +- [ ] **Defender for Endpoint offboarding** — Add MDE as a fourth service checkbox (`POST /api/machines/{id}/offboard`). Requires `Machine.Offboard` permission. (Issue #11) +- [ ] **LAPS password retrieval** — Display LAPS password in confirmation dialog alongside BitLocker/FileVault. `GET /deviceLocalCredentials/{id}` with `DeviceLocalCredential.Read.All`. (Issue #13) +- [ ] **Multi-Admin Approval awareness** — Detect MAA pending state in API responses. Show notification when action requires second admin approval. (Issue #58) + +### Search & Display +- [ ] **Partial/wildcard search** — Add "Contains" search mode using `startsWith()` / `contains()` OData filters where supported. (Issue #9) +- [ ] **Device group membership display** — Show Entra ID group memberships via `GET /devices/{id}/memberOf` for impact assessment before offboarding. +- [ ] **Device compliance state** — Display `complianceState` from managed device properties in results grid. + +### Playbooks +- [ ] **Implement Playbook 6: OS-Specific devices** — Filter by specific OS platform. +- [ ] **Implement Playbook 7: Outdated OS devices** — Devices not running latest OS version. +- [ ] **Implement Playbook 8: EOL OS devices** — Devices running end-of-life OS versions. +- [ ] **Implement Playbook 9: BitLocker Key Report** — Audit report of all BitLocker recovery keys. +- [ ] **Implement Playbook 10: FileVault Key Report** — Audit report of all FileVault recovery keys. + +--- + +## Phase 5: Code Quality & UX + +- [ ] **Load playbooks from local filesystem** — Use `$PSScriptRoot/Playbooks/` instead of downloading from GitHub at runtime. Eliminates security risk of remote code execution without integrity verification. +- [ ] **Fix dashboard threading** — Primary `$count` path no longer uses thread jobs (fixed in Phase 3). Fallback full-fetch path still uses thread jobs without Graph auth context. Either pass token explicitly or use `-InitializationScript` to re-authenticate in worker threads. +- [ ] **Fix dashboard card UI blocking** — Card click handlers run synchronous Graph calls. Move to background jobs with loading indicators. +- [ ] **Fix search box Enter key** — Remove `AcceptsReturn="True"` and add `KeyDown` handler to submit on Enter. +- [ ] **Make window resizable** — Change `ResizeMode="NoResize"` to `CanResize` with minimum size constraints. Current 1200x700 clips on many laptops. +- [ ] **Remove dead code** — Empty `GotFocus`/`LostFocus` handlers, unused `ToastNotificationStyle`, non-functional `RemoveHandler` calls. +- [ ] **Deduplicate utility functions** — Extract `Get-GraphPagedResults` and `ConvertTo-SafeDateTime` into a shared module or dot-sourced file. Currently duplicated 9 and 6 times respectively. +- [ ] **Clear client secret after use** — Zero out `$AuthDetails.Secret` after `Connect-ToGraph` completes. + +--- + +## Phase 6: Polish (If Time Permits) + +- [ ] Advanced grid filtering and shift-click range selection (Issue #33) +- [ ] Offboarding report generation — HTML/PDF audit artifact +- [ ] Saved authentication config for service principals (Issue #48) +- [ ] Platform filtering on dashboard (Issue #40) +- [ ] Autopilot group tag management (Issue #21) +- [ ] Localization / multi-language support (Issue #14) +- [ ] Co-management awareness — Detect and warn about co-managed devices + +--- + +## Notes + +- All Graph API calls should use `beta` endpoints (not v1.0) for richer response data +- Run `feature-dev:code-reviewer` before marking any phase complete +- Each phase should be a separate PR or commit group for clean history From b63ab90f942ac9baff5e4c225ce57f95490a959a Mon Sep 17 00:00:00 2001 From: Ugur Koc Date: Sat, 14 Mar 2026 12:04:04 +0100 Subject: [PATCH 03/16] Phase 6: Polish - saved auth config, co-management awareness, platform filtering, grid filtering, HTML reports - Add saved authentication config (#48): Save/auto-load cert and secret configs to %LocalAppData%/DeviceOffboardingManager. Client secret is never persisted for security. - Add co-management awareness: ManagementAgent property on DeviceObject, amber warning banner in confirmation dialog for co-managed devices. - Add platform filtering on dashboard (#40): ComboBox to filter all dashboard statistics by OS platform. - Add grid filtering and shift-click range selection (#33): Filter TextBoxes above DataGrid for live column filtering, shift-click on checkboxes to toggle ranges. - Add HTML offboarding report generation: Export-OffboardingReport function with professional styled HTML, per-device service status, summary stats. Export buttons in summary and dashboard dialogs. - Update homepage to show Defender for Endpoint as supported (no longer "Soon"). Co-Authored-By: Claude Opus 4.6 (1M context) --- DeviceOffboardingManager.ps1 | 1583 +++++++++++++++++++++++++++------ Playbooks/PlaybookHelpers.ps1 | 131 +++ Playbooks/Playbook_1.ps1 | 82 +- Playbooks/Playbook_10.ps1 | 69 ++ Playbooks/Playbook_2.ps1 | 82 +- Playbooks/Playbook_3.ps1 | 82 +- Playbooks/Playbook_4.ps1 | 82 +- Playbooks/Playbook_5.ps1 | 82 +- Playbooks/Playbook_6.ps1 | 48 + Playbooks/Playbook_7.ps1 | 84 ++ Playbooks/Playbook_8.ps1 | 73 ++ Playbooks/Playbook_9.ps1 | 83 ++ 12 files changed, 1817 insertions(+), 664 deletions(-) create mode 100644 Playbooks/PlaybookHelpers.ps1 create mode 100644 Playbooks/Playbook_10.ps1 create mode 100644 Playbooks/Playbook_6.ps1 create mode 100644 Playbooks/Playbook_7.ps1 create mode 100644 Playbooks/Playbook_8.ps1 create mode 100644 Playbooks/Playbook_9.ps1 diff --git a/DeviceOffboardingManager.ps1 b/DeviceOffboardingManager.ps1 index 8629ffd..0803138 100644 --- a/DeviceOffboardingManager.ps1 +++ b/DeviceOffboardingManager.ps1 @@ -117,13 +117,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 +130,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) } - ) } } } @@ -191,6 +173,9 @@ if (-not ([System.Management.Automation.PSTypeName]'DeviceObject').Type) { 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; @@ -408,7 +393,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"> @@ -720,23 +706,6 @@ function ConvertTo-SafeDateTime { - - - + + +
+
+

Device Offboarding Report

+
Generated: $timestamp | Admin: $adminUPN | Device Offboarding Manager $version
+
+
+
$total
Total Devices
+
$successCount
Successful
+
$partialCount
Partial
+
$failedCount
Failed
+
+ + + + + + + + + + + + +$deviceRows + +
Device NameSerial NumberEntra IDIntuneAutopilotMDE
+ +
+ + +"@ + + [System.IO.File]::WriteAllText($exportPath, $html) + Write-Log "Exported offboarding report to: $exportPath" -Severity "AUDIT" + [System.Windows.MessageBox]::Show( + "Report exported successfully to:`n$exportPath", + "Export Successful", + [System.Windows.MessageBoxButton]::OK, + [System.Windows.MessageBoxImage]::Information + ) + return $true + } + catch { + Write-Log "Error exporting offboarding report: $_" -Severity "ERROR" + [System.Windows.MessageBox]::Show( + "Error exporting report: $_", + "Export Error", + [System.Windows.MessageBoxButton]::OK, + [System.Windows.MessageBoxImage]::Error + ) + return $false + } +} + function Invoke-DeviceSearch { param( [Parameter(Mandatory = $true)] @@ -3527,7 +3827,7 @@ function Invoke-DeviceSearch { # Batch Entra + Intune queries together $batchRequests = @( @{ id = "entra"; method = "GET"; url = "/devices?`$filter=displayName eq '$SearchText'&`$select=id,deviceId,displayName,operatingSystem,approximateLastSignInDateTime,accountEnabled,physicalIds" } - @{ id = "intune"; method = "GET"; url = "/deviceManagement/managedDevices?`$filter=deviceName eq '$SearchText'&`$select=id,deviceName,serialNumber,operatingSystem,userDisplayName,lastSyncDateTime,azureADDeviceId" } + @{ id = "intune"; method = "GET"; url = "/deviceManagement/managedDevices?`$filter=deviceName eq '$SearchText'&`$select=id,deviceName,serialNumber,operatingSystem,userDisplayName,lastSyncDateTime,azureADDeviceId,complianceState,managementAgent" } ) $batchResponses = Invoke-GraphBatchRequest -Requests $batchRequests $entraResp = $batchResponses | Where-Object { $_.id -eq "entra" } @@ -3582,6 +3882,8 @@ function Invoke-DeviceSearch { $CombinedDevice.IntuneDeviceId = $matchingIntuneDevice?.id $CombinedDevice.AutopilotIdentityId = $matchingAutopilotDevice?.id $CombinedDevice.EntraAccountEnabled = if ($null -ne $AADDevice.accountEnabled) { $AADDevice.accountEnabled.ToString() } else { $null } + $CombinedDevice.ComplianceState = $matchingIntuneDevice?.complianceState + $CombinedDevice.ManagementAgent = $matchingIntuneDevice?.managementAgent $searchResults.Add($CombinedDevice) $AADCount++ @@ -3589,7 +3891,7 @@ function Invoke-DeviceSearch { if ($matchingAutopilotDevice) { $AutopilotCount++ } } } - + # Process Intune devices not in Entra ID if ($IntuneDevices) { foreach ($IntuneDevice in $IntuneDevices) { @@ -3617,13 +3919,15 @@ function Invoke-DeviceSearch { $CombinedDevice.AutopilotLastContact = ConvertTo-SafeDateTime -dateString $matchingAutopilotDevice.lastContactedDateTime $CombinedDevice.IntuneDeviceId = $IntuneDevice.id $CombinedDevice.AutopilotIdentityId = $matchingAutopilotDevice?.id + $CombinedDevice.ComplianceState = $IntuneDevice.complianceState + $CombinedDevice.ManagementAgent = $IntuneDevice.managementAgent $searchResults.Add($CombinedDevice) $IntuneCount++ if ($matchingAutopilotDevice) { $AutopilotCount++ } } } - + # Process Autopilot devices not in Entra ID or Intune if ($AutopilotDevices) { foreach ($AutopilotDevice in $AutopilotDevices) { @@ -3650,7 +3954,7 @@ function Invoke-DeviceSearch { elseif ($SearchOption -eq "Serialnumber") { # Batch Intune + Autopilot queries together $batchRequests = @( - @{ id = "intune"; method = "GET"; url = "/deviceManagement/managedDevices?`$filter=serialNumber eq '$SearchText'&`$select=id,deviceName,serialNumber,operatingSystem,userDisplayName,lastSyncDateTime,azureADDeviceId" } + @{ id = "intune"; method = "GET"; url = "/deviceManagement/managedDevices?`$filter=serialNumber eq '$SearchText'&`$select=id,deviceName,serialNumber,operatingSystem,userDisplayName,lastSyncDateTime,azureADDeviceId,complianceState,managementAgent" } @{ id = "autopilot"; method = "GET"; url = "/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$SearchText')&`$select=id,displayName,serialNumber,lastContactedDateTime" } ) $batchResponses = Invoke-GraphBatchRequest -Requests $batchRequests @@ -3684,6 +3988,8 @@ function Invoke-DeviceSearch { $CombinedDevice.IntuneDeviceId = $IntuneDevice.id $CombinedDevice.AutopilotIdentityId = $matchingAutopilotDevice?.id $CombinedDevice.EntraAccountEnabled = if ($null -ne $AADDevice -and $null -ne $AADDevice.accountEnabled) { $AADDevice.accountEnabled.ToString() } else { $null } + $CombinedDevice.ComplianceState = $IntuneDevice.complianceState + $CombinedDevice.ManagementAgent = $IntuneDevice.managementAgent $searchResults.Add($CombinedDevice) if ($AADDevice) { $AADCount++ } @@ -3729,7 +4035,7 @@ function Invoke-DeviceSearch { # Cross-reference Intune by azureADDeviceId for accurate matching $IntuneDevice = $null if ($AADDevice.deviceId) { - $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$($AADDevice.deviceId)'&`$select=id,deviceName,serialNumber,operatingSystem,userDisplayName,lastSyncDateTime,azureADDeviceId" + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$($AADDevice.deviceId)'&`$select=id,deviceName,serialNumber,operatingSystem,userDisplayName,lastSyncDateTime,azureADDeviceId,complianceState,managementAgent" $IntuneDevice = (Get-GraphPagedResults -Uri $uri) | Select-Object -First 1 if ($IntuneDevice) { $IntuneCount++ } } @@ -3766,6 +4072,8 @@ function Invoke-DeviceSearch { $CombinedDevice.IntuneDeviceId = $IntuneDevice?.id $CombinedDevice.AutopilotIdentityId = $AutopilotDevice?.id $CombinedDevice.EntraAccountEnabled = if ($null -ne $AADDevice.accountEnabled) { $AADDevice.accountEnabled.ToString() } else { $null } + $CombinedDevice.ComplianceState = $IntuneDevice?.complianceState + $CombinedDevice.ManagementAgent = $IntuneDevice?.managementAgent $searchResults.Add($CombinedDevice) } @@ -3773,8 +4081,123 @@ function Invoke-DeviceSearch { Write-Log "No device found with ID: $SearchText" } } + elseif ($SearchOption -eq "Contains (partial match)") { + # Batch Entra (startsWith) + Intune (contains) queries + $batchRequests = @( + @{ id = "entra"; method = "GET"; url = "/devices?`$filter=startsWith(displayName,'$SearchText')&`$select=id,deviceId,displayName,operatingSystem,approximateLastSignInDateTime,accountEnabled,physicalIds&`$count=true"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "intune"; method = "GET"; url = "/deviceManagement/managedDevices?`$filter=contains(deviceName,'$SearchText')&`$select=id,deviceName,serialNumber,operatingSystem,userDisplayName,lastSyncDateTime,azureADDeviceId,complianceState,managementAgent" } + ) + $batchResponses = Invoke-GraphBatchRequest -Requests $batchRequests + $entraResp = $batchResponses | Where-Object { $_.id -eq "entra" } + $intuneResp = $batchResponses | Where-Object { $_.id -eq "intune" } + $AADDevices = if ($entraResp -and $entraResp.status -eq 200 -and $entraResp.body.value) { $entraResp.body.value } else { @() } + $IntuneDevices = if ($intuneResp -and $intuneResp.status -eq 200 -and $intuneResp.body.value) { $intuneResp.body.value } else { @() } + + # Pre-fetch Autopilot devices for client-side filtering + $AutopilotDevices = @() + try { + $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$select=id,displayName,serialNumber,lastContactedDateTime" + $allAutopilot = Get-GraphPagedResults -Uri $uri + $AutopilotDevices = $allAutopilot | Where-Object { $_.displayName -and $_.displayName -like "*$SearchText*" } + } catch { + Write-Log "Error fetching Autopilot devices for partial match: $_" + } + + # Process Entra ID devices + if ($AADDevices) { + foreach ($AADDevice in $AADDevices) { + $matchingIntuneDevice = $IntuneDevices | Where-Object { $_.deviceName -eq $AADDevice.displayName } | Select-Object -First 1 + $matchingAutopilotDevice = $AutopilotDevices | Where-Object { $_.displayName -eq $AADDevice.displayName } | Select-Object -First 1 + + if (-not $matchingAutopilotDevice -and $matchingIntuneDevice -and $matchingIntuneDevice.serialNumber) { + $matchingAutopilotDevice = $AutopilotDevices | Where-Object { $_.serialNumber -eq $matchingIntuneDevice.serialNumber } | Select-Object -First 1 + } + + $CombinedDevice = New-Object DeviceObject + $CombinedDevice.IsSelected = $false + $CombinedDevice.DeviceName = $AADDevice.displayName + $CombinedDevice.SerialNumber = $matchingIntuneDevice?.serialNumber ?? $matchingAutopilotDevice?.serialNumber + if (-not $CombinedDevice.SerialNumber -and $AADDevice.physicalIds) { + foreach ($physicalId in $AADDevice.physicalIds) { + if ($physicalId -match '\[SerialNumber\]:(.+)') { + $CombinedDevice.SerialNumber = $matches[1].Trim() + break + } + } + } + $CombinedDevice.OperatingSystem = $AADDevice.operatingSystem + $CombinedDevice.PrimaryUser = $matchingIntuneDevice?.userDisplayName + $CombinedDevice.AzureADLastContact = ConvertTo-SafeDateTime -dateString $AADDevice.approximateLastSignInDateTime + $CombinedDevice.IntuneLastContact = ConvertTo-SafeDateTime -dateString $matchingIntuneDevice.lastSyncDateTime + $CombinedDevice.AutopilotLastContact = ConvertTo-SafeDateTime -dateString $matchingAutopilotDevice.lastContactedDateTime + $CombinedDevice.EntraDeviceId = $AADDevice.id + $CombinedDevice.EntraDeviceObjectId = $AADDevice.deviceId + $CombinedDevice.IntuneDeviceId = $matchingIntuneDevice?.id + $CombinedDevice.AutopilotIdentityId = $matchingAutopilotDevice?.id + $CombinedDevice.EntraAccountEnabled = if ($null -ne $AADDevice.accountEnabled) { $AADDevice.accountEnabled.ToString() } else { $null } + $CombinedDevice.ComplianceState = $matchingIntuneDevice?.complianceState + $CombinedDevice.ManagementAgent = $matchingIntuneDevice?.managementAgent + + $searchResults.Add($CombinedDevice) + $AADCount++ + if ($matchingIntuneDevice) { $IntuneCount++ } + if ($matchingAutopilotDevice) { $AutopilotCount++ } + } + } + + # Process Intune devices not in Entra ID results + if ($IntuneDevices) { + foreach ($IntuneDevice in $IntuneDevices) { + if ($searchResults | Where-Object { $_.DeviceName -eq $IntuneDevice.deviceName }) { + continue + } + $matchingAutopilotDevice = $AutopilotDevices | Where-Object { $_.displayName -eq $IntuneDevice.deviceName } | Select-Object -First 1 + if (-not $matchingAutopilotDevice -and $IntuneDevice.serialNumber) { + $matchingAutopilotDevice = $AutopilotDevices | Where-Object { $_.serialNumber -eq $IntuneDevice.serialNumber } | Select-Object -First 1 + } + + $CombinedDevice = New-Object DeviceObject + $CombinedDevice.IsSelected = $false + $CombinedDevice.DeviceName = $IntuneDevice.deviceName + $CombinedDevice.SerialNumber = $IntuneDevice.serialNumber ?? $matchingAutopilotDevice?.serialNumber + $CombinedDevice.OperatingSystem = $IntuneDevice.operatingSystem + $CombinedDevice.PrimaryUser = $IntuneDevice.userDisplayName + $CombinedDevice.IntuneLastContact = ConvertTo-SafeDateTime -dateString $IntuneDevice.lastSyncDateTime + $CombinedDevice.AutopilotLastContact = ConvertTo-SafeDateTime -dateString $matchingAutopilotDevice.lastContactedDateTime + $CombinedDevice.IntuneDeviceId = $IntuneDevice.id + $CombinedDevice.AutopilotIdentityId = $matchingAutopilotDevice?.id + $CombinedDevice.ComplianceState = $IntuneDevice.complianceState + $CombinedDevice.ManagementAgent = $IntuneDevice.managementAgent + + $searchResults.Add($CombinedDevice) + $IntuneCount++ + if ($matchingAutopilotDevice) { $AutopilotCount++ } + } + } + + # Process Autopilot-only devices + if ($AutopilotDevices) { + foreach ($AutopilotDevice in $AutopilotDevices) { + if ($searchResults | Where-Object { + $_.DeviceName -eq $AutopilotDevice.displayName -or + ($_.SerialNumber -and $_.SerialNumber -eq $AutopilotDevice.serialNumber) + }) { + continue + } + $CombinedDevice = New-Object DeviceObject + $CombinedDevice.IsSelected = $false + $CombinedDevice.DeviceName = $AutopilotDevice.displayName + $CombinedDevice.SerialNumber = $AutopilotDevice.serialNumber + $CombinedDevice.AutopilotLastContact = ConvertTo-SafeDateTime -dateString $AutopilotDevice.lastContactedDateTime + $CombinedDevice.AutopilotIdentityId = $AutopilotDevice.id + + $searchResults.Add($CombinedDevice) + $AutopilotCount++ + } + } + } } - + # Update UI status $Window.FindName('intune_status').Text = "Intune: $IntuneCount device found" $Window.FindName('intune_status').Foreground = if ($IntuneCount -gt 0) { '#4299E1' } else { '#FC8181' } @@ -3784,13 +4207,19 @@ function Invoke-DeviceSearch { $Window.FindName('aad_status').Foreground = if ($AADCount -gt 0) { '#ED64A6' } else { '#FC8181' } if ($searchResults.Count -gt 0) { + $script:AllSearchResults = $searchResults $SearchResultsDataGrid.ItemsSource = $searchResults + $script:LastCheckedIndex = -1 + # Show filter row when results are available + $FilterRow.Visibility = 'Visible' } else { + $script:AllSearchResults = $null $SearchResultsDataGrid.ItemsSource = $null + $FilterRow.Visibility = 'Collapsed' [System.Windows.MessageBox]::Show("No devices found matching the search criteria.") } - + # Ensure Offboard button and Export Selected button are disabled until selection $OffboardButton.IsEnabled = $false $ExportSelectedButton.IsEnabled = $false @@ -3813,24 +4242,102 @@ $Disconnect = $Window.FindName('disconnect_button') $logs_button = $Window.FindName('logs_button') $PrerequisitesButton = $Window.FindName('PrerequisitesButton') $FeedbackLink = $Window.FindName('FeedbackLink') +$FilterRow = $Window.FindName('FilterRow') +$FilterDeviceName = $Window.FindName('FilterDeviceName') +$FilterSerialNumber = $Window.FindName('FilterSerialNumber') +$FilterOS = $Window.FindName('FilterOS') +$FilterPrimaryUser = $Window.FindName('FilterPrimaryUser') +$FilterCompliance = $Window.FindName('FilterCompliance') +$SearchResultsDataGrid = $Window.FindName('SearchResultsDataGrid') + +# Grid filter function +function Update-DeviceFilter { + if (-not $script:AllSearchResults) { return } + $filtered = $script:AllSearchResults + $nameFilter = $FilterDeviceName.Text + $serialFilter = $FilterSerialNumber.Text + $osFilter = $FilterOS.Text + $userFilter = $FilterPrimaryUser.Text + $compFilter = $FilterCompliance.Text + if ($nameFilter) { $filtered = $filtered | Where-Object { $_.DeviceName -like "*$nameFilter*" } } + if ($serialFilter) { $filtered = $filtered | Where-Object { $_.SerialNumber -like "*$serialFilter*" } } + if ($osFilter) { $filtered = $filtered | Where-Object { $_.OperatingSystem -like "*$osFilter*" } } + if ($userFilter) { $filtered = $filtered | Where-Object { $_.PrimaryUser -like "*$userFilter*" } } + if ($compFilter) { $filtered = $filtered | Where-Object { $_.ComplianceState -like "*$compFilter*" } } + $SearchResultsDataGrid.ItemsSource = @($filtered) + $script:LastCheckedIndex = -1 +} + +# Wire filter TextChanged events +$FilterDeviceName.Add_TextChanged({ Update-DeviceFilter }) +$FilterSerialNumber.Add_TextChanged({ Update-DeviceFilter }) +$FilterOS.Add_TextChanged({ Update-DeviceFilter }) +$FilterPrimaryUser.Add_TextChanged({ Update-DeviceFilter }) +$FilterCompliance.Add_TextChanged({ Update-DeviceFilter }) + +# Shift-click range selection +$script:LastCheckedIndex = -1 +$SearchResultsDataGrid.Add_PreviewMouseLeftButtonDown({ + param($sender, $e) + $source = $e.OriginalSource + # Walk up to find CheckBox + $element = $source + $isCheckBox = $false + while ($element -ne $null) { + if ($element -is [System.Windows.Controls.CheckBox]) { + $isCheckBox = $true + break + } + if ($element -is [System.Windows.Controls.DataGridRow]) { break } + $element = [System.Windows.Media.VisualTreeHelper]::GetParent($element) + } + if (-not $isCheckBox) { return } + + # Find the DataGridRow + $row = $source + while ($row -ne $null -and $row -isnot [System.Windows.Controls.DataGridRow]) { + $row = [System.Windows.Media.VisualTreeHelper]::GetParent($row) + } + if (-not $row) { return } + $currentIndex = $SearchResultsDataGrid.ItemContainerGenerator.IndexFromContainer($row) + if ($currentIndex -lt 0) { return } + + if ([System.Windows.Input.Keyboard]::IsKeyDown([System.Windows.Input.Key]::LeftShift) -or + [System.Windows.Input.Keyboard]::IsKeyDown([System.Windows.Input.Key]::RightShift)) { + if ($script:LastCheckedIndex -ge 0) { + $start = [Math]::Min($script:LastCheckedIndex, $currentIndex) + $end = [Math]::Max($script:LastCheckedIndex, $currentIndex) + $items = $SearchResultsDataGrid.ItemsSource + $targetState = -not $items[$currentIndex].IsSelected + for ($i = $start; $i -le $end; $i++) { + $items[$i].IsSelected = $targetState + } + $e.Handled = $true + } + } + $script:LastCheckedIndex = $currentIndex + }) # Add feedback link handler $FeedbackLink.Add_Click({ Start-Process "https://github.com/ugurkocde/DeviceOffboardingManager/issues" }) -$SearchInputText.Add_GotFocus({ - # Empty - no resizing needed - }) - -$SearchInputText.Add_LostFocus({ - # Empty - no resizing needed +$SearchInputText.Add_KeyDown({ + param($sender, $e) + if ($e.Key -eq [System.Windows.Input.Key]::Return) { + $e.Handled = $true + $SearchButton.RaiseEvent( + (New-Object System.Windows.RoutedEventArgs( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent))) + } }) $Window.Add_Loaded({ $Dropdown.Items.Add("Devicename") $Dropdown.Items.Add("Serialnumber") $Dropdown.Items.Add("Device ID") + $Dropdown.Items.Add("Contains (partial match)") $Dropdown.SelectedIndex = 0 }) @@ -4227,6 +4734,22 @@ $OffboardButton.Add_Click({ + + + + + + + + + + + + + + + + @@ -4280,7 +4803,7 @@ $OffboardButton.Add_Click({ - + @@ -4335,6 +4858,14 @@ $OffboardButton.Add_Click({ $confirmButton = $confirmationWindow.FindName('ConfirmButton') $encryptionKeysList = $confirmationWindow.FindName('EncryptionKeysList') $devicePreviewList = $confirmationWindow.FindName('DevicePreviewList') + $preActionCombo = $confirmationWindow.FindName('PreActionComboBox') + $coMgmtBanner = $confirmationWindow.FindName('CoMgmtBanner') + + # Check for co-managed devices and show warning banner + $hasCoManaged = $selectedDevices | Where-Object { $_.ManagementAgent -and $_.ManagementAgent -like '*configurationManager*' } + if ($hasCoManaged) { + $coMgmtBanner.Visibility = 'Visible' + } # Populate Device Identity Preview $previewItems = New-Object System.Collections.ObjectModel.ObservableCollection[Object] @@ -4436,6 +4967,41 @@ $OffboardButton.Add_Click({ else { $keyInfo.KeyText = "Device not found in Intune." } + + # LAPS password retrieval (works for any OS, uses Entra device ID) + $lapsKeyInfo = @{ + DeviceName = "$($selectedDevice.DeviceName) - LAPS" + KeyText = "Loading LAPS password..." + Key = $null + } + $lapsDeviceId = $selectedDevice.EntraDeviceObjectId + if ($lapsDeviceId) { + try { + $uri = "https://graph.microsoft.com/beta/directory/deviceLocalCredentials/$lapsDeviceId?`$select=credentials" + $lapsResponse = Invoke-MgGraphRequest -Uri $uri -Method GET + if ($lapsResponse.credentials -and $lapsResponse.credentials.Count -gt 0) { + $latestCred = $lapsResponse.credentials | Sort-Object -Property backupDateTime -Descending | Select-Object -First 1 + $lapsPassword = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($latestCred.passwordBase64)) + $lapsAccount = $latestCred.accountName + $lapsKeyInfo.KeyText = "LAPS Password: $lapsPassword (Account: $lapsAccount)" + $lapsKeyInfo.Key = $lapsPassword + Write-Log "SENSITIVE: LAPS password retrieved for device $($selectedDevice.DeviceName)" -Severity "AUDIT" + } else { + $lapsKeyInfo.KeyText = "No LAPS password found for this device." + } + } + catch { + if ($_.Exception.Response.StatusCode -eq 'NotFound' -or $_ -match '404') { + $lapsKeyInfo.KeyText = "No LAPS password found for this device." + } else { + Write-Log "Error retrieving LAPS password: $_" -Severity "ERROR" + $lapsKeyInfo.KeyText = "Error retrieving LAPS password. Check logs for details." + } + } + } else { + $lapsKeyInfo.KeyText = "No Entra device ID available for LAPS lookup." + } + $encryptionKeys.Add([PSCustomObject]$lapsKeyInfo) } catch { Write-Log "Error retrieving encryption key for $($selectedDevice.DeviceName): $_" -Severity "ERROR" @@ -4478,7 +5044,8 @@ $OffboardButton.Add_Click({ @{ Name = "Entra ID"; Icon = "M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"; DefaultChecked = $true }, @{ Name = "Disable in Entra ID"; Icon = "M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"; DefaultChecked = $false }, @{ Name = "Intune"; Icon = "M21,14V4H3V14H21M21,2A2,2 0 0,1 23,4V16A2,2 0 0,1 21,18H14L16,21V22H8V21L10,18H3C1.89,18 1,17.1 1,16V4C1,2.89 1.89,2 3,2H21M4,5H20V13H4V5Z"; DefaultChecked = $true }, - @{ Name = "Autopilot"; Icon = "M12,3L1,9L12,15L21,10.09V17H23V9M5,13.18V17.18L12,21L19,17.18V13.18L12,17L5,13.18Z"; DefaultChecked = $true } + @{ Name = "Autopilot"; Icon = "M12,3L1,9L12,15L21,10.09V17H23V9M5,13.18V17.18L12,21L19,17.18V13.18L12,17L5,13.18Z"; DefaultChecked = $true }, + @{ Name = "Defender for Endpoint"; Icon = "M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M12,3.18L19,6.3V11.22C19,15.54 16.18,19.5 12,20.93C7.82,19.5 5,15.54 5,11.22V6.3L12,3.18Z"; DefaultChecked = $false } ) # Create hashtable to store checkbox references @@ -4588,6 +5155,13 @@ $OffboardButton.Add_Click({ return } + # Capture pre-action selection (0=none, 1=retire, 2=wipe) + $script:preAction = $preActionCombo.SelectedIndex + if ($script:preAction -gt 0) { + $preActionName = if ($script:preAction -eq 1) { "Retire" } else { "Wipe" } + Write-Log "Pre-offboarding action selected: $preActionName" -Severity "AUDIT" + } + # Create results collection to track all operations $offboardingResults = @() $bulkAutopilotIds = @() @@ -4598,6 +5172,36 @@ $OffboardButton.Add_Click({ $deleteEntra = (-not $disableEntra) -and $script:serviceCheckboxes["Entra ID"].IsChecked $deleteIntune = $script:serviceCheckboxes["Intune"].IsChecked $deleteAutopilot = $script:serviceCheckboxes["Autopilot"].IsChecked + $offboardMde = $script:serviceCheckboxes.ContainsKey("Defender for Endpoint") -and $script:serviceCheckboxes["Defender for Endpoint"].IsChecked + + # Resolve MDE device IDs if MDE offboarding is selected + if ($offboardMde) { + try { + $mdeToken = Get-MdeAccessToken + if ($mdeToken) { + foreach ($device in $selectedDevices) { + if ($device.EntraDeviceObjectId -and -not $device.MdeDeviceId) { + try { + $mdeHeaders = @{ Authorization = "Bearer $mdeToken" } + $mdeResponse = Invoke-RestMethod -Uri "https://api.security.microsoft.com/api/machines?`$filter=aadDeviceId eq '$($device.EntraDeviceObjectId)'" -Headers $mdeHeaders -Method GET + if ($mdeResponse.value -and $mdeResponse.value.Count -gt 0) { + $device.MdeDeviceId = $mdeResponse.value[0].id + Write-Log "Resolved MDE device ID for $($device.DeviceName): $($device.MdeDeviceId)" + } + } catch { + Write-Log "Could not resolve MDE device ID for $($device.DeviceName): $_" -Severity "WARN" + } + } + } + } else { + Write-Log "Could not acquire MDE access token. MDE offboarding will be skipped." -Severity "WARN" + $offboardMde = $false + } + } catch { + Write-Log "Error during MDE token acquisition: $_" -Severity "ERROR" + $offboardMde = $false + } + } # Collect serial numbers and Autopilot IDs for potential bulk deletion (2+ devices) $bulkAutopilotSerials = @() @@ -4616,10 +5220,60 @@ $OffboardButton.Add_Click({ EntraID = @{ Found = $false; Success = $false; Error = $null; Action = $null } Intune = @{ Found = $false; Success = $false; Error = $null } Autopilot = @{ Found = $false; Success = $false; Error = $null } + MDE = @{ Found = $false; Success = $false; Error = $null } + PreAction = @{ Action = $null; Success = $false; Error = $null } } Write-Log "Starting offboarding for device: $deviceName (Serial: $serialNumber, EntraId: $($device.EntraDeviceId), IntuneId: $($device.IntuneDeviceId), AutopilotId: $($device.AutopilotIdentityId))" -Severity "AUDIT" + # Execute pre-offboarding action (retire/wipe) if selected + if ($script:preAction -gt 0 -and $device.IntuneDeviceId) { + $preActionName = if ($script:preAction -eq 1) { "retire" } else { "wipe" } + $deviceResult.PreAction.Action = $preActionName + try { + $preActionUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($device.IntuneDeviceId)/$preActionName" + $preActionBody = if ($script:preAction -eq 2) { '{}' } else { $null } + if ($preActionBody) { + Invoke-MgGraphRequest -Uri $preActionUri -Method POST -Body $preActionBody -ContentType "application/json" + } else { + Invoke-MgGraphRequest -Uri $preActionUri -Method POST + } + $deviceResult.PreAction.Success = $true + Write-Log "Successfully executed $preActionName on device $deviceName (IntuneId: $($device.IntuneDeviceId))" -Severity "AUDIT" + Start-Sleep -Seconds 2 + } catch { + $deviceResult.PreAction.Error = $_.Exception.Message + Write-Log "Error executing $preActionName on device $deviceName`: $_" -Severity "ERROR" + $continueChoice = [System.Windows.MessageBox]::Show( + "Failed to $preActionName device '$deviceName'. Continue with deletion?`n`nError: $($_.Exception.Message)", + "Pre-Action Failed", + [System.Windows.MessageBoxButton]::YesNo, + [System.Windows.MessageBoxImage]::Warning + ) + if ($continueChoice -eq [System.Windows.MessageBoxResult]::No) { + $offboardingResults += $deviceResult + continue + } + } + } + + # Execute MDE offboarding if selected + if ($offboardMde -and $device.MdeDeviceId) { + $deviceResult.MDE.Found = $true + try { + $mdeHeaders = @{ Authorization = "Bearer $mdeToken"; "Content-Type" = "application/json" } + $mdeBody = @{ Comment = "Offboarded via DeviceOffboardingManager" } | ConvertTo-Json + Invoke-RestMethod -Uri "https://api.security.microsoft.com/api/machines/$($device.MdeDeviceId)/offboard" -Headers $mdeHeaders -Method POST -Body $mdeBody -ContentType "application/json" + $deviceResult.MDE.Success = $true + Write-Log "Successfully offboarded device $deviceName from MDE (MdeId: $($device.MdeDeviceId))" -Severity "AUDIT" + } catch { + $deviceResult.MDE.Error = $_.Exception.Message + Write-Log "Error offboarding device $deviceName from MDE: $_" -Severity "ERROR" + } + } elseif ($offboardMde -and -not $device.MdeDeviceId) { + Write-Log "Skipping MDE offboarding for $deviceName - no MDE device ID resolved" -Severity "WARN" + } + # Build batch requests for this device $batchRequests = @() @@ -4684,6 +5338,9 @@ $OffboardButton.Add_Click({ if ($entraResp.status -in @(200, 204)) { $deviceResult.EntraID.Success = $true Write-Log "Successfully $($deviceResult.EntraID.Action.ToLower()) device $deviceName in Entra ID (ID: $($device.EntraDeviceId))" -Severity "AUDIT" + } elseif ($entraResp.status -eq 403 -and $entraResp.body.error.code -match 'multipleAdminApproval|protectedOperation') { + $deviceResult.EntraID.Error = "Requires Multi-Admin Approval" + Write-Log "Entra ID operation for $deviceName requires Multi-Admin Approval" -Severity "WARN" } else { $deviceResult.EntraID.Error = "HTTP $($entraResp.status)" Write-Log "Error with Entra ID operation for $deviceName`: HTTP $($entraResp.status)" -Severity "ERROR" @@ -4696,6 +5353,9 @@ $OffboardButton.Add_Click({ if ($intuneResp.status -in @(200, 204)) { $deviceResult.Intune.Success = $true Write-Log "Successfully removed device $deviceName from Intune (ID: $($device.IntuneDeviceId))" -Severity "AUDIT" + } elseif ($intuneResp.status -eq 403 -and $intuneResp.body.error.code -match 'multipleAdminApproval|protectedOperation') { + $deviceResult.Intune.Error = "Requires Multi-Admin Approval" + Write-Log "Intune operation for $deviceName requires Multi-Admin Approval" -Severity "WARN" } else { $deviceResult.Intune.Error = "HTTP $($intuneResp.status)" Write-Log "Error removing device $deviceName from Intune: HTTP $($intuneResp.status)" -Severity "ERROR" @@ -4708,6 +5368,9 @@ $OffboardButton.Add_Click({ if ($autopilotResp.status -in @(200, 204)) { $deviceResult.Autopilot.Success = $true Write-Log "Successfully removed device $deviceName from Autopilot (ID: $($device.AutopilotIdentityId))" -Severity "AUDIT" + } elseif ($autopilotResp.status -eq 403 -and $autopilotResp.body.error.code -match 'multipleAdminApproval|protectedOperation') { + $deviceResult.Autopilot.Error = "Requires Multi-Admin Approval" + Write-Log "Autopilot operation for $deviceName requires Multi-Admin Approval" -Severity "WARN" } else { $deviceResult.Autopilot.Error = "HTTP $($autopilotResp.status)" Write-Log "Error removing device $deviceName from Autopilot: HTTP $($autopilotResp.status)" -Severity "ERROR" @@ -4827,6 +5490,7 @@ $ExportSearchResultsButton.Add_Click({ SerialNumber = $device.SerialNumber OperatingSystem = $device.OperatingSystem PrimaryUser = $device.PrimaryUser + ComplianceState = $device.ComplianceState AzureADLastContact = $device.AzureADLastContact IntuneLastContact = $device.IntuneLastContact AutopilotLastContact = $device.AutopilotLastContact @@ -4861,6 +5525,7 @@ $ExportSelectedButton.Add_Click({ SerialNumber = $device.SerialNumber OperatingSystem = $device.OperatingSystem PrimaryUser = $device.PrimaryUser + ComplianceState = $device.ComplianceState AzureADLastContact = $device.AzureADLastContact IntuneLastContact = $device.IntuneLastContact AutopilotLastContact = $device.AutopilotLastContact @@ -4878,16 +5543,172 @@ $ExportSelectedButton.Add_Click({ ) } }) - + +function Get-MdeAccessToken { + try { + # Use the existing Graph connection context to get a token for the MDE resource + $context = Get-MgContext + if (-not $context) { + Write-Log "No Graph context available for MDE token acquisition" -Severity "WARN" + return $null + } + + # Check if MSAL.PS module is available + if (-not (Get-Module -ListAvailable -Name "MSAL.PS")) { + Write-Log "MSAL.PS module not installed. MDE offboarding requires the MSAL.PS module. Install with: Install-Module MSAL.PS" -Severity "WARN" + return $null + } + + Import-Module MSAL.PS -ErrorAction Stop + $scopes = @("https://api.security.microsoft.com/.default") + + # Try silent token acquisition first + try { + $mdeToken = (Get-MsalToken -ClientId $context.ClientId -TenantId $context.TenantId -Scopes $scopes -Silent -ErrorAction Stop).AccessToken + return $mdeToken + } catch { + Write-Log "Silent MDE token acquisition failed, trying interactive: $_" -Severity "WARN" + } + # Fallback: try interactive token acquisition + try { + $mdeToken = (Get-MsalToken -ClientId $context.ClientId -TenantId $context.TenantId -Scopes $scopes -Interactive -ErrorAction Stop).AccessToken + return $mdeToken + } catch { + Write-Log "Interactive MDE token acquisition failed: $_" -Severity "ERROR" + return $null + } + } catch { + Write-Log "Error acquiring MDE access token: $_" -Severity "ERROR" + return $null + } +} + +function Show-DeviceGroupMembership { + param( + [Parameter(Mandatory = $true)] + [string]$EntraDeviceId, + [string]$DeviceName = "Device" + ) + + try { + $uri = "https://graph.microsoft.com/beta/devices/$EntraDeviceId/memberOf?`$select=displayName,groupTypes,mailEnabled,securityEnabled" + $groups = Get-GraphPagedResults -Uri $uri + + [xml]$groupModalXaml = @" + + + + + + + + + +
@@ -5259,6 +6170,7 @@ function Show-DashboardCardResults { $countText = $dashboardWindow.FindName('CountText') $resultsDataGrid = $dashboardWindow.FindName('ResultsDataGrid') $exportButton = $dashboardWindow.FindName('ExportButton') + $exportHTMLButton = $dashboardWindow.FindName('ExportHTMLButton') $closeButton = $dashboardWindow.FindName('CloseButton') # Ensure DeviceList is an array @@ -5284,7 +6196,64 @@ function Show-DashboardCardResults { Export-DeviceListToCSV -DeviceList $DeviceList -DefaultFileName $fileName } }) - + + # Export HTML button handler + $exportHTMLButton.Add_Click({ + if ($DeviceList.Count -gt 0) { + try { + $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" + $saveFileDialog = New-Object System.Windows.Forms.SaveFileDialog + $saveFileDialog.Filter = "HTML Files (*.html)|*.html" + $saveFileDialog.DefaultExt = "html" + $saveFileDialog.FileName = "Dashboard_$($Title.Replace(' ', '_'))_${timestamp}.html" + $saveFileDialog.Title = "Export Dashboard Results" + + if ($saveFileDialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { + $reportTimestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $version = Get-ScriptVersion + $rows = "" + foreach ($d in $DeviceList) { + $dn = [System.Web.HttpUtility]::HtmlEncode($d.DeviceName) + $sn = [System.Web.HttpUtility]::HtmlEncode($d.SerialNumber) + $os = [System.Web.HttpUtility]::HtmlEncode($d.OperatingSystem) + $lc = [System.Web.HttpUtility]::HtmlEncode($d.LastContact) + $pu = [System.Web.HttpUtility]::HtmlEncode($d.PrimaryUser) + $ow = [System.Web.HttpUtility]::HtmlEncode($d.Ownership) + $rows += "$dn$sn$os$lc$pu$ow`n" + } + $html = @" + +$([System.Web.HttpUtility]::HtmlEncode($Title)) +
+

$([System.Web.HttpUtility]::HtmlEncode($Title))

+
Generated: $reportTimestamp | $($DeviceList.Count) devices | Device Offboarding Manager $version
+ +$rows
Device NameSerial NumberOSLast ContactPrimary UserOwnership
+
+"@ + [System.IO.File]::WriteAllText($saveFileDialog.FileName, $html) + Write-Log "Exported dashboard HTML report to: $($saveFileDialog.FileName)" + [System.Windows.MessageBox]::Show("Report exported successfully.", "Export Successful", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information) + } + } + catch { + Write-Log "Error exporting dashboard HTML: $_" -Severity "ERROR" + [System.Windows.MessageBox]::Show("Error exporting report: $_", "Export Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error) + } + } + }) + # Close button handler $closeButton.Add_Click({ $dashboardWindow.Close() @@ -5605,13 +6574,37 @@ $MenuPlaybooks.Add_Checked({ $Window.FindName('PlaybooksScrollViewer').Visibility = 'Visible' }) - +# Wire platform filter ComboBox +$DashboardPlatformFilter = $Window.FindName('DashboardPlatformFilter') +$DashboardPlatformFilter.Add_SelectionChanged({ + if (-not $AuthenticateButton.IsEnabled) { + $selected = $DashboardPlatformFilter.SelectedItem.Content + Update-DashboardStatistics -Platform $selected + } + }) function Update-DashboardStatistics { + param( + [Parameter(Mandatory = $false)] + [string]$Platform = "All Platforms" + ) + try { - Write-Log "Updating dashboard statistics..." + Write-Log "Updating dashboard statistics (Platform: $Platform)..." $startTime = Get-Date + # Build platform filter clause for $count queries + $platformFilter = "" + switch ($Platform) { + "Windows" { $platformFilter = " and startswith(operatingSystem,'Windows')" } + "macOS" { $platformFilter = " and operatingSystem eq 'macOS'" } + "iOS" { $platformFilter = " and operatingSystem eq 'iOS'" } + "Android" { $platformFilter = " and operatingSystem eq 'Android'" } + "Linux" { $platformFilter = " and operatingSystem eq 'Linux'" } + } + # For standalone filters (no preceding "and"), strip the leading " and " + $platformFilterStandalone = if ($platformFilter) { $platformFilter.Substring(5) } else { "" } + # Try $count batch approach first (single API call instead of fetching all devices) $countSuccess = $false try { @@ -5619,16 +6612,28 @@ function Update-DashboardStatistics { $ninetyDaysAgo = (Get-Date).AddDays(-90).ToString('yyyy-MM-ddTHH:mm:ssZ') $oneEightyDaysAgo = (Get-Date).AddDays(-180).ToString('yyyy-MM-ddTHH:mm:ssZ') + # Build Intune/Entra count URLs with optional platform filter + $intuneCountUrl = if ($platformFilterStandalone) { + "/deviceManagement/managedDevices/`$count?`$filter=$platformFilterStandalone" + } else { + "/deviceManagement/managedDevices/`$count" + } + $entraCountUrl = if ($platformFilterStandalone) { + "/devices/`$count?`$filter=$platformFilterStandalone" + } else { + "/devices/`$count" + } + $batchBody = @{ requests = @( - @{ id = "intune"; method = "GET"; url = "/deviceManagement/managedDevices/`$count"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "intune"; method = "GET"; url = $intuneCountUrl; headers = @{ "ConsistencyLevel" = "eventual" } } @{ id = "autopilot"; method = "GET"; url = "/deviceManagement/windowsAutopilotDeviceIdentities/`$count"; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "entra"; method = "GET"; url = "/devices/`$count"; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "stale30"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=lastSyncDateTime lt $thirtyDaysAgo"; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "stale90"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=lastSyncDateTime lt $ninetyDaysAgo"; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "stale180"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=lastSyncDateTime lt $oneEightyDaysAgo"; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "personal"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=managedDeviceOwnerType eq 'personal'"; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "corporate"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=managedDeviceOwnerType eq 'company'"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "entra"; method = "GET"; url = $entraCountUrl; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "stale30"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=lastSyncDateTime lt $thirtyDaysAgo$platformFilter"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "stale90"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=lastSyncDateTime lt $ninetyDaysAgo$platformFilter"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "stale180"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=lastSyncDateTime lt $oneEightyDaysAgo$platformFilter"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "personal"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=managedDeviceOwnerType eq 'personal'$platformFilter"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "corporate"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=managedDeviceOwnerType eq 'company'$platformFilter"; headers = @{ "ConsistencyLevel" = "eventual" } } @{ id = "osWindows"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=startswith(operatingSystem,'Windows')"; headers = @{ "ConsistencyLevel" = "eventual" } } @{ id = "osmacOS"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=operatingSystem eq 'macOS'"; headers = @{ "ConsistencyLevel" = "eventual" } } @{ id = "osiOS"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=operatingSystem eq 'iOS'"; headers = @{ "ConsistencyLevel" = "eventual" } } @@ -5704,22 +6709,26 @@ function Update-DashboardStatistics { } # Build platform groups from $count results for pie chart - # Compute "Other" as total minus known OS counts - if ($null -eq $osWindows) { $osWindows = 0 } - if ($null -eq $osmacOS) { $osmacOS = 0 } - if ($null -eq $osiOS) { $osiOS = 0 } - if ($null -eq $osAndroid) { $osAndroid = 0 } - if ($null -eq $osLinux) { $osLinux = 0 } - $osOther = [Math]::Max(0, $intuneCount - ($osWindows + $osmacOS + $osiOS + $osAndroid + $osLinux)) - - $platformGroups = @() - if ($osWindows -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'Windows'; Count = $osWindows } } - if ($osmacOS -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'macOS'; Count = $osmacOS } } - if ($osiOS -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'iOS'; Count = $osiOS } } - if ($osAndroid -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'Android'; Count = $osAndroid } } - if ($osLinux -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'Linux'; Count = $osLinux } } - if ($osOther -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'Other'; Count = $osOther } } - $platformGroups = $platformGroups | Sort-Object Count -Descending + # When a specific platform is selected, pie chart shows only that platform + if ($platformFilterStandalone) { + $platformGroups = @([PSCustomObject]@{ Name = $Platform; Count = $intuneCount }) + } else { + if ($null -eq $osWindows) { $osWindows = 0 } + if ($null -eq $osmacOS) { $osmacOS = 0 } + if ($null -eq $osiOS) { $osiOS = 0 } + if ($null -eq $osAndroid) { $osAndroid = 0 } + if ($null -eq $osLinux) { $osLinux = 0 } + $osOther = [Math]::Max(0, $intuneCount - ($osWindows + $osmacOS + $osiOS + $osAndroid + $osLinux)) + + $platformGroups = @() + if ($osWindows -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'Windows'; Count = $osWindows } } + if ($osmacOS -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'macOS'; Count = $osmacOS } } + if ($osiOS -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'iOS'; Count = $osiOS } } + if ($osAndroid -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'Android'; Count = $osAndroid } } + if ($osLinux -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'Linux'; Count = $osLinux } } + if ($osOther -gt 0) { $platformGroups += [PSCustomObject]@{ Name = 'Other'; Count = $osOther } } + $platformGroups = $platformGroups | Sort-Object Count -Descending + } } catch { Write-Log "Dashboard `$count batch failed, falling back to full fetch: $_" -Severity "WARN" @@ -5727,63 +6736,9 @@ function Update-DashboardStatistics { # Fallback: full-fetch approach if $count batch failed if (-not $countSuccess) { - $intuneJob = Start-ThreadJob -ScriptBlock { - function Get-GraphPagedResults { - param([string]$Uri) - $results = @() - $nextLink = $Uri - do { - $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET - if ($response.value) { $results += $response.value } - $nextLink = $response.'@odata.nextLink' - } while ($nextLink) - return $results - } - $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,managedDeviceOwnerType" - return @(Get-GraphPagedResults -Uri $uri) - } - $autopilotJob = Start-ThreadJob -ScriptBlock { - function Get-GraphPagedResults { - param([string]$Uri) - $results = @() - $nextLink = $Uri - do { - $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET - if ($response.value) { $results += $response.value } - $nextLink = $response.'@odata.nextLink' - } while ($nextLink) - return $results - } - $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$select=id,displayName,serialNumber,lastContactedDateTime" - return @(Get-GraphPagedResults -Uri $uri) - } - $entraJob = Start-ThreadJob -ScriptBlock { - function Get-GraphPagedResults { - param([string]$Uri) - $results = @() - $nextLink = $Uri - do { - $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET - if ($response.value) { $results += $response.value } - $nextLink = $response.'@odata.nextLink' - } while ($nextLink) - return $results - } - $uri = "https://graph.microsoft.com/beta/devices?`$select=displayName,operatingSystem,operatingSystemVersion" - return @(Get-GraphPagedResults -Uri $uri) - } - - Wait-Job -Job $intuneJob, $autopilotJob, $entraJob | Out-Null - - $intuneDevices = try { @(Receive-Job -Job $intuneJob -ErrorAction Stop) } catch { @() } - $autopilotDevices = try { @(Receive-Job -Job $autopilotJob -ErrorAction Stop) } catch { @() } - $entraDevices = try { @(Receive-Job -Job $entraJob -ErrorAction Stop) } catch { @() } - Remove-Job -Job $intuneJob, $autopilotJob, $entraJob -Force - - # Handle hashtable returns - if ($intuneDevices.Count -eq 1 -and $intuneDevices[0] -is [System.Collections.Hashtable]) { $intuneDevices = @($intuneDevices[0].value) } - if ($autopilotDevices.Count -eq 1 -and $autopilotDevices[0] -is [System.Collections.Hashtable]) { $autopilotDevices = @($autopilotDevices[0].value) } - if ($entraDevices.Count -eq 1 -and $entraDevices[0] -is [System.Collections.Hashtable]) { $entraDevices = @($entraDevices[0].value) } + $intuneDevices = @(Get-GraphPagedResults -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,managedDeviceOwnerType") + $autopilotDevices = @(Get-GraphPagedResults -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$select=id,displayName,serialNumber,lastContactedDateTime") + $entraDevices = @(Get-GraphPagedResults -Uri "https://graph.microsoft.com/beta/devices?`$select=displayName,operatingSystem,operatingSystemVersion") Write-Log "Fallback: Total devices - Intune: $($intuneDevices.Count), Autopilot: $($autopilotDevices.Count), Entra: $($entraDevices.Count)" @@ -5997,6 +6952,29 @@ foreach ($button in $PlaybookButtons) { $playbookPath = Join-Path $PSScriptRoot "Playbooks" "Playbook_5.ps1" Invoke-Playbook -PlaybookName $playbookName -PlaybookPath $playbookPath -Description $playbookDescription } + "OS-Specific Device List" { + $selectedOS = Show-OSPickerDialog + if ($selectedOS) { + $playbookPath = Join-Path $PSScriptRoot "Playbooks" "Playbook_6.ps1" + Invoke-Playbook -PlaybookName "$playbookName ($selectedOS)" -PlaybookPath $playbookPath -Description $playbookDescription -Parameters @{ OSFilter = $selectedOS } + } + } + "Outdated OS Report" { + $playbookPath = Join-Path $PSScriptRoot "Playbooks" "Playbook_7.ps1" + Invoke-Playbook -PlaybookName $playbookName -PlaybookPath $playbookPath -Description $playbookDescription + } + "End-of-Life OS Report" { + $playbookPath = Join-Path $PSScriptRoot "Playbooks" "Playbook_8.ps1" + Invoke-Playbook -PlaybookName $playbookName -PlaybookPath $playbookPath -Description $playbookDescription + } + "BitLocker Key Report" { + $playbookPath = Join-Path $PSScriptRoot "Playbooks" "Playbook_9.ps1" + Invoke-Playbook -PlaybookName $playbookName -PlaybookPath $playbookPath -Description $playbookDescription + } + "FileVault Key Report" { + $playbookPath = Join-Path $PSScriptRoot "Playbooks" "Playbook_10.ps1" + Invoke-Playbook -PlaybookName $playbookName -PlaybookPath $playbookPath -Description $playbookDescription + } default { [System.Windows.MessageBox]::Show( "This playbook is not yet implemented.", @@ -6036,6 +7014,28 @@ $SelectAllCheckBox.Add_Click({ $OffboardButton.IsEnabled = $false $ExportSelectedButton.IsEnabled = $false +# Add click handler for "View" groups link in DataGrid +$SearchResultsDataGrid.Add_PreviewMouseDown({ + param($sender, $e) + $element = $e.OriginalSource + # Walk up the visual tree to find the TextBlock with Tag + while ($element -and -not ($element -is [System.Windows.Controls.TextBlock] -and $element.Text -eq "View" -and $element.Tag)) { + $element = [System.Windows.Media.VisualTreeHelper]::GetParent($element) + if (-not $element -or $element -is [System.Windows.Controls.DataGrid]) { $element = $null; break } + } + if ($element -and $element.Tag) { + $entraId = $element.Tag.ToString() + if ($entraId) { + # Find device name for display + $deviceObj = $SearchResultsDataGrid.ItemsSource | Where-Object { $_.EntraDeviceId -eq $entraId } | Select-Object -First 1 + $devName = if ($deviceObj) { $deviceObj.DeviceName } else { "Device" } + Show-DeviceGroupMembership -EntraDeviceId $entraId -DeviceName $devName + } else { + [System.Windows.MessageBox]::Show("No Entra ID available for this device.", "Groups", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information) + } + } + }) + # Add selection changed event handler for the DataGrid $SearchResultsDataGrid.Add_SelectionChanged({ # Update the Offboard button state based on selected devices @@ -6204,7 +7204,8 @@ function Invoke-Playbook { param( [string]$PlaybookName, [string]$PlaybookPath, - [string]$Description + [string]$Description, + [hashtable]$Parameters = @{} ) try { @@ -6234,7 +7235,7 @@ function Invoke-Playbook { $status.Text = "Executing playbook..." Write-Log "Executing playbook: $PlaybookPath" - $rawResults = & $PlaybookPath + $rawResults = & $PlaybookPath @Parameters # Filter out only the actual device objects $results = $rawResults | Where-Object { @@ -6273,6 +7274,18 @@ function Invoke-Playbook { EnrollmentDate = "Enrollment Date" LastSyncDateTime = "Last Sync" Ownership = "Ownership" + Model = "Model" + OSVersion = "OS Version" + OwnershipType = "Ownership Type" + CurrentVersion = "Current Version" + LatestVersion = "Latest Version" + EndOfSupportDate = "End of Support Date" + DaysPastEOL = "Days Past EOL" + DaysSinceLastSync = "Days Since Last Sync" + KeyId = "Key ID" + VolumeType = "Volume Type" + CreatedDateTime = "Created Date" + HasFileVaultKey = "Has FileVault Key" } $firstResult = $results | Select-Object -First 1 foreach ($prop in $firstResult.PSObject.Properties) { @@ -6544,16 +7557,18 @@ $ExportPlaybookResultsButton.Add_Click({ $StaleDevices30Card = $Window.FindName('StaleDevices30Card') $StaleDevices30Card.Add_MouseLeftButtonUp({ if (-not $AuthenticateButton.IsEnabled) { + $previousCursor = $Window.Cursor try { + $Window.Cursor = [System.Windows.Input.Cursors]::Wait Write-Log "Fetching 30-day stale devices..." $thirtyDaysAgo = (Get-Date).AddDays(-30) $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=lastSyncDateTime lt $($thirtyDaysAgo.ToString('yyyy-MM-ddTHH:mm:ssZ'))&`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,osVersion,userPrincipalName,managedDeviceOwnerType" $staleDevices = Get-GraphPagedResults -Uri $uri - + # Ensure we have a valid array if ($null -eq $staleDevices) { $staleDevices = @() } - - + + $deviceList = @() foreach ($device in $staleDevices) { $deviceList += [PSCustomObject]@{ @@ -6570,28 +7585,34 @@ $StaleDevices30Card.Add_MouseLeftButtonUp({ Ownership = $device.managedDeviceOwnerType } } - + $title = "30 Day Stale Devices" - + Show-DashboardCardResults -Title $title -DeviceList $deviceList } catch { Write-Log "Error fetching stale devices: $_" [System.Windows.MessageBox]::Show("Error fetching stale devices. Check logs for details.", "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error) } + finally { + $Window.Cursor = $previousCursor + } } }) $StaleDevices90Card = $Window.FindName('StaleDevices90Card') $StaleDevices90Card.Add_MouseLeftButtonUp({ if (-not $AuthenticateButton.IsEnabled) { + $previousCursor = $Window.Cursor try { + $Window.Cursor = [System.Windows.Input.Cursors]::Wait Write-Log "Fetching 90-day stale devices..." $ninetyDaysAgo = (Get-Date).AddDays(-90) $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=lastSyncDateTime lt $($ninetyDaysAgo.ToString('yyyy-MM-ddTHH:mm:ssZ'))&`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,osVersion,userPrincipalName,managedDeviceOwnerType" $staleDevices = Get-GraphPagedResults -Uri $uri - - + + if ($null -eq $staleDevices) { $staleDevices = @() } + $deviceList = @() foreach ($device in $staleDevices) { $deviceList += [PSCustomObject]@{ @@ -6608,28 +7629,34 @@ $StaleDevices90Card.Add_MouseLeftButtonUp({ Ownership = $device.managedDeviceOwnerType } } - + $title = "90 Day Stale Devices" - + Show-DashboardCardResults -Title $title -DeviceList $deviceList } catch { Write-Log "Error fetching stale devices: $_" [System.Windows.MessageBox]::Show("Error fetching stale devices. Check logs for details.", "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error) } + finally { + $Window.Cursor = $previousCursor + } } }) $StaleDevices180Card = $Window.FindName('StaleDevices180Card') $StaleDevices180Card.Add_MouseLeftButtonUp({ if (-not $AuthenticateButton.IsEnabled) { + $previousCursor = $Window.Cursor try { + $Window.Cursor = [System.Windows.Input.Cursors]::Wait Write-Log "Fetching 180-day stale devices..." $hundredEightyDaysAgo = (Get-Date).AddDays(-180) $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=lastSyncDateTime lt $($hundredEightyDaysAgo.ToString('yyyy-MM-ddTHH:mm:ssZ'))&`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,osVersion,userPrincipalName,managedDeviceOwnerType" $staleDevices = Get-GraphPagedResults -Uri $uri - - + + if ($null -eq $staleDevices) { $staleDevices = @() } + $deviceList = @() foreach ($device in $staleDevices) { $deviceList += [PSCustomObject]@{ @@ -6646,26 +7673,31 @@ $StaleDevices180Card.Add_MouseLeftButtonUp({ Ownership = $device.managedDeviceOwnerType } } - + $title = "180 Day Stale Devices" - + Show-DashboardCardResults -Title $title -DeviceList $deviceList } catch { Write-Log "Error fetching stale devices: $_" [System.Windows.MessageBox]::Show("Error fetching stale devices. Check logs for details.", "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error) } + finally { + $Window.Cursor = $previousCursor + } } }) $PersonalDevicesCard = $Window.FindName('PersonalDevicesCard') $PersonalDevicesCard.Add_MouseLeftButtonUp({ if (-not $AuthenticateButton.IsEnabled) { + $previousCursor = $Window.Cursor try { + $Window.Cursor = [System.Windows.Input.Cursors]::Wait Write-Log "Fetching personal devices..." $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=managedDeviceOwnerType eq 'personal'&`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,osVersion,userPrincipalName,managedDeviceOwnerType" $personalDevices = Get-GraphPagedResults -Uri $uri - + $deviceList = @() foreach ($device in $personalDevices) { $deviceList += [PSCustomObject]@{ @@ -6682,26 +7714,31 @@ $PersonalDevicesCard.Add_MouseLeftButtonUp({ Ownership = "Personal" } } - + $title = "Personal Devices" - + Show-DashboardCardResults -Title $title -DeviceList $deviceList } catch { Write-Log "Error fetching personal devices: $_" [System.Windows.MessageBox]::Show("Error fetching personal devices. Check logs for details.", "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error) } + finally { + $Window.Cursor = $previousCursor + } } }) $CorporateDevicesCard = $Window.FindName('CorporateDevicesCard') $CorporateDevicesCard.Add_MouseLeftButtonUp({ if (-not $AuthenticateButton.IsEnabled) { + $previousCursor = $Window.Cursor try { + $Window.Cursor = [System.Windows.Input.Cursors]::Wait Write-Log "Fetching corporate devices..." $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=managedDeviceOwnerType eq 'company'&`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,osVersion,userPrincipalName,managedDeviceOwnerType" $corporateDevices = Get-GraphPagedResults -Uri $uri - + $deviceList = @() foreach ($device in $corporateDevices) { $deviceList += [PSCustomObject]@{ @@ -6718,15 +7755,18 @@ $CorporateDevicesCard.Add_MouseLeftButtonUp({ Ownership = "Corporate" } } - + $title = "Corporate Devices" - + Show-DashboardCardResults -Title $title -DeviceList $deviceList } catch { Write-Log "Error fetching corporate devices: $_" [System.Windows.MessageBox]::Show("Error fetching corporate devices. Check logs for details.", "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error) } + finally { + $Window.Cursor = $previousCursor + } } }) @@ -6734,7 +7774,9 @@ $CorporateDevicesCard.Add_MouseLeftButtonUp({ $IntuneDevicesCard = $Window.FindName('IntuneDevicesCard') $IntuneDevicesCard.Add_MouseLeftButtonUp({ if (-not $AuthenticateButton.IsEnabled) { + $previousCursor = $Window.Cursor try { + $Window.Cursor = [System.Windows.Input.Cursors]::Wait Write-Log "Fetching all Intune devices..." $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,osVersion,userPrincipalName,managedDeviceOwnerType" $intuneDevices = Get-GraphPagedResults -Uri $uri @@ -6755,22 +7797,27 @@ $IntuneDevicesCard.Add_MouseLeftButtonUp({ Ownership = $device.managedDeviceOwnerType } } - + $title = "All Intune Devices" - + Show-DashboardCardResults -Title $title -DeviceList $deviceList } catch { Write-Log "Error fetching Intune devices: $_" [System.Windows.MessageBox]::Show("Error fetching Intune devices. Check logs for details.", "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error) } + finally { + $Window.Cursor = $previousCursor + } } }) $AutopilotDevicesCard = $Window.FindName('AutopilotDevicesCard') $AutopilotDevicesCard.Add_MouseLeftButtonUp({ if (-not $AuthenticateButton.IsEnabled) { + $previousCursor = $Window.Cursor try { + $Window.Cursor = [System.Windows.Input.Cursors]::Wait Write-Log "Fetching all Autopilot devices..." $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$select=displayName,serialNumber,lastContactedDateTime,model,manufacturer,userPrincipalName,systemFamily,managedDeviceOwnerType" $autopilotDevices = Get-GraphPagedResults -Uri $uri @@ -6791,26 +7838,31 @@ $AutopilotDevicesCard.Add_MouseLeftButtonUp({ Ownership = $device.managedDeviceOwnerType } } - + $title = "All Autopilot Devices" - + Show-DashboardCardResults -Title $title -DeviceList $deviceList } catch { Write-Log "Error fetching Autopilot devices: $_" [System.Windows.MessageBox]::Show("Error fetching Autopilot devices. Check logs for details.", "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error) } + finally { + $Window.Cursor = $previousCursor + } } }) $EntraIDDevicesCard = $Window.FindName('EntraIDDevicesCard') $EntraIDDevicesCard.Add_MouseLeftButtonUp({ if (-not $AuthenticateButton.IsEnabled) { + $previousCursor = $Window.Cursor try { + $Window.Cursor = [System.Windows.Input.Cursors]::Wait Write-Log "Fetching all Entra ID devices..." $uri = "https://graph.microsoft.com/beta/devices?`$select=displayName,operatingSystem,operatingSystemVersion,approximateLastSignInDateTime,deviceOwnership" $entraDevices = Get-GraphPagedResults -Uri $uri - + $deviceList = @() foreach ($device in $entraDevices) { $deviceList += [PSCustomObject]@{ @@ -6827,15 +7879,18 @@ $EntraIDDevicesCard.Add_MouseLeftButtonUp({ Ownership = if ($device.deviceOwnership) { $device.deviceOwnership } else { "N/A" } } } - + $title = "All Entra ID Devices" - + Show-DashboardCardResults -Title $title -DeviceList $deviceList } catch { Write-Log "Error fetching Entra ID devices: $_" [System.Windows.MessageBox]::Show("Error fetching Entra ID devices. Check logs for details.", "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error) } + finally { + $Window.Cursor = $previousCursor + } } }) diff --git a/Playbooks/PlaybookHelpers.ps1 b/Playbooks/PlaybookHelpers.ps1 new file mode 100644 index 0000000..58a58f0 --- /dev/null +++ b/Playbooks/PlaybookHelpers.ps1 @@ -0,0 +1,131 @@ +# Shared helper functions for all playbooks +# Dot-source this file at the top of each playbook: +# $helpersPath = Join-Path $PSScriptRoot "PlaybookHelpers.ps1" +# . $helpersPath + +function ConvertTo-SafeDateTime { + param( + [Parameter(Mandatory = $false)] + [string]$dateString + ) + + if ([string]::IsNullOrWhiteSpace($dateString)) { + return $null + } + + $formats = @( + "yyyy-MM-ddTHH:mm:ssZ", + "yyyy-MM-ddTHH:mm:ss.fffffffZ", + "yyyy-MM-ddTHH:mm:ss", + "MM/dd/yyyy HH:mm:ss", + "dd/MM/yyyy HH:mm:ss", + "yyyy-MM-dd HH:mm:ss", + "M/d/yyyy h:mm:ss tt", + "M/d/yyyy H:mm:ss" + ) + + $culture = [System.Globalization.CultureInfo]::InvariantCulture + + foreach ($format in $formats) { + try { + $parsedDate = [DateTime]::ParseExact($dateString, $format, $culture, [System.Globalization.DateTimeStyles]::None) + if ($parsedDate -eq [DateTime]::MinValue) { return $null } + return $parsedDate + } + catch { continue } + } + + try { + $parsedDate = [DateTime]::Parse($dateString, $culture) + if ($parsedDate -eq [DateTime]::MinValue) { return $null } + return $parsedDate + } + catch { + Write-Warning "Failed to parse date: $dateString" + return $null + } +} + +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 + } + + 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-Warning "Throttled (429) on $Method $Uri -- retrying in ${retryAfter}s (attempt $attempt/$MaxRetries)" + Start-Sleep -Seconds $retryAfter + continue + } + + 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-Warning "Server error ($statusCode) on $Method $Uri -- retrying in ${delay}s (attempt $attempt/$MaxRetries)" + Start-Sleep -Seconds $delay + continue + } + + throw + } + } +} + +function Get-GraphPagedResults { + param( + [Parameter(Mandatory = $true)] + [string]$Uri, + [hashtable]$Headers = @{} + ) + + $results = @() + $nextLink = $Uri + + do { + try { + $response = Invoke-GraphRequestWithRetry -Uri $nextLink -Method GET -Headers $Headers + if ($response.value) { + $results += $response.value + } + $nextLink = $response.'@odata.nextLink' + } + catch { + Write-Error "Error in pagination: $_" + break + } + } while ($nextLink) + + return $results +} diff --git a/Playbooks/Playbook_1.ps1 b/Playbooks/Playbook_1.ps1 index 1bd1cc3..b1eca27 100644 --- a/Playbooks/Playbook_1.ps1 +++ b/Playbooks/Playbook_1.ps1 @@ -1,86 +1,8 @@ # Playbook: List all devices that are in Autopilot but not in Intune # This script identifies devices that are registered in Windows Autopilot but not present in Intune management -# Helper function to safely convert date strings to DateTime objects -function ConvertTo-SafeDateTime { - param( - [Parameter(Mandatory = $false)] - [string]$dateString - ) - - if ([string]::IsNullOrWhiteSpace($dateString)) { - return $null - } - - # Define supported date formats - $formats = @( - "yyyy-MM-ddTHH:mm:ssZ", - "yyyy-MM-ddTHH:mm:ss.fffffffZ", - "yyyy-MM-ddTHH:mm:ss", - "MM/dd/yyyy HH:mm:ss", - "dd/MM/yyyy HH:mm:ss", - "yyyy-MM-dd HH:mm:ss", - "M/d/yyyy h:mm:ss tt", - "M/d/yyyy H:mm:ss" - ) - - $culture = [System.Globalization.CultureInfo]::InvariantCulture - - # Try each format - foreach ($format in $formats) { - try { - $parsedDate = [DateTime]::ParseExact($dateString, $format, $culture, [System.Globalization.DateTimeStyles]::None) - # Check for DateTime.MinValue (1/1/0001) - if ($parsedDate -eq [DateTime]::MinValue) { - return $null - } - return $parsedDate - } - catch { - # Continue to next format - continue - } - } - - # Try default parse as last resort with InvariantCulture - try { - $parsedDate = [DateTime]::Parse($dateString, $culture) - if ($parsedDate -eq [DateTime]::MinValue) { - return $null - } - return $parsedDate - } - catch { - Write-Warning "Failed to parse date: $dateString" - return $null - } -} - -function Get-GraphPagedResults { - param( - [Parameter(Mandatory = $true)] - [string]$Uri - ) - - $results = @() - $nextLink = $Uri - - do { - try { - $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET - if ($response.value) { - $results += $response.value - } - $nextLink = $response.'@odata.nextLink' - } - catch { - Write-Error "Error in pagination: $_" - break - } - } while ($nextLink) - - return $results -} +$helpersPath = Join-Path $PSScriptRoot "PlaybookHelpers.ps1" +. $helpersPath function Get-AutopilotNotIntuneDevices { try { diff --git a/Playbooks/Playbook_10.ps1 b/Playbooks/Playbook_10.ps1 new file mode 100644 index 0000000..8d6bc3e --- /dev/null +++ b/Playbooks/Playbook_10.ps1 @@ -0,0 +1,69 @@ +# Playbook: FileVault Key Report +# This script checks FileVault key availability for all macOS devices + +$helpersPath = Join-Path $PSScriptRoot "PlaybookHelpers.ps1" +. $helpersPath + +function Get-FileVaultKeyReport { + try { + Write-Host "Fetching macOS devices from Intune..." -ForegroundColor Cyan + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=operatingSystem eq 'macOS'&`$select=id,deviceName,serialNumber,lastSyncDateTime" + $macDevices = Get-GraphPagedResults -Uri $uri + Write-Host "Found $($macDevices.Count) macOS devices" -ForegroundColor Green + + if ($macDevices.Count -eq 0) { + Write-Host "No macOS devices found" -ForegroundColor Yellow + return @([PSCustomObject]@{ + DeviceName = "No macOS devices found" + SerialNumber = "N/A" + HasFileVaultKey = "N/A" + IntuneLastContact = $null + }) + } + + Write-Host "Checking FileVault key availability for each device..." -ForegroundColor Cyan + $formattedDevices = @() + $counter = 0 + + foreach ($device in $macDevices) { + $counter++ + if ($counter % 10 -eq 0) { + Write-Host " Checked $counter of $($macDevices.Count) devices..." -ForegroundColor Gray + } + + $hasKey = $false + try { + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices('$($device.id)')/getFileVaultKey" + $keyResponse = Invoke-MgGraphRequest -Uri $uri -Method GET + if ($keyResponse.value) { + $hasKey = $true + } + } + catch { + # 404 or other error means no key available + $hasKey = $false + } + + $formattedDevices += [PSCustomObject]@{ + DeviceName = $device.deviceName + SerialNumber = $device.serialNumber + HasFileVaultKey = if ($hasKey) { "Yes" } else { "No" } + IntuneLastContact = ConvertTo-SafeDateTime -dateString $device.lastSyncDateTime + } + } + + Write-Host "Successfully processed $($formattedDevices.Count) macOS devices" -ForegroundColor Yellow + return $formattedDevices + } + catch { + Write-Error "Error executing playbook: $_" + return $null + } +} + +$results = Get-FileVaultKeyReport +if ($results) { + $results | Format-Table -AutoSize +} + +return $results diff --git a/Playbooks/Playbook_2.ps1 b/Playbooks/Playbook_2.ps1 index 045653c..416ede2 100644 --- a/Playbooks/Playbook_2.ps1 +++ b/Playbooks/Playbook_2.ps1 @@ -1,86 +1,8 @@ # Playbook: List all devices that are in Intune but not in Autopilot # This script identifies devices that are managed in Intune but not registered in Windows Autopilot -# Helper function to safely convert date strings to DateTime objects -function ConvertTo-SafeDateTime { - param( - [Parameter(Mandatory = $false)] - [string]$dateString - ) - - if ([string]::IsNullOrWhiteSpace($dateString)) { - return $null - } - - # Define supported date formats - $formats = @( - "yyyy-MM-ddTHH:mm:ssZ", - "yyyy-MM-ddTHH:mm:ss.fffffffZ", - "yyyy-MM-ddTHH:mm:ss", - "MM/dd/yyyy HH:mm:ss", - "dd/MM/yyyy HH:mm:ss", - "yyyy-MM-dd HH:mm:ss", - "M/d/yyyy h:mm:ss tt", - "M/d/yyyy H:mm:ss" - ) - - $culture = [System.Globalization.CultureInfo]::InvariantCulture - - # Try each format - foreach ($format in $formats) { - try { - $parsedDate = [DateTime]::ParseExact($dateString, $format, $culture, [System.Globalization.DateTimeStyles]::None) - # Check for DateTime.MinValue (1/1/0001) - if ($parsedDate -eq [DateTime]::MinValue) { - return $null - } - return $parsedDate - } - catch { - # Continue to next format - continue - } - } - - # Try default parse as last resort with InvariantCulture - try { - $parsedDate = [DateTime]::Parse($dateString, $culture) - if ($parsedDate -eq [DateTime]::MinValue) { - return $null - } - return $parsedDate - } - catch { - Write-Warning "Failed to parse date: $dateString" - return $null - } -} - -function Get-GraphPagedResults { - param( - [Parameter(Mandatory = $true)] - [string]$Uri - ) - - $results = @() - $nextLink = $Uri - - do { - try { - $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET - if ($response.value) { - $results += $response.value - } - $nextLink = $response.'@odata.nextLink' - } - catch { - Write-Error "Error in pagination: $_" - break - } - } while ($nextLink) - - return $results -} +$helpersPath = Join-Path $PSScriptRoot "PlaybookHelpers.ps1" +. $helpersPath function Get-IntuneNotAutopilotDevices { try { diff --git a/Playbooks/Playbook_3.ps1 b/Playbooks/Playbook_3.ps1 index 4237a80..bfc28e0 100644 --- a/Playbooks/Playbook_3.ps1 +++ b/Playbooks/Playbook_3.ps1 @@ -1,86 +1,8 @@ # Playbook: List all corporate devices in Intune # This script identifies all company-owned devices managed in Intune -# Helper function to safely convert date strings to DateTime objects -function ConvertTo-SafeDateTime { - param( - [Parameter(Mandatory = $false)] - [string]$dateString - ) - - if ([string]::IsNullOrWhiteSpace($dateString)) { - return $null - } - - # Define supported date formats - $formats = @( - "yyyy-MM-ddTHH:mm:ssZ", - "yyyy-MM-ddTHH:mm:ss.fffffffZ", - "yyyy-MM-ddTHH:mm:ss", - "MM/dd/yyyy HH:mm:ss", - "dd/MM/yyyy HH:mm:ss", - "yyyy-MM-dd HH:mm:ss", - "M/d/yyyy h:mm:ss tt", - "M/d/yyyy H:mm:ss" - ) - - $culture = [System.Globalization.CultureInfo]::InvariantCulture - - # Try each format - foreach ($format in $formats) { - try { - $parsedDate = [DateTime]::ParseExact($dateString, $format, $culture, [System.Globalization.DateTimeStyles]::None) - # Check for DateTime.MinValue (1/1/0001) - if ($parsedDate -eq [DateTime]::MinValue) { - return $null - } - return $parsedDate - } - catch { - # Continue to next format - continue - } - } - - # Try default parse as last resort with InvariantCulture - try { - $parsedDate = [DateTime]::Parse($dateString, $culture) - if ($parsedDate -eq [DateTime]::MinValue) { - return $null - } - return $parsedDate - } - catch { - Write-Warning "Failed to parse date: $dateString" - return $null - } -} - -function Get-GraphPagedResults { - param( - [Parameter(Mandatory = $true)] - [string]$Uri - ) - - $results = @() - $nextLink = $Uri - - do { - try { - $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET - if ($response.value) { - $results += $response.value - } - $nextLink = $response.'@odata.nextLink' - } - catch { - Write-Error "Error in pagination: $_" - break - } - } while ($nextLink) - - return $results -} +$helpersPath = Join-Path $PSScriptRoot "PlaybookHelpers.ps1" +. $helpersPath function Get-CorporateDevices { try { diff --git a/Playbooks/Playbook_4.ps1 b/Playbooks/Playbook_4.ps1 index 2f1f803..ace3e97 100644 --- a/Playbooks/Playbook_4.ps1 +++ b/Playbooks/Playbook_4.ps1 @@ -1,86 +1,8 @@ # Playbook: List all personal devices in Intune # This script identifies all personally-owned devices managed in Intune -# Helper function to safely convert date strings to DateTime objects -function ConvertTo-SafeDateTime { - param( - [Parameter(Mandatory = $false)] - [string]$dateString - ) - - if ([string]::IsNullOrWhiteSpace($dateString)) { - return $null - } - - # Define supported date formats - $formats = @( - "yyyy-MM-ddTHH:mm:ssZ", - "yyyy-MM-ddTHH:mm:ss.fffffffZ", - "yyyy-MM-ddTHH:mm:ss", - "MM/dd/yyyy HH:mm:ss", - "dd/MM/yyyy HH:mm:ss", - "yyyy-MM-dd HH:mm:ss", - "M/d/yyyy h:mm:ss tt", - "M/d/yyyy H:mm:ss" - ) - - $culture = [System.Globalization.CultureInfo]::InvariantCulture - - # Try each format - foreach ($format in $formats) { - try { - $parsedDate = [DateTime]::ParseExact($dateString, $format, $culture, [System.Globalization.DateTimeStyles]::None) - # Check for DateTime.MinValue (1/1/0001) - if ($parsedDate -eq [DateTime]::MinValue) { - return $null - } - return $parsedDate - } - catch { - # Continue to next format - continue - } - } - - # Try default parse as last resort with InvariantCulture - try { - $parsedDate = [DateTime]::Parse($dateString, $culture) - if ($parsedDate -eq [DateTime]::MinValue) { - return $null - } - return $parsedDate - } - catch { - Write-Warning "Failed to parse date: $dateString" - return $null - } -} - -function Get-GraphPagedResults { - param( - [Parameter(Mandatory = $true)] - [string]$Uri - ) - - $results = @() - $nextLink = $Uri - - do { - try { - $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET - if ($response.value) { - $results += $response.value - } - $nextLink = $response.'@odata.nextLink' - } - catch { - Write-Error "Error in pagination: $_" - break - } - } while ($nextLink) - - return $results -} +$helpersPath = Join-Path $PSScriptRoot "PlaybookHelpers.ps1" +. $helpersPath function Get-PersonalDevices { try { diff --git a/Playbooks/Playbook_5.ps1 b/Playbooks/Playbook_5.ps1 index ff69665..69cee54 100644 --- a/Playbooks/Playbook_5.ps1 +++ b/Playbooks/Playbook_5.ps1 @@ -5,86 +5,8 @@ param( [int]$StaleDays = 30 # Default to 30 days if not specified ) -# Helper function to safely convert date strings to DateTime objects -function ConvertTo-SafeDateTime { - param( - [Parameter(Mandatory = $false)] - [string]$dateString - ) - - if ([string]::IsNullOrWhiteSpace($dateString)) { - return $null - } - - # Define supported date formats - $formats = @( - "yyyy-MM-ddTHH:mm:ssZ", - "yyyy-MM-ddTHH:mm:ss.fffffffZ", - "yyyy-MM-ddTHH:mm:ss", - "MM/dd/yyyy HH:mm:ss", - "dd/MM/yyyy HH:mm:ss", - "yyyy-MM-dd HH:mm:ss", - "M/d/yyyy h:mm:ss tt", - "M/d/yyyy H:mm:ss" - ) - - $culture = [System.Globalization.CultureInfo]::InvariantCulture - - # Try each format - foreach ($format in $formats) { - try { - $parsedDate = [DateTime]::ParseExact($dateString, $format, $culture, [System.Globalization.DateTimeStyles]::None) - # Check for DateTime.MinValue (1/1/0001) - if ($parsedDate -eq [DateTime]::MinValue) { - return $null - } - return $parsedDate - } - catch { - # Continue to next format - continue - } - } - - # Try default parse as last resort with InvariantCulture - try { - $parsedDate = [DateTime]::Parse($dateString, $culture) - if ($parsedDate -eq [DateTime]::MinValue) { - return $null - } - return $parsedDate - } - catch { - Write-Warning "Failed to parse date: $dateString" - return $null - } -} - -function Get-GraphPagedResults { - param( - [Parameter(Mandatory = $true)] - [string]$Uri - ) - - $results = @() - $nextLink = $Uri - - do { - try { - $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET - if ($response.value) { - $results += $response.value - } - $nextLink = $response.'@odata.nextLink' - } - catch { - Write-Error "Error in pagination: $_" - break - } - } while ($nextLink) - - return $results -} +$helpersPath = Join-Path $PSScriptRoot "PlaybookHelpers.ps1" +. $helpersPath function Get-StaleDevices { param( diff --git a/Playbooks/Playbook_6.ps1 b/Playbooks/Playbook_6.ps1 new file mode 100644 index 0000000..7e769b8 --- /dev/null +++ b/Playbooks/Playbook_6.ps1 @@ -0,0 +1,48 @@ +# Playbook: List devices by operating system +# This script filters managed devices by a specified operating system + +param( + [string]$OSFilter = "Windows" +) + +$helpersPath = Join-Path $PSScriptRoot "PlaybookHelpers.ps1" +. $helpersPath + +function Get-OSSpecificDevices { + param( + [string]$OSFilter = "Windows" + ) + + try { + Write-Host "Fetching $OSFilter devices from Intune..." -ForegroundColor Cyan + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=operatingSystem eq '$OSFilter'&`$select=deviceName,serialNumber,operatingSystem,model,osVersion,lastSyncDateTime,userDisplayName" + $devices = Get-GraphPagedResults -Uri $uri + Write-Host "Found $($devices.Count) $OSFilter devices" -ForegroundColor Green + + $formattedDevices = $devices | ForEach-Object { + [PSCustomObject]@{ + DeviceName = $_.deviceName + SerialNumber = $_.serialNumber + OperatingSystem = $_.operatingSystem + Model = $_.model + OSVersion = $_.osVersion + IntuneLastContact = ConvertTo-SafeDateTime -dateString $_.lastSyncDateTime + PrimaryUser = $_.userDisplayName + } + } + + Write-Host "Successfully processed $($formattedDevices.Count) devices" -ForegroundColor Yellow + return $formattedDevices + } + catch { + Write-Error "Error executing playbook: $_" + return $null + } +} + +$results = Get-OSSpecificDevices -OSFilter $OSFilter +if ($results) { + $results | Format-Table -AutoSize +} + +return $results diff --git a/Playbooks/Playbook_7.ps1 b/Playbooks/Playbook_7.ps1 new file mode 100644 index 0000000..e38d0d0 --- /dev/null +++ b/Playbooks/Playbook_7.ps1 @@ -0,0 +1,84 @@ +# Playbook: Find devices running outdated OS versions +# This script compares device OS versions against known latest versions + +$helpersPath = Join-Path $PSScriptRoot "PlaybookHelpers.ps1" +. $helpersPath + +function Get-OutdatedOSDevices { + # Latest known OS versions (update these as new versions release) + $latestVersions = @{ + "Windows" = "10.0.26100" # Windows 11 24H2 + "macOS" = "15.3" + "iOS" = "18.3" + "Android" = "15" + } + + try { + Write-Host "Fetching all managed devices..." -ForegroundColor Cyan + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=deviceName,serialNumber,operatingSystem,osVersion,lastSyncDateTime" + $devices = Get-GraphPagedResults -Uri $uri + Write-Host "Found $($devices.Count) total devices" -ForegroundColor Green + + $outdatedDevices = @() + foreach ($device in $devices) { + $os = $device.operatingSystem + $currentVersion = $device.osVersion + + if (-not $currentVersion -or -not $os) { continue } + + $latestVersion = $null + foreach ($key in $latestVersions.Keys) { + if ($os -match $key) { + $latestVersion = $latestVersions[$key] + break + } + } + + if (-not $latestVersion) { continue } + + # Compare versions - device is outdated if its version is less than the latest + try { + $currentParts = $currentVersion -split '\.' | ForEach-Object { [int]$_ } + $latestParts = $latestVersion -split '\.' | ForEach-Object { [int]$_ } + + $isOutdated = $false + $maxParts = [Math]::Max($currentParts.Count, $latestParts.Count) + for ($i = 0; $i -lt $maxParts; $i++) { + $c = if ($i -lt $currentParts.Count) { $currentParts[$i] } else { 0 } + $l = if ($i -lt $latestParts.Count) { $latestParts[$i] } else { 0 } + if ($c -lt $l) { $isOutdated = $true; break } + if ($c -gt $l) { break } + } + + if ($isOutdated) { + $outdatedDevices += [PSCustomObject]@{ + DeviceName = $device.deviceName + SerialNumber = $device.serialNumber + OperatingSystem = $os + CurrentVersion = $currentVersion + LatestVersion = $latestVersion + IntuneLastContact = ConvertTo-SafeDateTime -dateString $device.lastSyncDateTime + } + } + } + catch { + # Skip devices with unparseable versions + continue + } + } + + Write-Host "Found $($outdatedDevices.Count) devices with outdated OS" -ForegroundColor Yellow + return $outdatedDevices + } + catch { + Write-Error "Error executing playbook: $_" + return $null + } +} + +$results = Get-OutdatedOSDevices +if ($results) { + $results | Format-Table -AutoSize +} + +return $results diff --git a/Playbooks/Playbook_8.ps1 b/Playbooks/Playbook_8.ps1 new file mode 100644 index 0000000..ffd924d --- /dev/null +++ b/Playbooks/Playbook_8.ps1 @@ -0,0 +1,73 @@ +# Playbook: Identify devices running end-of-life OS versions +# This script checks devices against known OS end-of-life dates + +$helpersPath = Join-Path $PSScriptRoot "PlaybookHelpers.ps1" +. $helpersPath + +function Get-EOLDevices { + # End-of-life dates for OS versions (OS name pattern -> EOL date) + $eolTable = @( + @{ Pattern = "^10\.0\.(1904[0-3]|1836[0-7]|1776[0-5]|1713[0-4]|1658[0-9]|1600[0-9]|1439[0-3]|1058[0-6]|1024[0-0])"; OS = "Windows 10"; EOLDate = "2025-10-14" } + @{ Pattern = "^12\."; OS = "macOS 12 Monterey"; EOLDate = "2024-09-16" } + @{ Pattern = "^13\."; OS = "macOS 13 Ventura"; EOLDate = "2025-10-01" } + @{ Pattern = "^16\."; OS = "iOS 16"; EOLDate = "2024-09-16" } + @{ Pattern = "^17\."; OS = "iOS 17"; EOLDate = "2025-09-15" } + @{ Pattern = "^13$|^13\."; OS = "Android 13"; EOLDate = "2025-03-01" } + ) + + try { + Write-Host "Fetching all managed devices..." -ForegroundColor Cyan + $uri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=deviceName,serialNumber,operatingSystem,osVersion,lastSyncDateTime" + $devices = Get-GraphPagedResults -Uri $uri + Write-Host "Found $($devices.Count) total devices" -ForegroundColor Green + + $eolDevices = @() + $today = Get-Date + + foreach ($device in $devices) { + $osVersion = $device.osVersion + $osName = $device.operatingSystem + if (-not $osVersion) { continue } + + foreach ($eolEntry in $eolTable) { + $matchVersion = $osVersion + # For Windows, use the full build number; for others use osVersion directly + if ($osName -eq "Windows" -and $osVersion -match "^10\.0\.(\d+)") { + $matchVersion = $osVersion + } elseif ($osName -ne "Windows") { + $matchVersion = $osVersion + } + + if ($matchVersion -match $eolEntry.Pattern) { + $eolDate = [DateTime]::Parse($eolEntry.EOLDate) + $daysPast = [Math]::Ceiling(($today - $eolDate).TotalDays) + + $eolDevices += [PSCustomObject]@{ + DeviceName = $device.deviceName + SerialNumber = $device.serialNumber + OperatingSystem = "$osName ($($eolEntry.OS))" + OSVersion = $osVersion + EndOfSupportDate = $eolEntry.EOLDate + DaysPastEOL = $daysPast + } + break + } + } + } + + $eolDevices = $eolDevices | Sort-Object -Property DaysPastEOL -Descending + Write-Host "Found $($eolDevices.Count) devices running EOL operating systems" -ForegroundColor Yellow + return $eolDevices + } + catch { + Write-Error "Error executing playbook: $_" + return $null + } +} + +$results = Get-EOLDevices +if ($results) { + $results | Format-Table -AutoSize +} + +return $results diff --git a/Playbooks/Playbook_9.ps1 b/Playbooks/Playbook_9.ps1 new file mode 100644 index 0000000..a4ab47c --- /dev/null +++ b/Playbooks/Playbook_9.ps1 @@ -0,0 +1,83 @@ +# Playbook: BitLocker Key Report +# This script retrieves BitLocker recovery key metadata for all Windows devices + +$helpersPath = Join-Path $PSScriptRoot "PlaybookHelpers.ps1" +. $helpersPath + +function Get-BitLockerKeyReport { + try { + # Get all BitLocker recovery keys (metadata only, not actual key values) + Write-Host "Fetching BitLocker recovery key metadata..." -ForegroundColor Cyan + $uri = "https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys?`$select=id,createdDateTime,deviceId,volumeType" + $recoveryKeys = Get-GraphPagedResults -Uri $uri + Write-Host "Found $($recoveryKeys.Count) BitLocker recovery keys" -ForegroundColor Green + + if ($recoveryKeys.Count -eq 0) { + Write-Host "No BitLocker recovery keys found" -ForegroundColor Yellow + return @([PSCustomObject]@{ + DeviceName = "No keys found" + SerialNumber = "N/A" + KeyId = "N/A" + VolumeType = "N/A" + CreatedDateTime = $null + }) + } + + # Get unique device IDs and resolve device names + $deviceIds = $recoveryKeys | Select-Object -ExpandProperty deviceId -Unique | Where-Object { $_ } + Write-Host "Resolving device names for $($deviceIds.Count) devices..." -ForegroundColor Cyan + + $deviceInfoMap = @{} + foreach ($deviceId in $deviceIds) { + try { + # Get device name from Entra + $uri = "https://graph.microsoft.com/beta/devices?`$filter=deviceId eq '$deviceId'&`$select=displayName" + $device = (Get-GraphPagedResults -Uri $uri) | Select-Object -First 1 + $devName = if ($device) { $device.displayName } else { "Unknown Device" } + + # Get serial number from Intune via azureADDeviceId + $serialNumber = "N/A" + try { + $intuneUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$deviceId'&`$select=serialNumber" + $intuneDevice = (Get-GraphPagedResults -Uri $intuneUri) | Select-Object -First 1 + if ($intuneDevice -and $intuneDevice.serialNumber) { + $serialNumber = $intuneDevice.serialNumber + } + } catch {} + + $deviceInfoMap[$deviceId] = @{ Name = $devName; Serial = $serialNumber } + } + catch { + continue + } + } + + $formattedKeys = $recoveryKeys | ForEach-Object { + $info = if ($_.deviceId -and $deviceInfoMap.ContainsKey($_.deviceId)) { + $deviceInfoMap[$_.deviceId] + } else { @{ Name = "Unknown Device"; Serial = "N/A" } } + + [PSCustomObject]@{ + DeviceName = $info.Name + SerialNumber = $info.Serial + KeyId = $_.id + VolumeType = $_.volumeType + CreatedDateTime = ConvertTo-SafeDateTime -dateString $_.createdDateTime + } + } + + Write-Host "Successfully processed $($formattedKeys.Count) BitLocker keys" -ForegroundColor Yellow + return $formattedKeys + } + catch { + Write-Error "Error executing playbook: $_" + return $null + } +} + +$results = Get-BitLockerKeyReport +if ($results) { + $results | Format-Table -AutoSize +} + +return $results From 14108ca26fc5e85e984dfd5f557b12fd498fdce1 Mon Sep 17 00:00:00 2001 From: "@ugurkocde" <43906965+ugurkocde@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:18:05 +0100 Subject: [PATCH 04/16] Fix LAPS password retrieval: add fallback device ID and fix URI interpolation - Add fallback from EntraDeviceObjectId to intuneDevice.azureADDeviceId for LAPS lookup (mirrors BitLocker fallback pattern) - Fix PowerShell string interpolation in LAPS URI using $() syntax - Add diagnostic logging for LAPS device ID resolution Co-Authored-By: Claude Opus 4.6 (1M context) --- DeviceOffboardingManager.ps1 | 135 ++++++++++++++++++++++++----------- 1 file changed, 92 insertions(+), 43 deletions(-) diff --git a/DeviceOffboardingManager.ps1 b/DeviceOffboardingManager.ps1 index 0803138..e6e2457 100644 --- a/DeviceOffboardingManager.ps1 +++ b/DeviceOffboardingManager.ps1 @@ -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 @@ -3564,6 +3568,11 @@ function Write-Log { $logMessage = "$timestamp | $Severity | $admin | $Message" Add-Content -Path $script:LogFilePath -Value $logMessage + + if ($script:VerboseMode -and $Severity -in @("WARN", "ERROR")) { + $color = if ($Severity -eq "ERROR") { "Red" } else { "Yellow" } + Write-Host "[$Severity] $Message" -ForegroundColor $color + } } function Export-DeviceListToCSV { @@ -3805,7 +3814,7 @@ function Invoke-DeviceSearch { $allAutopilotDevices = $null if ($SearchOption -eq "Devicename") { try { - $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$select=id,displayName,serialNumber,lastContactedDateTime" + $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities" $allAutopilotDevices = Get-GraphPagedResults -Uri $uri Write-Log "Pre-fetched $($allAutopilotDevices.Count) Autopilot devices for display name matching" } @@ -3852,7 +3861,7 @@ function Invoke-DeviceSearch { # If no Autopilot match by displayName and we have Intune device with serial, try serial number if (-not $matchingAutopilotDevice -and $matchingIntuneDevice -and $matchingIntuneDevice.serialNumber) { - $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$($matchingIntuneDevice.serialNumber)')&`$select=id,displayName,serialNumber,lastContactedDateTime" + $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$($matchingIntuneDevice.serialNumber)')" $matchingAutopilotDevice = (Get-GraphPagedResults -Uri $uri) | Select-Object -First 1 } @@ -3905,7 +3914,7 @@ function Invoke-DeviceSearch { # If no match by displayName and we have serial number, try serial number if (-not $matchingAutopilotDevice -and $IntuneDevice.serialNumber) { - $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$($IntuneDevice.serialNumber)')&`$select=id,displayName,serialNumber,lastContactedDateTime" + $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$($IntuneDevice.serialNumber)')" $matchingAutopilotDevice = (Get-GraphPagedResults -Uri $uri) | Select-Object -First 1 } @@ -3955,7 +3964,7 @@ function Invoke-DeviceSearch { # Batch Intune + Autopilot queries together $batchRequests = @( @{ id = "intune"; method = "GET"; url = "/deviceManagement/managedDevices?`$filter=serialNumber eq '$SearchText'&`$select=id,deviceName,serialNumber,operatingSystem,userDisplayName,lastSyncDateTime,azureADDeviceId,complianceState,managementAgent" } - @{ id = "autopilot"; method = "GET"; url = "/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$SearchText')&`$select=id,displayName,serialNumber,lastContactedDateTime" } + @{ id = "autopilot"; method = "GET"; url = "/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,'$SearchText')" } ) $batchResponses = Invoke-GraphBatchRequest -Requests $batchRequests $intuneResp = $batchResponses | Where-Object { $_.id -eq "intune" } @@ -4096,7 +4105,7 @@ function Invoke-DeviceSearch { # Pre-fetch Autopilot devices for client-side filtering $AutopilotDevices = @() try { - $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$select=id,displayName,serialNumber,lastContactedDateTime" + $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities" $allAutopilot = Get-GraphPagedResults -Uri $uri $AutopilotDevices = $allAutopilot | Where-Object { $_.displayName -and $_.displayName -like "*$SearchText*" } } catch { @@ -4975,9 +4984,11 @@ $OffboardButton.Add_Click({ Key = $null } $lapsDeviceId = $selectedDevice.EntraDeviceObjectId + if (-not $lapsDeviceId) { $lapsDeviceId = $intuneDevice.azureADDeviceId } + Write-Log "LAPS lookup - EntraDeviceObjectId: '$($selectedDevice.EntraDeviceObjectId)', intuneDevice.azureADDeviceId: '$($intuneDevice.azureADDeviceId)', resolved lapsDeviceId: '$lapsDeviceId'" -Severity "INFO" if ($lapsDeviceId) { try { - $uri = "https://graph.microsoft.com/beta/directory/deviceLocalCredentials/$lapsDeviceId?`$select=credentials" + $uri = "https://graph.microsoft.com/beta/directory/deviceLocalCredentials/$($lapsDeviceId)?`$select=credentials" $lapsResponse = Invoke-MgGraphRequest -Uri $uri -Method GET if ($lapsResponse.credentials -and $lapsResponse.credentials.Count -gt 0) { $latestCred = $lapsResponse.credentials | Sort-Object -Property backupDateTime -Descending | Select-Object -First 1 @@ -6613,53 +6624,59 @@ function Update-DashboardStatistics { $oneEightyDaysAgo = (Get-Date).AddDays(-180).ToString('yyyy-MM-ddTHH:mm:ssZ') # Build Intune/Entra count URLs with optional platform filter + # Intune endpoints use ?$count=true&$top=1 (/$count path segment not supported in batch) $intuneCountUrl = if ($platformFilterStandalone) { - "/deviceManagement/managedDevices/`$count?`$filter=$platformFilterStandalone" + "/deviceManagement/managedDevices?`$count=true&`$top=1&`$select=id&`$filter=$platformFilterStandalone" } else { - "/deviceManagement/managedDevices/`$count" + "/deviceManagement/managedDevices?`$count=true&`$top=1&`$select=id" } $entraCountUrl = if ($platformFilterStandalone) { - "/devices/`$count?`$filter=$platformFilterStandalone" + "/devices?`$count=true&`$top=1&`$select=id&`$filter=$platformFilterStandalone" } else { - "/devices/`$count" + "/devices?`$count=true&`$top=1&`$select=id" } $batchBody = @{ requests = @( - @{ id = "intune"; method = "GET"; url = $intuneCountUrl; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "autopilot"; method = "GET"; url = "/deviceManagement/windowsAutopilotDeviceIdentities/`$count"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "intune"; method = "GET"; url = $intuneCountUrl } + @{ id = "autopilot"; method = "GET"; url = "/deviceManagement/windowsAutopilotDeviceIdentities?`$count=true&`$top=1" } @{ id = "entra"; method = "GET"; url = $entraCountUrl; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "stale30"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=lastSyncDateTime lt $thirtyDaysAgo$platformFilter"; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "stale90"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=lastSyncDateTime lt $ninetyDaysAgo$platformFilter"; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "stale180"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=lastSyncDateTime lt $oneEightyDaysAgo$platformFilter"; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "personal"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=managedDeviceOwnerType eq 'personal'$platformFilter"; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "corporate"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=managedDeviceOwnerType eq 'company'$platformFilter"; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "osWindows"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=startswith(operatingSystem,'Windows')"; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "osmacOS"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=operatingSystem eq 'macOS'"; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "osiOS"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=operatingSystem eq 'iOS'"; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "osAndroid"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=operatingSystem eq 'Android'"; headers = @{ "ConsistencyLevel" = "eventual" } } - @{ id = "osLinux"; method = "GET"; url = "/deviceManagement/managedDevices/`$count?`$filter=operatingSystem eq 'Linux'"; headers = @{ "ConsistencyLevel" = "eventual" } } + @{ id = "stale30"; method = "GET"; url = "/deviceManagement/managedDevices?`$count=true&`$top=1&`$select=id&`$filter=lastSyncDateTime lt $thirtyDaysAgo$platformFilter" } + @{ id = "stale90"; method = "GET"; url = "/deviceManagement/managedDevices?`$count=true&`$top=1&`$select=id&`$filter=lastSyncDateTime lt $ninetyDaysAgo$platformFilter" } + @{ id = "stale180"; method = "GET"; url = "/deviceManagement/managedDevices?`$count=true&`$top=1&`$select=id&`$filter=lastSyncDateTime lt $oneEightyDaysAgo$platformFilter" } + @{ id = "personal"; method = "GET"; url = "/deviceManagement/managedDevices?`$count=true&`$top=1&`$select=id&`$filter=managedDeviceOwnerType eq 'personal'$platformFilter" } + @{ id = "corporate"; method = "GET"; url = "/deviceManagement/managedDevices?`$count=true&`$top=1&`$select=id&`$filter=managedDeviceOwnerType eq 'company'$platformFilter" } + @{ id = "osWindows"; method = "GET"; url = "/deviceManagement/managedDevices?`$count=true&`$top=1&`$select=id&`$filter=startswith(operatingSystem,'Windows')" } + @{ id = "osmacOS"; method = "GET"; url = "/deviceManagement/managedDevices?`$count=true&`$top=1&`$select=id&`$filter=operatingSystem eq 'macOS'" } + @{ id = "osiOS"; method = "GET"; url = "/deviceManagement/managedDevices?`$count=true&`$top=1&`$select=id&`$filter=operatingSystem eq 'iOS'" } + @{ id = "osAndroid"; method = "GET"; url = "/deviceManagement/managedDevices?`$count=true&`$top=1&`$select=id&`$filter=operatingSystem eq 'Android'" } + @{ id = "osLinux"; method = "GET"; url = "/deviceManagement/managedDevices?`$count=true&`$top=1&`$select=id&`$filter=operatingSystem eq 'Linux'" } ) } | ConvertTo-Json -Depth 5 $batchResponse = Invoke-GraphRequestWithRetry -Uri "https://graph.microsoft.com/beta/`$batch" -Method POST -Body $batchBody -ContentType "application/json" $batchResponses = $batchResponse.responses + Write-Log "Dashboard batch raw statuses: $(($batchResponses | ForEach-Object { "$($_.id)=$($_.status)" }) -join ', ')" - # Helper to extract count from batch response (handles both raw int and hashtable wrapper) + # Helper to extract count from batch response (handles raw int, @odata.count, and hashtable wrapper) $getCount = { param([string]$id) $resp = $batchResponses | Where-Object { $_.id -eq $id } - if ($resp -and $resp.status -eq 200) { - $rawBody = $resp.body - if ($rawBody -is [int] -or $rawBody -is [long]) { - return [int]$rawBody - } elseif ($rawBody -is [System.Collections.IDictionary] -and $rawBody.ContainsKey('value')) { - return [int]$rawBody['value'] - } else { - return [int]$rawBody - } - } - return $null + if (-not $resp -or $resp.status -ne 200) { return $null } + $rawBody = $resp.body + if ($null -eq $rawBody) { return $null } + if ($rawBody -is [int] -or $rawBody -is [long]) { return [int]$rawBody } + # Try @odata.count (from ?$count=true queries) + try { + $odataCount = $rawBody.'@odata.count' + if ($null -ne $odataCount) { return [int]$odataCount } + } catch {} + # Try .value as raw int + try { + $val = $rawBody.'value' + if ($null -ne $val -and ($val -is [int] -or $val -is [long])) { return [int]$val } + } catch {} + try { return [int]$rawBody } catch { return $null } } $intuneCount = & $getCount "intune" @@ -6676,11 +6693,37 @@ function Update-DashboardStatistics { $osAndroid = & $getCount "osAndroid" $osLinux = & $getCount "osLinux" - # Verify all counts were retrieved - $allCounts = @($intuneCount, $autopilotCount, $entraCount, $stale30, $stale90, $stale180, $personalDevices, $corporateDevices) - if ($allCounts -contains $null) { - throw "One or more $count queries returned an error" + # Log which counts failed and use defaults for non-critical ones + $failedIds = @() + if ($null -eq $intuneCount) { $failedIds += "intune" } + if ($null -eq $entraCount) { $failedIds += "entra" } + if ($failedIds.Count -gt 0) { + throw "Core `$count queries failed: $($failedIds -join ', ')" } + # Non-critical counts — default to 0 if the filter query is unsupported + if ($null -eq $autopilotCount) { + # Autopilot $count not supported in batch — fetch count directly + try { + $apResponse = Invoke-GraphRequestWithRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$top=1&`$count=true" -Method GET + $autopilotCount = if ($apResponse.'@odata.count') { [int]$apResponse.'@odata.count' } else { @($apResponse.value).Count } + Write-Log "Autopilot count fetched directly: $autopilotCount" + } catch { + Write-Log "Autopilot count fetch failed, trying full list: $_" -Severity "WARN" + try { + $apAll = Get-GraphPagedResults -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities" + $autopilotCount = @($apAll).Count + Write-Log "Autopilot count from full list: $autopilotCount" + } catch { + Write-Log "Autopilot endpoint unavailable, defaulting to 0: $_" -Severity "WARN" + $autopilotCount = 0 + } + } + } + if ($null -eq $stale30) { Write-Log "stale30 `$count returned null, defaulting to 0" -Severity "WARN"; $stale30 = 0 } + if ($null -eq $stale90) { Write-Log "stale90 `$count returned null, defaulting to 0" -Severity "WARN"; $stale90 = 0 } + if ($null -eq $stale180) { Write-Log "stale180 `$count returned null, defaulting to 0" -Severity "WARN"; $stale180 = 0 } + if ($null -eq $personalDevices) { Write-Log "personal `$count returned null, defaulting to 0" -Severity "WARN"; $personalDevices = 0 } + if ($null -eq $corporateDevices) { Write-Log "corporate `$count returned null, defaulting to 0" -Severity "WARN"; $corporateDevices = 0 } $countSuccess = $true $duration = (Get-Date) - $startTime @@ -6737,7 +6780,12 @@ function Update-DashboardStatistics { # Fallback: full-fetch approach if $count batch failed if (-not $countSuccess) { $intuneDevices = @(Get-GraphPagedResults -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=deviceName,serialNumber,lastSyncDateTime,operatingSystem,managedDeviceOwnerType") - $autopilotDevices = @(Get-GraphPagedResults -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$select=id,displayName,serialNumber,lastContactedDateTime") + $autopilotDevices = @() + try { + $autopilotDevices = @(Get-GraphPagedResults -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities") + } catch { + Write-Log "Autopilot fallback fetch failed (endpoint may be unavailable or permissions missing): $_" -Severity "WARN" + } $entraDevices = @(Get-GraphPagedResults -Uri "https://graph.microsoft.com/beta/devices?`$select=displayName,operatingSystem,operatingSystemVersion") Write-Log "Fallback: Total devices - Intune: $($intuneDevices.Count), Autopilot: $($autopilotDevices.Count), Entra: $($entraDevices.Count)" @@ -6865,7 +6913,8 @@ function Update-DashboardStatistics { $pathGeometry.Figures.Add($pathFigure) $path.Data = $pathGeometry - $color = if ($platformColors.ContainsKey($platform.Name)) { $platformColors[$platform.Name] } else { $platformColors['Unknown'] } + $pName = if ($platform.Name) { $platform.Name } else { 'Unknown' } + $color = if ($platformColors[$pName]) { $platformColors[$pName] } else { $platformColors['Unknown'] } $path.Fill = New-Object System.Windows.Media.SolidColorBrush( [System.Windows.Media.ColorConverter]::ConvertFromString($color)) @@ -6897,7 +6946,7 @@ function Update-DashboardStatistics { } catch { Write-Log "Error updating dashboard statistics: $_" - [System.Windows.MessageBox]::Show("Error updating dashboard statistics. Please ensure you are connected to MS Graph.") + [System.Windows.MessageBox]::Show("Error updating dashboard statistics: $_`n`nPlease ensure you are connected to MS Graph.") } } @@ -7819,7 +7868,7 @@ $AutopilotDevicesCard.Add_MouseLeftButtonUp({ try { $Window.Cursor = [System.Windows.Input.Cursors]::Wait Write-Log "Fetching all Autopilot devices..." - $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$select=displayName,serialNumber,lastContactedDateTime,model,manufacturer,userPrincipalName,systemFamily,managedDeviceOwnerType" + $uri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities" $autopilotDevices = Get-GraphPagedResults -Uri $uri $deviceList = @() From da8e86e2afc0594541e18b8fca6e1f3533967373 Mon Sep 17 00:00:00 2001 From: "@ugurkocde" <43906965+ugurkocde@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:20:35 +0100 Subject: [PATCH 05/16] Update version references to 0.3 --- DeviceOffboardingManager.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DeviceOffboardingManager.ps1 b/DeviceOffboardingManager.ps1 index e6e2457..48683f6 100644 --- a/DeviceOffboardingManager.ps1 +++ b/DeviceOffboardingManager.ps1 @@ -1,6 +1,6 @@ <#PSScriptInfo -.VERSION 0.2.2 +.VERSION 0.3 .GUID a686724d-588d-472e-b927-c4840c32eed1 From cbaf2e2654bab3e8a6608af2bca66e07d3411cdc Mon Sep 17 00:00:00 2001 From: Ugur Koc Date: Sat, 14 Mar 2026 13:24:03 +0100 Subject: [PATCH 06/16] Add v0.3 changelog entry --- Changelog.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Changelog.md b/Changelog.md index d0aafe6..121084b 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,44 @@ +## Version 0.3 - 3/14/2026 + +### Bug Fixes +- **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 + +### 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 +- **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**: Now shown as a supported service on the homepage (no longer marked as "Soon") + +### 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 +- **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) From db0eb2992a35c40bc70728d95a3926060402a677 Mon Sep 17 00:00:00 2001 From: Ugur Koc Date: Sat, 14 Mar 2026 13:57:46 +0100 Subject: [PATCH 07/16] UI/UX redesign: sidebar, home page, dashboard, device management, playbooks, confirmation dialog - Standardize CornerRadius to 6, fix dashboard subtitle contrast - Fix DataGrid column widths, add tooltips and hover states - Restructure sidebar: connect/tenant at top, nav center, utils bottom - Add ConnectionStatusDot indicator (red/green) - Redesign home page: slim preview banner, Get Started card, quick nav buttons - Add dashboard page header with refresh button - Fix hardcoded stale device progress bars with dynamic calculation - Add device management page header, search placeholder, result count - Fix dropdown labels (Device Name, Serial Number) - Add filter toggle, clear search, conditional offboard panel with selection count - Categorize playbook cards into Device Compliance, Inventory, Health sections - Make playbook cards responsive (MinWidth/MaxWidth) - Move confirmation warning to top, add type-to-confirm safeguard Co-Authored-By: Claude Opus 4.6 (1M context) --- DeviceOffboardingManager.ps1 | 905 ++++++++++++++++++++++++----------- 1 file changed, 618 insertions(+), 287 deletions(-) diff --git a/DeviceOffboardingManager.ps1 b/DeviceOffboardingManager.ps1 index 48683f6..791cb37 100644 --- a/DeviceOffboardingManager.ps1 +++ b/DeviceOffboardingManager.ps1 @@ -420,8 +420,8 @@ function ConvertTo-SafeDateTime { - @@ -487,7 +487,7 @@ function ConvertTo-SafeDateTime { @@ -750,26 +750,41 @@ function ConvertTo-SafeDateTime { - - - - - - - - - - - + @@ -1928,78 +2080,104 @@ function ConvertTo-SafeDateTime { x:Name="PlaybooksScrollViewer" Margin="20,0,20,20" VerticalScrollBarVisibility="Auto"> - - - + + + + + + + + + +