#requires -Version 5.1 <# .SYNOPSIS Bulk device operations for Intune with enterprise-grade safeguards. .DESCRIPTION Retire, wipe, delete, or sync devices in bulk with filtering, dry-run mode, and exclusions for hybrid-joined devices. Uses fzf when available. .EXAMPLE ./Scripts/Bulk-DeviceOperations.ps1 -TenantId "contoso.onmicrosoft.com" -WhatIf #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$TenantId, [string]$AppId, [string]$Secret, [string]$Certificate, [ValidateSet("AppOnly","Browser","DeviceCode")] [string]$AuthMode = "AppOnly", [string]$RedirectUri, [string]$SettingsFile, [switch]$WhatIf ) $ErrorActionPreference = "Stop" #region Helper functions function Test-FzfAvailable { return [bool](Get-Command fzf -ErrorAction SilentlyContinue) } function Show-FzfMenu { param( [Parameter(Mandatory)] [string[]]$Items, [string]$Header = "Select one", [switch]$Multi ) $argsList = @("--header=$Header") if($Multi) { $argsList += "--multi" } $selected = $Items | fzf @argsList if(-not $selected) { return $null } if($Multi) { return @($selected -split "`r?`n" | Where-Object { $_ }) } return $selected } function Show-NumberedMenu { param( [Parameter(Mandatory)] [string[]]$Items, [string]$Header = "Select one or more", [switch]$Multi ) Write-Host "`n$Header" -ForegroundColor Cyan for($i=0; $i -lt $Items.Count; $i++) { Write-Host " $($i+1). $($Items[$i])" } if($Multi) { $prompt = "Enter numbers separated by commas (e.g. 1,3,5) or 'all'" } else { $prompt = "Enter a number" } $choice = Read-Host $prompt if($choice -eq "all" -and $Multi) { return $Items } $indices = $choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $Items.Count } if($Multi) { return $Items[$indices] | Select-Object -Unique } else { if($indices.Count -eq 0) { return $null } return $Items[$indices[0]] } } function Select-MenuItem { param( [Parameter(Mandatory)] [string[]]$Items, [string]$Header = "Select one", [switch]$Multi ) if(Test-FzfAvailable) { return Show-FzfMenu -Items $Items -Header $Header -Multi:$Multi } return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi } function Read-YesNo { param( [string]$Prompt, [bool]$Default = $false ) $defaultChar = if($Default) { "Y" } else { "N" } $response = Read-Host "$Prompt [Y/n] (default: $defaultChar)" if([string]::IsNullOrWhiteSpace($response)) { return $Default } return $response -match "^\s*y" } function Get-DefaultSettingsPath { if($IsWindows -or $env:OS -eq "Windows_NT") { if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") } return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json") } if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") } return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json") } #endregion #region Initialize Runtime $projectRoot = Split-Path -Parent $PSScriptRoot $runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1" if(-not (Test-Path $runtimeModule)) { throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot" } $settingsPath = $SettingsFile if(-not $settingsPath) { $settingsPath = Get-DefaultSettingsPath } # Pre-load auth from settings if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or (-not $Secret -and -not $Certificate))) { try { $raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop $settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop if($settingsObj -and $settingsObj.ContainsKey($TenantId)) { $tenantNode = $settingsObj[$TenantId] if(-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId")) { $AppId = $tenantNode["GraphAzureAppId"] } if(-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret")) { $Secret = $tenantNode["GraphAzureAppSecret"] } if(-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert")) { $Certificate = $tenantNode["GraphAzureAppCert"] } } if(-not $Secret -and $IsMacOS -and $AppId) { try { $keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null if($keychainSecret) { $Secret = $keychainSecret } } catch { } } } catch { } } $invokeParams = @{ Silent = $true JSonSettings = $true JSonFile = $settingsPath TenantId = $TenantId AppId = $AppId AuthMode = $AuthMode } if($RedirectUri) { $invokeParams.RedirectUri = $RedirectUri } if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret } elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate } Import-Module $runtimeModule -Force Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams #endregion #region Ensure Graph connectivity if(-not (Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue)) { throw "Graph runtime did not load Invoke-GraphRequest. Aborting." } Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan try { $org = Invoke-GraphRequest "/organization" Write-Host "Connected to tenant: $($org.value[0].displayName) ($($org.value[0].id))" -ForegroundColor Green } catch { throw "Failed to connect to Graph. Ensure auth parameters are correct. Error: $_" } #endregion Clear-Host Write-Host "========================================" -ForegroundColor Cyan Write-Host " Intune Bulk Device Operations" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan if($WhatIf) { Write-Host "`n*** DRY-RUN MODE ENABLED ***" -ForegroundColor Magenta Write-Host "No destructive actions will be performed." -ForegroundColor Magenta } #region Action selection $action = Select-MenuItem -Items @("Delete","Retire","Wipe (Factory Reset)","Remote Lock","Sync") -Header "Select device operation" if(-not $action) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 } $actionValue = switch($action) { "Delete" { "delete" } "Retire" { "retire" } "Wipe (Factory Reset)" { "wipe" } "Remote Lock" { "remoteLock" } "Sync" { "syncDevice" } } #endregion #region Load devices with filtering Write-Host "`nLoading managed devices..." -ForegroundColor Cyan $deviceUrl = "/deviceManagement/managedDevices?`$select=id,deviceName,operatingSystem,complianceState,lastSyncDateTime,azureADDeviceId,azureADRegistered,isEncrypted,userPrincipalName,ownerType,managementState&`$orderby=deviceName" $devicesResponse = Invoke-GraphRequest $deviceUrl $devices = $devicesResponse.value | Where-Object { $_.deviceName } | Sort-Object deviceName Write-Host "Found $($devices.Count) devices." -ForegroundColor Green # Filters Write-Host "`n--- Apply Filters ---" -ForegroundColor Cyan $osFilter = Read-Host "Filter by OS (Windows, iOS, macOS, Android — or press Enter for all)" if(-not [string]::IsNullOrWhiteSpace($osFilter)) { $devices = $devices | Where-Object { $_.operatingSystem -like "*$osFilter*" } } $complianceFilter = Select-MenuItem -Items @("(all)","compliant","noncompliant","unknown","notApplicable","remediated","error","conflict") -Header "Filter by compliance state" if($complianceFilter -and $complianceFilter -ne "(all)") { $devices = $devices | Where-Object { $_.complianceState -eq $complianceFilter } } $daysInactive = Read-Host "Only show devices inactive for more than N days (press Enter to skip)" if(-not [string]::IsNullOrWhiteSpace($daysInactive) -and $daysInactive -match "^\d+$") { $cutoff = (Get-Date).AddDays(-[int]$daysInactive) $devices = $devices | Where-Object { [datetime]$_.lastSyncDateTime -lt $cutoff } } $nameFilter = Read-Host "Filter by device name (partial match, press Enter to skip)" if(-not [string]::IsNullOrWhiteSpace($nameFilter)) { $devices = $devices | Where-Object { $_.deviceName -like "*$nameFilter*" } } Write-Host "`nFiltered to $($devices.Count) devices." -ForegroundColor Green if($devices.Count -eq 0) { Write-Host "No devices match filters. Exiting." -ForegroundColor Yellow exit 0 } $deviceDisplays = $devices | ForEach-Object { "$($_.deviceName) | $($_.operatingSystem) | $($_.complianceState) | $($_.userPrincipalName) [$($_.id)]" } $selectedDisplays = Select-MenuItem -Items $deviceDisplays -Header "Select devices (multi-select)" -Multi if(-not $selectedDisplays) { Write-Host "No devices selected. Exiting." -ForegroundColor Yellow exit 0 } $selectedDevices = @() foreach($disp in $selectedDisplays) { $id = $disp -replace '.*\[(.*?)\]$', '$1' $dev = $devices | Where-Object { $_.id -eq $id } | Select-Object -First 1 if($dev) { $selectedDevices += $dev } } Write-Host "Selected $($selectedDevices.Count) devices." -ForegroundColor Green #endregion #region Safeguards $excludeHybrid = Read-YesNo -Prompt "Exclude hybrid Azure AD joined devices?" -Default $true if($excludeHybrid) { $preCount = $selectedDevices.Count # We need ownerType or join type info. managedDevices doesn't always expose hybrid directly, # but azureADRegistered + ownerType can help. We'll check azureADDeviceId against devices endpoint for joinType. Write-Host "`nChecking device join types..." -ForegroundColor Cyan $aadDeviceIds = $selectedDevices | Where-Object { $_.azureADDeviceId } | Select-Object -ExpandProperty azureADDeviceId -Unique $hybridIds = @{} foreach($aadId in $aadDeviceIds) { try { $aadDevice = Invoke-GraphRequest "/devices?`$filter=deviceId eq '$aadId'&`$select=id,displayName,joinType" if($aadDevice.value -and $aadDevice.value[0].joinType -eq "hybridAzureADJoin") { $hybridIds[$aadId] = $true } } catch { } } $selectedDevices = $selectedDevices | Where-Object { -not $hybridIds[$_.azureADDeviceId] } $excluded = $preCount - $selectedDevices.Count if($excluded -gt 0) { Write-Host "Excluded $excluded hybrid-joined device(s)." -ForegroundColor Yellow } } if($selectedDevices.Count -eq 0) { Write-Host "No devices remaining after safeguards. Exiting." -ForegroundColor Yellow exit 0 } #endregion #region Review Clear-Host Write-Host "Review operation:" -ForegroundColor Green Write-Host " Action : $action" Write-Host " Devices : $($selectedDevices.Count)" foreach($d in $selectedDevices) { Write-Host " - $($d.deviceName) ($($d.operatingSystem)) | $($d.userPrincipalName)" } $confirmText = switch($actionValue) { "delete" { "PERMANENTLY DELETE" } "wipe" { "FACTORY RESET" } default { $action.ToUpper() } } $confirm = Read-Host "`nType '$confirmText' to confirm, or press Enter to cancel" if($confirm -ne $confirmText) { Write-Host "Cancelled." -ForegroundColor Yellow exit 0 } #endregion #region Execute $success = 0 $failed = 0 foreach($dev in $selectedDevices) { Write-Host "`nProcessing: $($dev.deviceName)" -ForegroundColor Cyan -NoNewline try { if($WhatIf) { Write-Host " [WHATIF: $actionValue]" -ForegroundColor Magenta $success++ continue } if($actionValue -in @("delete","retire","remoteLock","syncDevice")) { $url = "/deviceManagement/managedDevices/$($dev.id)/$actionValue" $null = Invoke-GraphRequest $url -HttpMethod POST } elseif($actionValue -eq "wipe") { # Wipe supports keepEnrollmentData / keepUserData flags $keepEnrollment = Read-YesNo -Prompt "Keep enrollment data for $($dev.deviceName)?" -Default $false $keepUserData = Read-YesNo -Prompt "Keep user data for $($dev.deviceName)?" -Default $false $body = @{ keepEnrollmentData = $keepEnrollment keepUserData = $keepUserData macOsUnlockCode = "" } | ConvertTo-Json -Compress $null = Invoke-GraphRequest "/deviceManagement/managedDevices/$($dev.id)/wipe" -HttpMethod POST -Content $body } Write-Host " -> OK" -ForegroundColor Green $success++ } catch { Write-Host " -> ERROR: $($_.Exception.Message)" -ForegroundColor Red $failed++ } } Write-Host "`n========================================" -ForegroundColor Cyan Write-Host " Bulk Device Operations Complete" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan Write-Host " Success : $success" Write-Host " Failed : $failed" #endregion