Public/Show-IntuneLapsGui.ps1
|
function Show-IntuneLapsGui { <# .SYNOPSIS Launches the IntuneLaps WPF graphical user interface. .DESCRIPTION Opens a Windows Presentation Foundation (WPF) window that allows the user to interactively sign in, search for Intune-managed devices, and retrieve LAPS credentials. The GUI respects the Windows dark/light mode setting. If already connected via Connect-IntuneLaps, the existing session is reused without re-authenticating. The [LapsSession] singleton is reconstructed on the STA thread via Build-LapsSession (WPF STA threads have their own module scope and do not inherit $script:CurrentSession from the calling thread). .EXAMPLE Show-IntuneLapsGui #> [CmdletBinding()] param() begin { $ErrorActionPreference = 'Stop' # WPF is Windows-only. Fail gracefully on macOS/Linux with a clear message. if (-not $IsWindows -and $PSVersionTable.PSVersion.Major -ge 6) { Write-Warning 'Show-IntuneLapsGui is only supported on Windows (WPF not available on macOS/Linux).' Write-Warning 'Use the CLI functions instead: Connect-IntuneLaps | Find-IntuneLapsDevice -DeviceName <name> | Get-IntuneLapsCredential -DeviceId <id>' return } # WPF requires STA (Single Threaded Apartment) mode. # PowerShell 5.1 runs STA by default; PowerShell 7 (pwsh) runs MTA by default. [System.Threading.ApartmentState]$CurrentState = [System.Threading.Thread]::CurrentThread.GetApartmentState() if ($CurrentState -ne [System.Threading.ApartmentState]::STA) { Write-Verbose 'Running in MTA (PowerShell 7). Launching WPF GUI on a dedicated STA thread...' # Capture the manifest path before entering the thread — $PSCommandPath is $null inside a Thread scriptblock [string]$ManifestPath = Join-Path -Path $PSScriptRoot -ChildPath '..\IntuneLaps.psd1' $StaThread = [System.Threading.Thread]::new({ Import-Module $ManifestPath -Force -ErrorAction Stop Show-IntuneLapsGui }.GetNewClosure()) $StaThread.SetApartmentState([System.Threading.ApartmentState]::STA) $StaThread.IsBackground = $true $StaThread.Start() $StaThread.Join() return } # Load required WPF assemblies (STA thread confirmed) Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName PresentationCore Add-Type -AssemblyName WindowsBase Add-Type -AssemblyName System.Xaml } process { # ─── Read XAML ──────────────────────────────────────────────────────────── [string]$XamlPath = Join-Path -Path $PSScriptRoot -ChildPath '..\Resources\MainWindow.xaml' if (-not (Test-Path -Path $XamlPath)) { throw "MainWindow.xaml not found at: $XamlPath" } [xml]$Xaml = Get-Content -Path $XamlPath -Raw -Encoding UTF8 $Reader = [System.Xml.XmlNodeReader]::new($Xaml) $Window = [System.Windows.Markup.XamlReader]::Load($Reader) Set-WindowTheme -Window $Window -IsDark (Get-WindowsIsDarkMode) # ─── Get named controls ──────────────────────────────────────────────────── $TxtSearch = $Window.FindName('TxtSearch') $BtnSearch = $Window.FindName('BtnSearch') $BtnConnect = $Window.FindName('BtnConnect') $BtnDisconnect = $Window.FindName('BtnDisconnect') $GridDevices = $Window.FindName('GridDevices') $LblSelectedDevice = $Window.FindName('LblSelectedDevice') $BtnGetCredentials = $Window.FindName('BtnGetCredentials') $TxtUsername = $Window.FindName('TxtUsername') $BtnCopyUsername = $Window.FindName('BtnCopyUsername') $PwdPassword = $Window.FindName('PwdPassword') $TxtPassword = $Window.FindName('TxtPassword') $BtnTogglePassword = $Window.FindName('BtnTogglePassword') $BtnCopyPassword = $Window.FindName('BtnCopyPassword') $LblStatus = $Window.FindName('LblStatus') $LblPermissionLevel= $Window.FindName('LblPermissionLevel') $PrgLoading = $Window.FindName('PrgLoading') $PnlLoading = $Window.FindName('PnlLoading') $TxtLoadingStatus = $Window.FindName('TxtLoadingStatus') # ─── Internal state hashtable ───────────────────────────────────────────── # Reference type: all event handler closures share the same object. $State = @{ PasswordVisible = $false PlainPassword = [string]$null ClipTimer = $null Session = $null # [LapsSession] } # ─── Helper: update status bar ──────────────────────────────────────────── function Update-Status { param([string]$Text, [LapsPermissionLevel]$Level = [LapsPermissionLevel]::None) $LblStatus.Text = $Text if ($Level -eq [LapsPermissionLevel]::Full) { $DisplayNames = if ($State.Session -and $State.Session.ActiveRoles.Count -gt 0) { $AuRoleNames = @($State.Session.AuScopedRoles | ForEach-Object { $_.RoleName }) $State.Session.ActiveRoles | ForEach-Object { if ($AuRoleNames -contains $_) { "$_ (AU-scoped)" } else { $_ } } } else { $null } $LblPermissionLevel.Text = if ($DisplayNames) { "[OK] Full access — $($DisplayNames -join ', ')" } else { '[OK] Full access' } $LblPermissionLevel.Foreground = [System.Windows.Media.SolidColorBrush]::new( [System.Windows.Media.Color]::FromRgb(0x10, 0x7C, 0x10)) } elseif ($Level -eq [LapsPermissionLevel]::Metadata) { $RoleNames = if ($State.Session -and $State.Session.ActiveRoles.Count -gt 0) { $State.Session.ActiveRoles -join ', ' } else { $null } $LblPermissionLevel.Text = if ($State.Session -and $State.Session.PimEligibleRoles.Count -gt 0) { '[!] Metadata only — activate PIM role for full access' } elseif ($RoleNames) { "[!] Metadata only — $RoleNames" } else { '[!] Metadata only (no password access)' } $LblPermissionLevel.Foreground = [System.Windows.Media.SolidColorBrush]::new( [System.Windows.Media.Color]::FromRgb(0xCA, 0x75, 0x00)) } else { # None or no level provided if ($State.Session -and $State.Session.PimEligibleRoles.Count -gt 0) { $LblPermissionLevel.Text = '[!] No active LAPS role — PIM-eligible roles detected, activate first' } elseif ($Text -ne '') { $LblPermissionLevel.Text = '[x] No LAPS permissions' } else { $LblPermissionLevel.Text = '' } $LblPermissionLevel.Foreground = [System.Windows.Media.SolidColorBrush]::new( [System.Windows.Media.Color]::FromRgb(0xC4, 0x2B, 0x1C)) } } # ─── Helper: clipboard auto-clear timer ─────────────────────────────────── function Start-ClipboardClearTimer { param([int]$Seconds = 30) if ($State.ClipTimer) { $State.ClipTimer.Stop() $State.ClipTimer = $null } $State.ClipTimer = [System.Windows.Threading.DispatcherTimer]::new() $State.ClipTimer.Interval = [TimeSpan]::FromSeconds($Seconds) $State.ClipTimer.Add_Tick({ [System.Windows.Clipboard]::Clear() $State.ClipTimer.Stop() $State.ClipTimer = $null Update-Status "Clipboard cleared automatically after $Seconds seconds." }) $State.ClipTimer.Start() } # ─── Helper: force WPF to process pending render/layout work ───────────── function Invoke-DispatcherFlush { $Window.Dispatcher.Invoke( [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background ) } # ─── Helper: Search Devices ─────────────────────────────────────────────── function Invoke-DeviceSearch { [string]$Query = $TxtSearch.Text.Trim() $GridDevices.ItemsSource = $null $BtnGetCredentials.IsEnabled = $false $LblSelectedDevice.Text = 'Searching...' $PnlLoading.Visibility = [System.Windows.Visibility]::Visible $TxtLoadingStatus.Text = 'Loading devices...' $PrgLoading.Visibility = [System.Windows.Visibility]::Visible if ($Query) { Update-Status "Searching for devices matching '$Query'..." } else { Update-Status 'Fetching all managed devices...' } Invoke-DispatcherFlush try { [string]$SelectFields = 'id,azureADDeviceId,deviceName,operatingSystem,osVersion,lastSyncDateTime,managementState' if ($Query) { [string]$SafeQuery = $Query -replace "'", "''" [string]$DeviceUri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=startsWith(deviceName,'$SafeQuery')&`$select=$SelectFields" } else { [string]$DeviceUri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$select=$SelectFields" } $RawDevices = Invoke-MgGraphPagedRequest -Uri $DeviceUri -OnPageLoaded { param([int]$Count) $TxtLoadingStatus.Text = "Loading devices... ($Count loaded)" Invoke-DispatcherFlush } if ($RawDevices.Count -eq 0) { $PnlLoading.Visibility = [System.Windows.Visibility]::Collapsed $PrgLoading.Visibility = [System.Windows.Visibility]::Collapsed if ($Query) { Update-Status "No devices found for '$Query'." } else { Update-Status 'No Intune-managed devices found in the tenant.' } $LblSelectedDevice.Text = 'No results' return } # ── Enrich with LAPS Active status ───────────────────────────────── # Only attempt the bulk call when EffectiveLevel = Full. # The /directory/deviceLocalCredentials endpoint requires Read.All scope; # attempting it with Metadata level causes a 403 storm. [int]$TotalDevices = $RawDevices.Count $TxtLoadingStatus.Text = "Checking LAPS status for $TotalDevices device(s)..." Invoke-DispatcherFlush $LapsActiveNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) [bool]$LapsLookupSucceeded = $false if ($State.Session -and $State.Session.EffectiveLevel -eq [LapsPermissionLevel]::Full) { try { [string]$LapsUri = "https://graph.microsoft.com/v1.0/directory/deviceLocalCredentials?`$select=id,deviceName" $LapsEntries = Invoke-MgGraphPagedRequest -Uri $LapsUri foreach ($Entry in $LapsEntries) { if (-not [string]::IsNullOrEmpty($Entry.deviceName)) { $null = $LapsActiveNames.Add($Entry.deviceName) } } $LapsLookupSucceeded = $true } catch { Write-Verbose "Could not retrieve LAPS device list: $_" } } $EnrichedDevices = @(foreach ($Dev in $RawDevices) { [PSCustomObject]@{ DeviceId = $Dev.azureADDeviceId DeviceName = $Dev.deviceName OperatingSystem = $Dev.operatingSystem OsVersion = $Dev.osVersion ManagementState = $Dev.managementState LastSyncDateTime = $Dev.lastSyncDateTime LapsActive = if ($State.Session -and $State.Session.EffectiveLevel -ne [LapsPermissionLevel]::Full) { 'N/A' } elseif (-not $LapsLookupSucceeded) { '?' } elseif ($LapsActiveNames.Contains($Dev.deviceName)) { 'Yes' } else { 'No' } } }) $GridDevices.ItemsSource = $EnrichedDevices $PnlLoading.Visibility = [System.Windows.Visibility]::Collapsed $PrgLoading.Visibility = [System.Windows.Visibility]::Collapsed Update-Status "$TotalDevices device(s) found." $LblSelectedDevice.Text = '- select a device' } catch { $PnlLoading.Visibility = [System.Windows.Visibility]::Collapsed $PrgLoading.Visibility = [System.Windows.Visibility]::Collapsed Update-Status "Search failed: $_" $LblSelectedDevice.Text = '- select a device' } } # ─── EVENT: Sign In ─────────────────────────────────────────────────────── $BtnConnect.Add_Click({ Update-Status 'Signing in to Microsoft Graph...' try { $State.Session = Connect-IntuneLaps Update-Status "Signed in as: $($State.Session.Account)" -Level $State.Session.EffectiveLevel $BtnSearch.IsEnabled = $true $BtnDisconnect.IsEnabled = $true Invoke-DeviceSearch } catch { Update-Status "Sign in failed: $_" } }) # ─── EVENT: Sign Out ────────────────────────────────────────────────────── $BtnDisconnect.Add_Click({ Update-Status 'Signing out...' try { Disconnect-IntuneLaps -ErrorAction SilentlyContinue $State.Session = $null Update-Status 'Not connected - click Sign In to authenticate' $BtnSearch.IsEnabled = $false $BtnDisconnect.IsEnabled = $false $GridDevices.ItemsSource = $null $LblSelectedDevice.Text = '- select a device' $BtnGetCredentials.IsEnabled = $false $TxtUsername.Text = '' $PwdPassword.Password = '' $TxtPassword.Text = '' $State.PlainPassword = $null } catch { Update-Status "Sign out failed: $_" } }) # ─── EVENT: Search ──────────────────────────────────────────────────────── $BtnSearch.Add_Click({ Invoke-DeviceSearch }) # Support pressing Enter in search box $TxtSearch.Add_KeyDown({ param($s, $e) if ($e.Key -eq [System.Windows.Input.Key]::Return) { $BtnSearch.RaiseEvent([System.Windows.RoutedEventArgs]::new([System.Windows.Controls.Button]::ClickEvent)) } }) # ─── EVENT: Device row selected ─────────────────────────────────────────── $GridDevices.Add_SelectionChanged({ if ($null -ne $GridDevices.SelectedItem) { [string]$Name = $GridDevices.SelectedItem.DeviceName $LblSelectedDevice.Text = "- $Name" $BtnGetCredentials.IsEnabled = $true $TxtUsername.Text = '' $PwdPassword.Password = '' $TxtPassword.Text = '' $State.PlainPassword = $null $BtnCopyUsername.IsEnabled = $false $BtnCopyPassword.IsEnabled = $false $BtnTogglePassword.IsEnabled = $false } }) # ─── EVENT: Load Credentials ────────────────────────────────────────────── $BtnGetCredentials.Add_Click({ if ($null -eq $GridDevices.SelectedItem) { return } [string]$DeviceId = $GridDevices.SelectedItem.DeviceId Update-Status 'Retrieving LAPS credentials...' try { $CredResult = Get-IntuneLapsCredential -DeviceId $DeviceId if ($null -eq $CredResult) { Update-Status 'No LAPS record found for this device. Ensure LAPS is configured and the device has checked in recently.' return } [bool]$HasCreds = ($null -ne $CredResult.Credentials -and $CredResult.Credentials.Count -gt 0) if ($HasCreds) { # Display the newest credential (index 0 — sorted desc by BackupDateTime) # When multiple accounts exist, a count note is shown in the status bar. $Latest = $CredResult.Credentials[0] $TxtUsername.Text = $Latest.AccountName $State.PlainPassword = $Latest.Password $PwdPassword.Password = $Latest.Password $TxtPassword.Text = $Latest.Password $BtnCopyUsername.IsEnabled = (-not [string]::IsNullOrEmpty($Latest.AccountName)) $BtnCopyPassword.IsEnabled = $true $BtnTogglePassword.IsEnabled = $true [string]$CountNote = if ($CredResult.Credentials.Count -gt 1) { " ($($CredResult.Credentials.Count) accounts — showing newest)" } else { '' } Update-Status "Credentials loaded for: $($CredResult.DeviceName)$CountNote" -Level $CredResult.EffectiveLevel } else { $TxtUsername.Text = '' $PwdPassword.Password = '' $TxtPassword.Text = '' $State.PlainPassword = $null $BtnCopyUsername.IsEnabled = $false $BtnCopyPassword.IsEnabled = $false $BtnTogglePassword.IsEnabled = $false if ($CredResult.EffectiveLevel -eq [LapsPermissionLevel]::Full) { [string]$AuNote = if ($State.Session -and $State.Session.HasAuScopedRoles()) { ' Your role is AU-scoped — this device may be outside your Administrative Unit.' } else { '' } Update-Status "No credentials found for this device.$AuNote" -Level $CredResult.EffectiveLevel } else { Update-Status 'Metadata loaded — no password access with current permissions.' -Level $CredResult.EffectiveLevel } } } catch { Update-Status "Failed to load credentials: $_" } }) # ─── EVENT: Toggle password visibility ──────────────────────────────────── $BtnTogglePassword.Add_Click({ $State.PasswordVisible = -not $State.PasswordVisible if ($State.PasswordVisible) { $PwdPassword.Visibility = [System.Windows.Visibility]::Collapsed $TxtPassword.Visibility = [System.Windows.Visibility]::Visible $Window.FindName('TxtToggleIcon').Text = '[Hide]' } else { $TxtPassword.Visibility = [System.Windows.Visibility]::Collapsed $PwdPassword.Visibility = [System.Windows.Visibility]::Visible $Window.FindName('TxtToggleIcon').Text = '[Show]' } }) # ─── EVENT: Copy Username ───────────────────────────────────────────────── $BtnCopyUsername.Add_Click({ if (-not [string]::IsNullOrEmpty($TxtUsername.Text)) { [System.Windows.Clipboard]::SetText($TxtUsername.Text) Update-Status 'Username copied to clipboard.' } }) # ─── EVENT: Copy Password ───────────────────────────────────────────────── $BtnCopyPassword.Add_Click({ if (-not [string]::IsNullOrEmpty($State.PlainPassword)) { [System.Windows.Clipboard]::SetText($State.PlainPassword) Update-Status 'Password copied to clipboard - will be cleared in 30 seconds.' Start-ClipboardClearTimer -Seconds 30 } }) # ─── Check existing Graph session on open ───────────────────────────────── # WPF STA threads have their own module scope — $script:CurrentSession from the # calling thread is not inherited. Build-LapsSession reconstructs the session # from the existing Graph token (no browser) if already authenticated. $Window.Add_Loaded({ try { $Ctx = Get-MgContext -ErrorAction SilentlyContinue if ($Ctx) { $State.Session = Build-LapsSession Update-Status "Already signed in as: $($State.Session.Account)" -Level $State.Session.EffectiveLevel $BtnSearch.IsEnabled = $true $BtnDisconnect.IsEnabled = $true Invoke-DeviceSearch } else { Update-Status 'Not connected — click Sign In to authenticate.' $BtnSearch.IsEnabled = $false $BtnDisconnect.IsEnabled = $false } } catch { Update-Status 'Not connected — click Sign In to authenticate.' $BtnSearch.IsEnabled = $false $BtnDisconnect.IsEnabled = $false } }) # ─── Clear clipboard and disconnect on window close ────────────────────── $Window.Add_Closing({ if ($State.ClipTimer) { $State.ClipTimer.Stop() } [System.Windows.Clipboard]::Clear() Disconnect-IntuneLaps -ErrorAction SilentlyContinue }) # ─── Show the window ────────────────────────────────────────────────────── $null = $Window.ShowDialog() } } |