From 93b3494352ee1cd6c9ee2c2eafccacb93b08307d Mon Sep 17 00:00:00 2001 From: Casey Clifton <62827568+cclifton7@users.noreply.github.com> Date: Fri, 29 Aug 2025 17:00:50 -0400 Subject: [PATCH 1/3] Update DeviceOffboardingManager.ps1 --- DeviceOffboardingManager.ps1 | 7359 ++++++++++++++++++++++++++++++++++ 1 file changed, 7359 insertions(+) diff --git a/DeviceOffboardingManager.ps1 b/DeviceOffboardingManager.ps1 index 7727f1f..e6f9f90 100644 --- a/DeviceOffboardingManager.ps1 +++ b/DeviceOffboardingManager.ps1 @@ -27,6 +27,7365 @@ .RELEASENOTES Changelog: https://github.com/ugurkocde/DeviceOffboardingManager/blob/main/Changelog.md +.PRIVATEDATA + +#> + +<# + +.DESCRIPTION + 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() + +#Requires -Version 7.0 +#Requires -Modules Microsoft.Graph.Authentication + +# Made by Ugur with ❤️ +# Guide and documentation available at https://github.com/ugurkocde/DeviceOffboardingManager +# Feedback and contributions are welcome! + +# Load required assemblies with error handling +try { + Add-Type -AssemblyName PresentationFramework -ErrorAction Stop + Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop + Add-Type -AssemblyName PresentationCore -ErrorAction Stop + Add-Type -AssemblyName WindowsBase -ErrorAction Stop +} +catch { + Write-Host "Failed to load required .NET assemblies: $_" -ForegroundColor Red + Write-Host "Please ensure .NET Framework is properly installed." -ForegroundColor Red + exit 1 +} + + +$script:LogFilePath = [System.IO.Path]::Combine($env:LOCALAPPDATA, "DeviceOffBoardingManager", "IntuneOffboardingTool_Log.txt") + +# Ensure DeviceOffBoardingManager folder exists +Write-Host "Ensuring DeviceOffBoardingManager folder exists" +$settingsFolder = [System.IO.Path]::Combine($env:LOCALAPPDATA, "DeviceOffBoardingManager") +if (-not (Test-Path $settingsFolder)) { + Write-Host "DeviceOffBoardingManager folder does not exist." + Write-Host "Creating DeviceOffBoardingManager folder at $settingsFolder" + New-Item -Path $settingsFolder -ItemType Directory | Out-Null +} + +# Create default settings.json file +$settingsPath = [System.IO.Path]::Combine($settingsFolder, "settings.json") +if (-not (Test-Path $settingsPath)) { + Write-Log -Message "Creating default settings.json file at $settingsPath" + $defaultSettings = @{ + RememberCertAuthentication = $false + RememberSecretAuthentication = $false + QuerySCCM = $true + } + $defaultSettings | ConvertTo-Json | Set-Content -Path $settingsPath +} + +function Write-Log { + param( + [Parameter(Mandatory = $true)] + [string] $Message + ) + + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $logMessage = "$timestamp - $Message" + + Add-Content -Path $script:LogFilePath -Value $logMessage +} + +# Function to get installed version +function Get-InstalledVersion { + try { + $module = Get-InstalledPSResource DeviceOffboardingManager | Sort-Object Version -Descending | Select-Object -First 1 + if ($module) { + return $module.Version.ToString() + } + return $script:PSScriptRoot.VERSION + } + catch { + Write-Log "|| Line: $($_.InvocationInfo.ScriptLineNumber) || Error getting installed version: $_" + return "Unknown" + } +} + +# Function to get latest version from PowerShell Gallery +function Get-LatestVersion { + try { + $module = Find-Script -Name DeviceOffboardingManager -ErrorAction Stop + return $module.Version + } + catch { + Write-Log "|| Line: $($_.InvocationInfo.ScriptLineNumber) || Error getting latest version: $_" + return "Unknown" + } +} + +# Function to get script version from PSScriptInfo +function Get-ScriptVersion { + try { + $scriptContent = Get-Content -Path $PSCommandPath -TotalCount 10 + $versionLine = $scriptContent | Where-Object { $_ -match '\.VERSION\s+(.+)' } + if ($versionLine) { + return $matches[1].Trim() + } + return "Unknown" + } + catch { + return "Unknown" + } +} + +# Function to update version displays +function Update-VersionDisplays { + param($window) + + $updateStatus = $window.FindName('UpdateStatus') + + if ($updateStatus) { + $installedVersion = Get-InstalledVersion + $latestVersion = Get-LatestVersion + + # Update display and add click handler based on version comparison + if ($installedVersion -ne "Unknown" -and $latestVersion -ne "Unknown") { + if ([version]$installedVersion -lt [version]$latestVersion) { + $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, + [System.Windows.Input.MouseButtonEventHandler] { + Start-Process "https://github.com/ugurkocde/DeviceOffboardingManager/blob/main/README.md#update-to-the-latest-version" + } + ) + } + else { + $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) } + ) + } + } +} + +# Add the DeviceObject class definition +if (-not ([System.Management.Automation.PSTypeName]'DeviceObject').Type) { + Add-Type -TypeDefinition @" + using System; + using System.ComponentModel; + + public class DeviceObject : INotifyPropertyChanged + { + private bool isSelected; + public bool IsSelected + { + get { return isSelected; } + set + { + isSelected = value; + OnPropertyChanged("IsSelected"); + } + } + + public string DeviceName { get; set; } + public string SerialNumber { get; set; } + public string OperatingSystem { get; set; } + public string PrimaryUser { get; set; } + public DateTime? AzureADLastContact { get; set; } + public DateTime? IntuneLastContact { get; set; } + public DateTime? AutopilotLastContact { get; set; } + public DateTime? SCCMLastContact { get; set; } + + public event PropertyChangedEventHandler PropertyChanged; + + protected void OnPropertyChanged(string name) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + } + } +"@ +} + +# Define a helper function for paginated Graph API calls +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-Log "Error in pagination: $_" + break + } + } while ($nextLink) + + return $results +} + +# 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-Log "|| Line: $($_.InvocationInfo.ScriptLineNumber) || Failed to parse date: $dateString" + return $null + } +} + +# Define WPF XAML +[xml]$xaml = @" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"@ + +# Define Changelog Modal XAML +[xml]$changelogModalXaml = @" + + + + + + + + + + + + + + + + + + + + + + +'@ + + try { + $reader = (New-Object System.Xml.XmlNodeReader $confirmationModalXaml) + $confirmationWindow = [Windows.Markup.XamlReader]::Load($reader) + + if ($null -eq $confirmationWindow) { + throw "Failed to create confirmation window. XamlReader returned null." + } + } + catch { + Write-Log "Error creating confirmation window: $_" + [System.Windows.MessageBox]::Show( + "Failed to create the confirmation dialog. Error: $_", + "Dialog Creation Error", + [System.Windows.MessageBoxButton]::OK, + [System.Windows.MessageBoxImage]::Error + ) + return + } + + # Get controls + $servicesList = $confirmationWindow.FindName('ServicesList') + $cancelButton = $confirmationWindow.FindName('CancelButton') + $confirmButton = $confirmationWindow.FindName('ConfirmButton') + $encryptionKeysList = $confirmationWindow.FindName('EncryptionKeysList') + + # Create a list to store encryption key information + $encryptionKeys = New-Object System.Collections.ObjectModel.ObservableCollection[Object] + + # Get encryption keys for all selected devices + 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 + } + + 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)'" + $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.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 + } + else { + $keyInfo.KeyText = "Error retrieving BitLocker key details." + } + } + else { + $keyInfo.KeyText = "No BitLocker recovery key found for this device." + } + } + catch { + Write-Log "Error retrieving BitLocker key: $_" + if ($_.Exception.Response.StatusCode -eq 'Forbidden') { + $keyInfo.KeyText = "BitLocker key access denied. Ensure BitlockerKey.Read.All permission is granted." + } + else { + $keyInfo.KeyText = "Error retrieving BitLocker key. Check logs for details." + } + } + } + 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" + try { + $fileVaultKey = Invoke-MgGraphRequest -Uri $uri -Method GET + if ($fileVaultKey.value) { + $keyInfo.KeyText = "FileVault Recovery Key: $($fileVaultKey.value)" + $keyInfo.Key = $fileVaultKey.value + } + else { + $keyInfo.KeyText = "No FileVault recovery key found for this device." + } + } + catch { + #Write-Log "Error retrieving FileVault key: $_" + $keyInfo.KeyText = "Error retrieving FileVault key details." + } + } + else { + $keyInfo.KeyText = "Encryption key not applicable for this device type." + } + } + else { + $keyInfo.KeyText = "Device not found in Intune." + } + } + catch { + #Write-Log "Error retrieving encryption key: $_" + $keyInfo.KeyText = "Error retrieving encryption key. Please check logs for details." + } + + $encryptionKeys.Add([PSCustomObject]$keyInfo) + } + + # Set the ItemsSource of the EncryptionKeysList + $encryptionKeysList.ItemsSource = $encryptionKeys + + # Add copy button handler + $confirmationWindow.Add_SourceInitialized({ + $copyKeyButton_Click = { + param($sender, $e) + $button = $e.OriginalSource -as [System.Windows.Controls.Button] + if ($button -and $button.Tag) { + Set-Clipboard -Value $button.Tag + $button.Content = "Copied!" + $script:copyButtonTimer = New-Object System.Windows.Threading.DispatcherTimer + $script:copyButtonTimer.Interval = [TimeSpan]::FromSeconds(2) + $script:copyButtonTimer.Add_Tick({ + $button.Content = "Copy Key" + $script:copyButtonTimer.Stop() + }.GetNewClosure()) + $script:copyButtonTimer.Start() + } + }.GetNewClosure() + + $encryptionKeysList = $confirmationWindow.FindName('EncryptionKeysList') + $encryptionKeysList.AddHandler( + [System.Windows.Controls.Button]::ClickEvent, + [System.Windows.RoutedEventHandler]$copyKeyButton_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 = "SCCM"; Icon = "m124.326-8.588 0 9.63c.964 58.745.001 43.336.28 73.96-.01 2.13-1.11 4.33-2.02 6.35-2.46 5.41-5.99 10.5-7.47 16.14-3.37 12.84-.25 24.68 7.68 35.31 3.81 5.11 2.96 7.64-2.7 10.1-3.03 1.32-5.93 3.01-8.69 4.83-23.23 15.29-24.92 60.96 1.44 77.85-33.02 0-66.04 0-89.156.326-.27-59.84.564-155.244-.963-234.014M44.16 85.04c6.86 0 13.72 0 20.65 0 0-6.72 0-13.56 0-20.45-7.07 0-13.8 0-20.9 0 0 6.68 0 13.11.25 20.45m50.94.53c.54-6.9 1.08-13.8 1.64-20.93-7.83 0-14.7 0-21.56 0 0 7.05 0 13.79 0 21 6.47 0 12.72 0 19.92-.07m-51.2 64c0 4.25 0 8.49 0 12.85 7.34 0 14.07 0 21.02 0 0-6.95 0-13.68 0-20.54-7.02 0-13.89 0-21.02 0 0 2.44 0 4.58 0 7.69m40.65-7.99c-3.11 0-6.22 0-9.31 0 0 7.24 0 13.97 0 20.85 7 0 13.73 0 20.72 0 0-6.94 0-13.66 0-20.85-3.53 0-6.99 0-11.41 0m-40.64-22.17c0 1.44 0 2.87 0 4.34 7.34 0 14.19 0 20.98 0 0-7.03 0-13.75 0-20.5-7.09 0-13.95 0-20.98 0 0 5.26 0 10.22 0 16.16m31.07-2.96c0 2.43 0 4.86 0 7.29 7.36 0 14.22 0 20.98 0 0-7.03 0-13.76 0-20.48-7.1 0-13.96 0-20.98 0 0 4.28 0 8.25 0 13.19zM127.47 226c-1.29-.79-1.97-1.88-2.94-2.32-13.82-6.29-21.43-16.87-22.52-32.09-1.27-17.82 3.31-32.84 20.54-41.28 4.35-2.13 9.4-2.83 14.06-4.17 5.25 14.73 10.75 18.98 22.61 18.18 10.2-.69 15.52-5.73 18.09-17.12 14.16-.87 29.37 9.33 33.28 23.68 4.17 15.28 4.37 30.67-7.23 43.24-4.33 4.69-10.61 7.58-15.67 11.58-19.7.29-39.72.29-60.21.29zM165.71 73.03c18.8 5.11 27.42 21.88 27.16 35.06-.31 15.41-11.84 29.97-27.09 33.78-14.91 3.72-31.78-3.51-38.92-16.68-8.28-15.27-5.92-32.22 6.19-43.35 9.2-8.46 19.94-11.61 32.67-8.81z" } + ) + + # Create hashtable to store checkbox references + $script:serviceCheckboxes = @{} + + foreach ($service in $services) { + $serviceItem = New-Object System.Windows.Controls.Border + $serviceItem.Background = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString("#F7FAFC")) + $serviceItem.BorderBrush = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString("#E2E8F0")) + $serviceItem.BorderThickness = New-Object System.Windows.Thickness(1) + $serviceItem.CornerRadius = New-Object System.Windows.CornerRadius(6) + $serviceItem.Padding = New-Object System.Windows.Thickness(16, 12, 16, 12) + $serviceItem.Margin = New-Object System.Windows.Thickness(0, 0, 12, 12) + $serviceItem.MinWidth = 200 + + $stackPanel = New-Object System.Windows.Controls.StackPanel + $stackPanel.Orientation = "Horizontal" + + # Checkbox + $checkbox = New-Object System.Windows.Controls.CheckBox + $checkbox.IsChecked = $true + $checkbox.VerticalAlignment = "Center" + $checkbox.Margin = New-Object System.Windows.Thickness(0, 0, 12, 0) + $script:serviceCheckboxes[$service.Name] = $checkbox + + # Icon + $path = New-Object System.Windows.Shapes.Path + $path.Data = [System.Windows.Media.Geometry]::Parse($service.Icon) + $path.Fill = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString("#4A5568")) + $path.Width = 24 + $path.Height = 24 + $path.Stretch = "Uniform" + $path.Margin = New-Object System.Windows.Thickness(0, 0, 12, 0) + $path.VerticalAlignment = "Center" + + # Service name + $text = New-Object System.Windows.Controls.TextBlock + $text.Text = $service.Name + $text.FontSize = 14 + $text.Foreground = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString("#2D3748")) + $text.VerticalAlignment = "Center" + + $stackPanel.Children.Add($checkbox) + $stackPanel.Children.Add($path) + $stackPanel.Children.Add($text) + $serviceItem.Child = $stackPanel + $servicesList.Children.Add($serviceItem) + } + + # Add button handlers + $cancelButton.Add_Click({ + $confirmationWindow.DialogResult = $false + $confirmationWindow.Close() + }) + + $confirmButton.Add_Click({ + # Check if at least one service is selected + $anyServiceSelected = $false + foreach ($checkbox in $script:serviceCheckboxes.Values) { + if ($checkbox.IsChecked) { + $anyServiceSelected = $true + break + } + } + + if (-not $anyServiceSelected) { + [System.Windows.MessageBox]::Show( + "Please select at least one service to remove the device(s) from.", + "No Service Selected", + [System.Windows.MessageBoxButton]::OK, + [System.Windows.MessageBoxImage]::Warning + ) + return + } + + $confirmationWindow.DialogResult = $true + $confirmationWindow.Close() + }) + + # Show dialog + try { + if ($null -eq $confirmationWindow) { + throw "Confirmation window is null. Cannot show dialog." + } + $confirmationResult = $confirmationWindow.ShowDialog() + } + catch { + Write-Log "Error showing confirmation dialog: $_" + [System.Windows.MessageBox]::Show( + "Failed to show the confirmation dialog. Error: $_", + "Dialog Error", + [System.Windows.MessageBoxButton]::OK, + [System.Windows.MessageBoxImage]::Error + ) + return + } + if (-not $confirmationResult) { + Write-Log "User canceled offboarding operation." + return + } + + # Create results collection to track all operations + $offboardingResults = @() + + try { + foreach ($device in $selectedDevices) { + $deviceName = $device.DeviceName + $serialNumber = $device.SerialNumber + $deviceResult = @{ + DeviceName = $deviceName + SerialNumber = $serialNumber + EntraID = @{ Found = $false; Success = $false; Error = $null } + Intune = @{ Found = $false; Success = $false; Error = $null } + Autopilot = @{ Found = $false; Success = $false; Error = $null } + SCCM = @{ Found = $false; Success = $false; Error = $null } + } + + Write-Log "Starting offboarding for device: $deviceName (Serial: $serialNumber)" + + # 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) { + $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 "; ") + } + } + else { + Write-Log "Device $deviceName not found in Entra ID." + } + } + elseif ($deviceName -and -not $script:serviceCheckboxes["Entra ID"].IsChecked) { + Write-Log "Skipping Entra ID 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 + } + 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 + } + 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" + } + + 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." + } + catch { + $deviceResult.Intune.Error = $_.Exception.Message + Write-Log "Error removing device $deviceName from Intune: $_" + } + } + else { + Write-Log "Device $deviceName not found in Intune." + } + } + 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)" + } + } + catch { + Write-Log "Error searching Autopilot devices by display name: $_" + } + } + + 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: $_" + } + } + 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)" + } + + # Get SCCM Device + if ($script:serviceCheckboxes["SCCM"].IsChecked) { + # --- SCCM Offboarding Section --- + # Notes: + # - This section attempts to remove the device from SCCM using Remove-CMDevice. + # - It first connects to SCCM, then tries to remove the device by name. + # - Results are tracked in $deviceResult.SCCM. + # - Any errors are logged. + # - The original PowerShell location is restored at the end. + + ConnectSCCM + try { + $removeResult = Remove-CMDevice -Name $deviceName -Force -ErrorAction Stop + if ($?) { + $deviceResult.SCCM.Found = $true + $deviceResult.SCCM.Success = $true + Write-Log "Successfully removed device $deviceName from SCCM." + } + else { + $deviceResult.SCCM.Found = $true + $deviceResult.SCCM.Success = $false + $deviceResult.SCCM.Error = "Remove-CMDevice did not complete successfully." + Write-Log "Remove-CMDevice did not complete successfully for $deviceName." + } + + } + catch { + Write-Log "Error during SCCM offboarding for device $deviceName : $_" + } + Set-Location $originaldrive + } + else { + Write-Log "Skipping SCCM removal for device $deviceName (not selected)" + } + + $offboardingResults += $deviceResult + Write-Log "Completed offboarding attempt for device: $deviceName" + } + + # Show summary of all operations + Show-OffboardingSummary -Results $offboardingResults + + # Update UI status indicators if all operations were successful + $allEntraSuccess = $offboardingResults | Where-Object { $_.EntraID.Found -and $_.EntraID.Success } | Measure-Object | Select-Object -ExpandProperty Count + $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 + $allSCCMSuccess = $offboardingResults | Where-Object { $_.SCCM.Found -and $_.SCCM.Success } | Measure-Object | Select-Object -ExpandProperty Count + + if ($allEntraSuccess -gt 0) { + $Window.FindName('aad_status').Text = "Entra ID: Devices Removed" + $Window.FindName('aad_status').Foreground = "#FC8181" + } + if ($allIntuneSuccess -gt 0) { + $Window.FindName('intune_status').Text = "Intune: Devices Removed" + $Window.FindName('intune_status').Foreground = "#FC8181" + } + if ($allAutopilotSuccess -gt 0) { + $Window.FindName('autopilot_status').Text = "Autopilot: Devices Removed" + $Window.FindName('autopilot_status').Foreground = "#FC8181" + } + if ($allSCCMSuccess -gt 0) { + $Window.FindName('sccm_status').Text = "SCCM: Devices Removed" + $Window.FindName('sccm_status').Foreground = "#FC8181" + } + } + catch { + Write-Log "Critical error in offboarding operation. Exception: $_" + [System.Windows.MessageBox]::Show("Critical error in offboarding operation. Please check the logs for details.") + } + }) + +# Export search results button +$ExportSearchResultsButton = $Window.FindName('ExportSearchResultsButton') +$ExportSearchResultsButton.Add_Click({ + $results = $SearchResultsDataGrid.ItemsSource + if ($results -and $results.Count -gt 0) { + $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" + $fileName = "Device_Search_Results_${timestamp}.csv" + + # Create a clean export list without UI-specific properties + $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 + } + } + + Export-DeviceListToCSV -DeviceList $exportData -DefaultFileName $fileName + } + else { + [System.Windows.MessageBox]::Show( + "No search results to export.", + "Export", + [System.Windows.MessageBoxButton]::OK, + [System.Windows.MessageBoxImage]::Information + ) + } + }) + +# Export selected devices button +$ExportSelectedButton = $Window.FindName('ExportSelectedButton') +$ExportSelectedButton.Add_Click({ + $selectedDevices = $SearchResultsDataGrid.ItemsSource | Where-Object { $_.IsSelected } + if ($selectedDevices -and $selectedDevices.Count -gt 0) { + $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" + $fileName = "Selected_Devices_${timestamp}.csv" + + # Create a clean export list with device names and relevant metadata + $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 + } + } + + Export-DeviceListToCSV -DeviceList $exportData -DefaultFileName $fileName + } + else { + [System.Windows.MessageBox]::Show( + "No devices selected to export.", + "Export Selected", + [System.Windows.MessageBoxButton]::OK, + [System.Windows.MessageBoxImage]::Information + ) + } + }) + +function Show-OffboardingSummary { + param( + [Parameter(Mandatory = $true)] + [array]$Results + ) + + [xml]$summaryModalXaml = @' + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -"@ - -# Define Changelog Modal XAML -[xml]$changelogModalXaml = @" - - - - - - - - - - - - - - - - - - - - - - -'@ - - try { - $reader = (New-Object System.Xml.XmlNodeReader $confirmationModalXaml) - $confirmationWindow = [Windows.Markup.XamlReader]::Load($reader) - - if ($null -eq $confirmationWindow) { - throw "Failed to create confirmation window. XamlReader returned null." - } - } - catch { - Write-Log "Error creating confirmation window: $_" - [System.Windows.MessageBox]::Show( - "Failed to create the confirmation dialog. Error: $_", - "Dialog Creation Error", - [System.Windows.MessageBoxButton]::OK, - [System.Windows.MessageBoxImage]::Error - ) - return - } - - # Get controls - $servicesList = $confirmationWindow.FindName('ServicesList') - $cancelButton = $confirmationWindow.FindName('CancelButton') - $confirmButton = $confirmationWindow.FindName('ConfirmButton') - $encryptionKeysList = $confirmationWindow.FindName('EncryptionKeysList') - - # Create a list to store encryption key information - $encryptionKeys = New-Object System.Collections.ObjectModel.ObservableCollection[Object] - - # Get encryption keys for all selected devices - 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 - } - - 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)'" - $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.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 - } - else { - $keyInfo.KeyText = "Error retrieving BitLocker key details." - } - } - else { - $keyInfo.KeyText = "No BitLocker recovery key found for this device." - } - } - catch { - Write-Log "Error retrieving BitLocker key: $_" - if ($_.Exception.Response.StatusCode -eq 'Forbidden') { - $keyInfo.KeyText = "BitLocker key access denied. Ensure BitlockerKey.Read.All permission is granted." - } - else { - $keyInfo.KeyText = "Error retrieving BitLocker key. Check logs for details." - } - } - } - 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" - try { - $fileVaultKey = Invoke-MgGraphRequest -Uri $uri -Method GET - if ($fileVaultKey.value) { - $keyInfo.KeyText = "FileVault Recovery Key: $($fileVaultKey.value)" - $keyInfo.Key = $fileVaultKey.value - } - else { - $keyInfo.KeyText = "No FileVault recovery key found for this device." - } - } - catch { - #Write-Log "Error retrieving FileVault key: $_" - $keyInfo.KeyText = "Error retrieving FileVault key details." - } - } - else { - $keyInfo.KeyText = "Encryption key not applicable for this device type." - } - } - else { - $keyInfo.KeyText = "Device not found in Intune." - } - } - catch { - #Write-Log "Error retrieving encryption key: $_" - $keyInfo.KeyText = "Error retrieving encryption key. Please check logs for details." - } - - $encryptionKeys.Add([PSCustomObject]$keyInfo) - } - - # Set the ItemsSource of the EncryptionKeysList - $encryptionKeysList.ItemsSource = $encryptionKeys - - # Add copy button handler - $confirmationWindow.Add_SourceInitialized({ - $copyKeyButton_Click = { - param($sender, $e) - $button = $e.OriginalSource -as [System.Windows.Controls.Button] - if ($button -and $button.Tag) { - Set-Clipboard -Value $button.Tag - $button.Content = "Copied!" - $script:copyButtonTimer = New-Object System.Windows.Threading.DispatcherTimer - $script:copyButtonTimer.Interval = [TimeSpan]::FromSeconds(2) - $script:copyButtonTimer.Add_Tick({ - $button.Content = "Copy Key" - $script:copyButtonTimer.Stop() - }.GetNewClosure()) - $script:copyButtonTimer.Start() - } - }.GetNewClosure() - - $encryptionKeysList = $confirmationWindow.FindName('EncryptionKeysList') - $encryptionKeysList.AddHandler( - [System.Windows.Controls.Button]::ClickEvent, - [System.Windows.RoutedEventHandler]$copyKeyButton_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" } - ) - - # Create hashtable to store checkbox references - $script:serviceCheckboxes = @{} - - foreach ($service in $services) { - $serviceItem = New-Object System.Windows.Controls.Border - $serviceItem.Background = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString("#F7FAFC")) - $serviceItem.BorderBrush = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString("#E2E8F0")) - $serviceItem.BorderThickness = New-Object System.Windows.Thickness(1) - $serviceItem.CornerRadius = New-Object System.Windows.CornerRadius(6) - $serviceItem.Padding = New-Object System.Windows.Thickness(16, 12, 16, 12) - $serviceItem.Margin = New-Object System.Windows.Thickness(0, 0, 12, 12) - $serviceItem.MinWidth = 200 - - $stackPanel = New-Object System.Windows.Controls.StackPanel - $stackPanel.Orientation = "Horizontal" - - # Checkbox - $checkbox = New-Object System.Windows.Controls.CheckBox - $checkbox.IsChecked = $true - $checkbox.VerticalAlignment = "Center" - $checkbox.Margin = New-Object System.Windows.Thickness(0, 0, 12, 0) - $script:serviceCheckboxes[$service.Name] = $checkbox - - # Icon - $path = New-Object System.Windows.Shapes.Path - $path.Data = [System.Windows.Media.Geometry]::Parse($service.Icon) - $path.Fill = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString("#4A5568")) - $path.Width = 24 - $path.Height = 24 - $path.Stretch = "Uniform" - $path.Margin = New-Object System.Windows.Thickness(0, 0, 12, 0) - $path.VerticalAlignment = "Center" - - # Service name - $text = New-Object System.Windows.Controls.TextBlock - $text.Text = $service.Name - $text.FontSize = 14 - $text.Foreground = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.ColorConverter]::ConvertFromString("#2D3748")) - $text.VerticalAlignment = "Center" - - $stackPanel.Children.Add($checkbox) - $stackPanel.Children.Add($path) - $stackPanel.Children.Add($text) - $serviceItem.Child = $stackPanel - $servicesList.Children.Add($serviceItem) - } - - # Add button handlers - $cancelButton.Add_Click({ - $confirmationWindow.DialogResult = $false - $confirmationWindow.Close() - }) - - $confirmButton.Add_Click({ - # Check if at least one service is selected - $anyServiceSelected = $false - foreach ($checkbox in $script:serviceCheckboxes.Values) { - if ($checkbox.IsChecked) { - $anyServiceSelected = $true - break - } - } - - if (-not $anyServiceSelected) { - [System.Windows.MessageBox]::Show( - "Please select at least one service to remove the device(s) from.", - "No Service Selected", - [System.Windows.MessageBoxButton]::OK, - [System.Windows.MessageBoxImage]::Warning - ) - return - } - - $confirmationWindow.DialogResult = $true - $confirmationWindow.Close() - }) - - # Show dialog - try { - if ($null -eq $confirmationWindow) { - throw "Confirmation window is null. Cannot show dialog." - } - $confirmationResult = $confirmationWindow.ShowDialog() - } - catch { - Write-Log "Error showing confirmation dialog: $_" - [System.Windows.MessageBox]::Show( - "Failed to show the confirmation dialog. Error: $_", - "Dialog Error", - [System.Windows.MessageBoxButton]::OK, - [System.Windows.MessageBoxImage]::Error - ) - return - } - if (-not $confirmationResult) { - Write-Log "User canceled offboarding operation." - return - } - - # Create results collection to track all operations - $offboardingResults = @() - - try { - foreach ($device in $selectedDevices) { - $deviceName = $device.DeviceName - $serialNumber = $device.SerialNumber - $deviceResult = @{ - DeviceName = $deviceName - SerialNumber = $serialNumber - EntraID = @{ Found = $false; Success = $false; Error = $null } - Intune = @{ Found = $false; Success = $false; Error = $null } - Autopilot = @{ Found = $false; Success = $false; Error = $null } - } - - Write-Log "Starting offboarding for device: $deviceName (Serial: $serialNumber)" - - # 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) { - $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 "; ") - } - } - else { - Write-Log "Device $deviceName not found in Entra ID." - } - } - elseif ($deviceName -and -not $script:serviceCheckboxes["Entra ID"].IsChecked) { - Write-Log "Skipping Entra ID 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 - } - 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 - } - 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" - } - - 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." - } - catch { - $deviceResult.Intune.Error = $_.Exception.Message - Write-Log "Error removing device $deviceName from Intune: $_" - } - } - else { - Write-Log "Device $deviceName not found in Intune." - } - } - 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)" - } - } - catch { - Write-Log "Error searching Autopilot devices by display name: $_" - } - } - - 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: $_" - } - } - 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 - Show-OffboardingSummary -Results $offboardingResults - - # Update UI status indicators if all operations were successful - $allEntraSuccess = $offboardingResults | Where-Object { $_.EntraID.Found -and $_.EntraID.Success } | Measure-Object | Select-Object -ExpandProperty Count - $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) { - $Window.FindName('aad_status').Text = "Entra ID: Devices Removed" - $Window.FindName('aad_status').Foreground = "#FC8181" - } - if ($allIntuneSuccess -gt 0) { - $Window.FindName('intune_status').Text = "Intune: Devices Removed" - $Window.FindName('intune_status').Foreground = "#FC8181" - } - if ($allAutopilotSuccess -gt 0) { - $Window.FindName('autopilot_status').Text = "Autopilot: Devices Removed" - $Window.FindName('autopilot_status').Foreground = "#FC8181" - } - } - catch { - Write-Log "Critical error in offboarding operation. Exception: $_" - [System.Windows.MessageBox]::Show("Critical error in offboarding operation. Please check the logs for details.") - } - }) - -# Export search results button -$ExportSearchResultsButton = $Window.FindName('ExportSearchResultsButton') -$ExportSearchResultsButton.Add_Click({ - $results = $SearchResultsDataGrid.ItemsSource - if ($results -and $results.Count -gt 0) { - $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" - $fileName = "Device_Search_Results_${timestamp}.csv" - - # Create a clean export list without UI-specific properties - $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 - } - } - - Export-DeviceListToCSV -DeviceList $exportData -DefaultFileName $fileName - } - else { - [System.Windows.MessageBox]::Show( - "No search results to export.", - "Export", - [System.Windows.MessageBoxButton]::OK, - [System.Windows.MessageBoxImage]::Information - ) - } - }) - -# Export selected devices button -$ExportSelectedButton = $Window.FindName('ExportSelectedButton') -$ExportSelectedButton.Add_Click({ - $selectedDevices = $SearchResultsDataGrid.ItemsSource | Where-Object { $_.IsSelected } - if ($selectedDevices -and $selectedDevices.Count -gt 0) { - $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" - $fileName = "Selected_Devices_${timestamp}.csv" - - # Create a clean export list with device names and relevant metadata - $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 - } - } - - Export-DeviceListToCSV -DeviceList $exportData -DefaultFileName $fileName - } - else { - [System.Windows.MessageBox]::Show( - "No devices selected to export.", - "Export Selected", - [System.Windows.MessageBoxButton]::OK, - [System.Windows.MessageBoxImage]::Information - ) - } - }) - -function Show-OffboardingSummary { - param( - [Parameter(Mandatory = $true)] - [array]$Results - ) - - [xml]$summaryModalXaml = @' - - - - - - - - - - - - - - -