Files
MDM-ODA/MDM-ODA V0.66.ps1
T
2026-03-29 03:35:20 +01:00

14296 lines
779 KiB
PowerShell

# -- param must be first statement for PS5 compatibility --
param([switch]$SkipUpgrade)
# ============================================================
# Entra Group Manager - Orchestrator Launcher
# Author : Satish Singhi
# Description : PS5-compatible launcher. Installs/upgrades
# PowerShell 7 via winget (user scope, no admin)
# then relaunches self in PS7 to show the GUI.
#
# Distribute alongside: Orchestrator_GUI.ps1
# Users run this file only. Orchestrator_GUI.ps1 is copied
# to a staging directory and launched via PS7.
#
# -- DEVELOPER CONFIG -----------------------------------------
# ============================================================
# ============================================================
#region ORCHESTRATOR CONFIG
# ============================================================
$OrcConfig = @{
StagingRoot = "C:\Logs\MDM-ODA"
StagingPath = "C:\Logs\MDM-ODA\Staging"
LogPath = "C:\Logs\MDM-ODA\Logs"
ToolFileName = "MDM-ODA.ps1"
}
# Ensure all folders exist upfront
foreach ($dir in @($OrcConfig.StagingRoot, $OrcConfig.StagingPath, $OrcConfig.LogPath)) {
if (-not (Test-Path $dir)) {
try { New-Item -ItemType Directory -Path $dir -Force | Out-Null } catch {}
}
}
#endregion ORCHESTRATOR CONFIG
# ============================================================
# ============================================================
# PS5 SECTION - Everything below here runs in PS5.
# PS7 code begins after the exit statement further down.
# PS5 exits before reaching the PS7 section so PS7-only
# syntax there never causes a parse error in PS5.
# ============================================================
if ($PSVersionTable.PSVersion.Major -lt 7) {
Write-Host ""
Write-Host " MDM ODA Tool" -ForegroundColor Cyan
Write-Host " Checking PowerShell 7..." -ForegroundColor Cyan
Write-Host ""
# -- Verify winget is available -------------------------------------------
$winget = Get-Command winget -ErrorAction SilentlyContinue
if (-not $winget) {
Write-Host " winget is not available on this device." -ForegroundColor Red
Write-Host " Please install PowerShell 7 from:" -ForegroundColor Yellow
Write-Host " https://github.com/PowerShell/PowerShell/releases/latest" -ForegroundColor Cyan
Write-Host ""
Read-Host " Press Enter to exit"
exit 1
}
# -- PS7 detection: validate real executable (not a Windows Store stub) -----
Write-Host " Checking PowerShell 7..." -ForegroundColor Gray
# Windows 11/10 places a tiny stub at WindowsApps\pwsh.exe even when PS7 is NOT
# installed — Test-Path returns $true but running it just opens the Store.
# Filter stubs out by requiring file size > 50 KB (real pwsh.exe is ~200 KB).
function Test-RealPwsh {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
if (-not (Test-Path $Path -ErrorAction SilentlyContinue)) { return $false }
$item = Get-Item $Path -ErrorAction SilentlyContinue
if (-not $item) { return $false }
if ($item.Length -lt 51200) {
Write-Host " Skipping stub at: $Path (size $($item.Length) bytes)" -ForegroundColor DarkGray
return $false
}
return $true
}
# Probe in priority order:
# 1. System-wide MSI (most reliable on corporate devices)
# 2. User-scope winget non-Store
# 3. PATH (covers custom/Chocolatey/SCCM installs)
# 4. WindowsApps (Store install — validated last because stubs live here)
$probePaths = [System.Collections.Generic.List[string]]::new()
$probePaths.Add('C:\Program Files\PowerShell\7\pwsh.exe') # system MSI
$probePaths.Add((Join-Path $env:LOCALAPPDATA 'Microsoft\PowerShell\7\pwsh.exe')) # winget user-scope
$pwshCmd = Get-Command pwsh -All -ErrorAction SilentlyContinue
if ($pwshCmd) {
foreach ($p in ($pwshCmd | Where-Object { $_.Source -notlike '*WindowsApps*' })) {
if (-not $probePaths.Contains($p.Source)) { $probePaths.Add($p.Source) }
}
}
$probePaths.Add((Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\pwsh.exe')) # Store (checked LAST; stubs are filtered by size)
$pwsh7Detected = $null
foreach ($p in $probePaths) {
if (Test-RealPwsh $p) { $pwsh7Detected = $p; break }
}
$ps7Installed = $null -ne $pwsh7Detected
if (-not $ps7Installed) {
Write-Host " PowerShell 7 not found. Installing (user-scope, no admin required)..." -ForegroundColor Yellow
Write-Host " This may take 1-2 minutes. Please wait." -ForegroundColor Gray
& winget install --id Microsoft.PowerShell --exact --scope user --source winget `
--accept-source-agreements --accept-package-agreements --silent
Start-Sleep -Seconds 3
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","User") + ";" +
[System.Environment]::GetEnvironmentVariable("Path","Machine")
# Re-probe after install (using same stub-safe validator)
foreach ($p in $probePaths) {
if (Test-RealPwsh $p) { $pwsh7Detected = $p; break }
}
$ps7Installed = $null -ne $pwsh7Detected
if ($ps7Installed) {
Write-Host " PowerShell 7 installed at: $pwsh7Detected" -ForegroundColor Green
} else {
Write-Host " Installation may have succeeded but path not yet visible." -ForegroundColor Yellow
Write-Host " Please close this window and open a new terminal, then run the script again." -ForegroundColor Yellow
Read-Host " Press Enter to exit"
exit 1
}
} else {
$scopeLabel = if ($pwsh7Detected -like "*LocalAppData*") { "(user-scope)" } else { "(system-wide)" }
Write-Host " PowerShell 7 detected $scopeLabel : $pwsh7Detected" -ForegroundColor Green
}
# -- Upgrade to latest stable every launch (skip previews) -------------------
if (-not $SkipUpgrade) {
Write-Host " Checking for updates (stable only)..." -ForegroundColor Gray
$showOutput = & winget show --id Microsoft.PowerShell --exact --source winget 2>&1
$verLine = $showOutput | Where-Object { $_ -match '^\s*Version\s*:' } | Select-Object -First 1
$latestStable = $null
if ($verLine -and $verLine -match ':\s*([\d\.]+)') {
$rawVer = $Matches[1]
if ($rawVer -notmatch 'preview|rc|beta|alpha') { $latestStable = $rawVer }
}
if ($latestStable) {
Write-Host " Latest stable PS7: $latestStable" -ForegroundColor Gray
$upgradeOut = & winget upgrade --id Microsoft.PowerShell --exact --scope user `
--source winget --accept-source-agreements --accept-package-agreements --silent 2>&1
$done = $upgradeOut | Where-Object {
$_ -match 'No applicable upgrade' -or
$_ -match 'already installed' -or
$_ -match 'Successfully installed'
}
if ($done) {
Write-Host " PowerShell 7 is up to date (v$latestStable)." -ForegroundColor Green
} else {
Write-Host " Update applied." -ForegroundColor Green
}
}
}
# -- Find pwsh.exe and relaunch in PS7 ---------------------------------------
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","User") + ";" +
[System.Environment]::GetEnvironmentVariable("Path","Machine")
# Use already-detected path; fall back to re-probe if needed
$pwsh7 = $pwsh7Detected
if (-not $pwsh7) {
$pwshAll = Get-Command pwsh -All -ErrorAction SilentlyContinue
if ($pwshAll) {
foreach ($p in ($pwshAll | Where-Object { $_.Source -notlike '*WindowsApps*' })) {
if (Test-Path $p.Source -ErrorAction SilentlyContinue) { $pwsh7 = $p.Source; break }
}
}
}
if ($pwsh7) {
Write-Host " Staging and launching in PowerShell 7 ($pwsh7)..." -ForegroundColor Green
# Hide this console window before handing off
try {
Add-Type -TypeDefinition @"
using System.Runtime.InteropServices;
public class OrcLaunchConsole {
[DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int nCmdShow);
[DllImport("kernel32.dll")] public static extern System.IntPtr GetConsoleWindow();
}
"@ -ErrorAction SilentlyContinue
$null = [OrcLaunchConsole]::ShowWindow([OrcLaunchConsole]::GetConsoleWindow(), 0)
} catch {}
# Extract PS7 payload from the block-comment section embedded in this script.
# PS5 never compiled that code — it was just a comment token to the PS5 tokenizer.
$scriptText = [System.IO.File]::ReadAllText($PSCommandPath, [System.Text.Encoding]::UTF8)
$startTag = '<#__PS7' + '_PAYLOAD_START__'
$endTag = '__PS7_PAYLOAD' + '_END__#>'
$startOffset = $scriptText.IndexOf($startTag)
$endOffset = $scriptText.IndexOf($endTag)
if ($startOffset -lt 0 -or $endOffset -le $startOffset) {
Write-Host " ERROR: PS7 payload boundary markers not found in script." -ForegroundColor Red
Read-Host " Press Enter to exit"
exit 1
}
$payloadText = $scriptText.Substring($startOffset + $startTag.Length, $endOffset - ($startOffset + $startTag.Length)).TrimStart("`r", "`n")
$stagingScript = Join-Path $OrcConfig.StagingPath $OrcConfig.ToolFileName
# Ensure staging folders exist
foreach ($dir in @($OrcConfig.StagingRoot, $OrcConfig.StagingPath, $OrcConfig.LogPath)) {
if (-not (Test-Path $dir)) {
try { New-Item -ItemType Directory -Path $dir -Force | Out-Null } catch {}
}
}
# Write PS7 payload to staging directory as MDM-ODA.ps1
[System.IO.File]::WriteAllText($stagingScript, $payloadText, [System.Text.Encoding]::UTF8)
Write-Host " Staged: $stagingScript" -ForegroundColor Green
# Launch staged PS7 script — hidden console window, wait for exit
Start-Process -FilePath $pwsh7 `
-ArgumentList "-ExecutionPolicy Bypass -STA -File `"$stagingScript`"" `
-WindowStyle Hidden `
-Wait
exit 0
} else {
Write-Host ""
Write-Host " Could not locate pwsh.exe." -ForegroundColor Red
Write-Host " Please close this window, open a new terminal and run the script again." -ForegroundColor Yellow
Write-Host ""
Read-Host " Press Enter to exit"
exit 1
}
}
# ============================================================
# PS7 PAYLOAD — embedded as a PS5 block comment so PS5 never
# compiles this code. The PS5 launcher reads this file at
# runtime, extracts the payload between the sentinel tags,
# stages it as MDM-ODA.ps1, and launches it with pwsh.exe.
# DO NOT edit or remove the sentinel tags below.
# ============================================================
<#__PS7_PAYLOAD_START__
# ── Orchestrator config ─────────────────────────────────────────────────────
# Defined here so MDM-ODA.ps1 is self-contained when staged and run directly.
# Values must match the $OrcConfig in the PS5 launcher section above.
$OrcConfig = @{
StagingRoot = 'C:\Logs\MDM-ODA'
StagingPath = 'C:\Logs\MDM-ODA\Staging'
LogPath = 'C:\Logs\MDM-ODA\Logs'
ToolFileName = 'MDM-ODA.ps1'
}
# ── Startup diagnostic log (captures fatal errors before the form appears) ─
$script:OrcDiagLog = Join-Path $OrcConfig.LogPath ("OrcDiag_" + (Get-Date -f 'yyyyMMdd_HHmmss') + ".log")
function Write-OrcDiag { param([string]$Msg)
try { [System.IO.Directory]::CreateDirectory($OrcConfig.LogPath)|Out-Null
[System.IO.File]::AppendAllText($script:OrcDiagLog,"[$(Get-Date -f 'HH:mm:ss')] $Msg`n",[System.Text.Encoding]::UTF8) } catch {} }
trap { Write-OrcDiag "FATAL: $($_.Exception.Message)`n$($_.ScriptStackTrace)"; break }
Write-OrcDiag "MDM-ODA.ps1 started. PSCommandPath=$PSCommandPath"
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
# -- Read WindowTitle from the embedded GUI section ----------------------------
$toolTitle = "Launching..."
try {
$selfContent = [System.IO.File]::ReadAllText($PSCommandPath, [System.Text.Encoding]::UTF8)
if ($selfContent -match 'WindowTitle\s*=\s*"([^"]+)"') { $toolTitle = $Matches[1] }
} catch {}
# -- Colours ------------------------------------------------------------------
$C = @{
Header = [System.Drawing.ColorTranslator]::FromHtml('#007A00')
HeaderFg = [System.Drawing.Color]::White
BtnGreen = [System.Drawing.ColorTranslator]::FromHtml('#007A00')
BtnFg = [System.Drawing.Color]::White
Bg = [System.Drawing.ColorTranslator]::FromHtml('#F8FBF8')
LogBg = [System.Drawing.ColorTranslator]::FromHtml('#1A1F2E')
LogFg = [System.Drawing.ColorTranslator]::FromHtml('#9DB8D8')
Success = [System.Drawing.ColorTranslator]::FromHtml('#5DC98B')
Warning = [System.Drawing.ColorTranslator]::FromHtml('#F5A623')
Error = [System.Drawing.ColorTranslator]::FromHtml('#F06C6C')
Info = [System.Drawing.ColorTranslator]::FromHtml('#C8DEF5')
}
# -- Build form ----------------------------------------------------------------
$form = [System.Windows.Forms.Form]::new()
$form.Text = $toolTitle
$form.Width = 580
$form.Height = 380
$form.MinimumSize = [System.Drawing.Size]::new(480, 300)
$form.StartPosition = 'CenterScreen'
$form.BackColor = $C.Bg
$form.Font = [System.Drawing.Font]::new('Segoe UI', 10)
$form.FormBorderStyle = 'FixedDialog'
$form.MaximizeBox = $false
$header = [System.Windows.Forms.Panel]::new()
$header.Dock = 'Top'
$header.Height = 52
$header.BackColor = $C.Header
$headerLbl = [System.Windows.Forms.Label]::new()
$headerLbl.Text = $toolTitle
$headerLbl.ForeColor = $C.HeaderFg
$headerLbl.Font = [System.Drawing.Font]::new('Segoe UI', 13, [System.Drawing.FontStyle]::Bold)
$headerLbl.AutoSize = $true
$headerLbl.Location = [System.Drawing.Point]::new(16, 13)
$header.Controls.Add($headerLbl)
$content = [System.Windows.Forms.TableLayoutPanel]::new()
$content.Dock = 'Fill'
$content.Padding = [System.Windows.Forms.Padding]::new(14, 10, 14, 10)
$content.BackColor = $C.Bg
$content.ColumnCount = 1
$content.RowCount = 3
$null = $content.ColumnStyles.Add([System.Windows.Forms.ColumnStyle]::new([System.Windows.Forms.SizeType]::Percent, 100))
$null = $content.RowStyles.Add([System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Absolute, 30))
$null = $content.RowStyles.Add([System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Percent, 100))
$null = $content.RowStyles.Add([System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Absolute, 44))
$form.Controls.Add($content)
$form.Controls.Add($header)
$statusLbl = [System.Windows.Forms.Label]::new()
$statusLbl.Text = 'Initialising...'
$statusLbl.Dock = 'Fill'
$statusLbl.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
$statusLbl.ForeColor = [System.Drawing.ColorTranslator]::FromHtml('#1A1F2E')
$content.Controls.Add($statusLbl, 0, 0)
$logBox = [System.Windows.Forms.RichTextBox]::new()
$logBox.ReadOnly = $true
$logBox.BackColor = $C.LogBg
$logBox.ForeColor = $C.LogFg
$logBox.Font = [System.Drawing.Font]::new('Consolas', 9)
$logBox.BorderStyle = 'None'
$logBox.Dock = 'Fill'
$content.Controls.Add($logBox, 0, 1)
$btnPanel = [System.Windows.Forms.Panel]::new()
$btnPanel.Dock = 'Fill'
$btnPanel.BackColor = $C.Bg
$content.Controls.Add($btnPanel, 0, 2)
$btnClose = [System.Windows.Forms.Button]::new()
$btnClose.Text = 'Close'
$btnClose.Width = 100
$btnClose.Height = 32
$btnClose.Anchor = 'Right,Bottom'
$btnClose.Location = [System.Drawing.Point]::new(440, 6)
$btnClose.BackColor = $C.BtnGreen
$btnClose.ForeColor = $C.BtnFg
$btnClose.FlatStyle = 'Flat'
$btnClose.FlatAppearance.BorderSize = 0
$btnClose.Visible = $false
$btnClose.Add_Click({ $form.Close() })
$btnPanel.Controls.Add($btnClose)
function OLog {
param([string]$Line, [string]$Level = 'Info')
$col = switch ($Level) {
'Success' { $C.Success } 'Warning' { $C.Warning }
'Error' { $C.Error } 'Action' { $C.Info }
default { $C.LogFg }
}
$logBox.SelectionStart = $logBox.TextLength
$logBox.SelectionLength = 0
$logBox.SelectionColor = $col
$logBox.AppendText("[$((Get-Date).ToString('HH:mm:ss'))] $Line`n")
$logBox.ScrollToCaret()
$form.Refresh()
}
function OStatus { param([string]$T) $statusLbl.Text = $T; $form.Refresh() }
$form.Add_Shown({
# -- Extract GUI tool content FIRST (before clearing staging) ----------------
# MDM-ODA.ps1 runs FROM the staging directory — read it before deleting it.
OStatus 'Extracting GUI tool...'
$toolPath = Join-Path $OrcConfig.StagingPath $OrcConfig.ToolFileName
$beginMark = '#' + '=' * 10 + ' BEGIN_GUI_TOOL_CONTENT ' + '=' * 10
$endMark = '#' + '=' * 10 + ' END_GUI_TOOL_CONTENT ' + '=' * 11
$guiLines = $null
try {
$selfLines = [System.IO.File]::ReadAllLines($PSCommandPath, [System.Text.Encoding]::UTF8)
$inBlock = $false
$guiLines = [System.Collections.Generic.List[string]]::new()
foreach ($line in $selfLines) {
if ($line -eq $beginMark) { $inBlock = $true; continue }
if ($line -eq $endMark) { $inBlock = $false; break }
if ($inBlock) { $guiLines.Add($line) }
}
if ($guiLines.Count -eq 0) { throw 'GUI tool section not found in orchestrator.' }
OLog ('GUI tool read (' + $guiLines.Count + ' lines).') 'Success'
Write-OrcDiag "GUI tool read: $($guiLines.Count) lines"
} catch {
OLog "Extraction failed: $($_.Exception.Message)" 'Error'
OStatus 'Error - see log.'
Write-OrcDiag "Extraction failed: $($_.Exception.Message)"
$btnClose.Visible = $true; return
}
# -- Prepare staging (AFTER reading self — Remove-Item deletes PSCommandPath) --
OStatus 'Preparing staging...'
OLog "Staging: $($OrcConfig.StagingPath)" 'Info'
try {
if (Test-Path $OrcConfig.StagingPath) {
Remove-Item $OrcConfig.StagingPath -Recurse -Force -ErrorAction Stop
}
New-Item -ItemType Directory -Path $OrcConfig.StagingPath -Force | Out-Null
OLog 'Staging ready.' 'Success'
} catch {
OLog "Staging failed: $($_.Exception.Message)" 'Error'
OStatus 'Error - see log.'
$btnClose.Visible = $true; return
}
# -- Write GUI tool to staging ---------------------------------------------
try {
[System.IO.File]::WriteAllText($toolPath, ($guiLines -join "`n"), [System.Text.Encoding]::UTF8)
OLog ('GUI tool staged (' + $guiLines.Count + ' lines).') 'Success'
Write-OrcDiag "GUI tool written to: $toolPath"
} catch {
OLog "Write to staging failed: $($_.Exception.Message)" 'Error'
OStatus 'Error - see log.'
$btnClose.Visible = $true; return
}
# -- Launch GUI tool -------------------------------------------------------
OStatus "Launching $toolTitle..."
$pwsh7 = (Get-Process -Id $PID).MainModule.FileName
OLog "Launching via: $pwsh7" 'Action'
# Write orchestrator launch log for diagnostics
$orcLogPath = Join-Path $OrcConfig.LogPath ("OrcLaunch_{0}.log" -f (Get-Date -f 'yyyyMMdd_HHmmss'))
Set-Content -Path $orcLogPath -Value "Orchestrator Launch Log" -Encoding UTF8
Add-Content -Path $orcLogPath -Value "========================" -Encoding UTF8
Add-Content -Path $orcLogPath -Value ("Timestamp : " + (Get-Date)) -Encoding UTF8
Add-Content -Path $orcLogPath -Value ("Host : " + $env:COMPUTERNAME) -Encoding UTF8
Add-Content -Path $orcLogPath -Value ("User : " + $env:USERNAME) -Encoding UTF8
Add-Content -Path $orcLogPath -Value ("PS7 Path : " + $pwsh7) -Encoding UTF8
Add-Content -Path $orcLogPath -Value ("Tool Path : " + $toolPath) -Encoding UTF8
Add-Content -Path $orcLogPath -Value ("Tool Lines : " + $guiLines.Count) -Encoding UTF8
Add-Content -Path $orcLogPath -Value ("Staging : " + $OrcConfig.StagingPath) -Encoding UTF8
Add-Content -Path $orcLogPath -Value ("Log Dir : " + $OrcConfig.LogPath) -Encoding UTF8
OLog "Launch log: $orcLogPath" 'Info'
# Transcript path for the GUI tool startup diagnostics
$transcriptPath = Join-Path $OrcConfig.LogPath ("EGM_Transcript_{0}.log" -f (Get-Date -f 'yyyyMMdd_HHmmss'))
# Pass log paths to GUI tool via environment variables.
# Note: param() blocks in mid-file cause PS5 parse errors, so env vars are used.
$env:EGM_LogFolder = $OrcConfig.LogPath
$env:EGM_Transcript = $transcriptPath
$launchArgs = "-ExecutionPolicy Bypass -STA -File `"$toolPath`""
try {
# WindowStyle Normal (visible) so startup crashes surface in a console window.
# Switch to Hidden once the tool is confirmed stable.
$proc = Start-Process $pwsh7 -ArgumentList $launchArgs -WindowStyle Hidden -PassThru
Add-Content -Path $orcLogPath -Value ("Process ID : " + $proc.Id) -Encoding UTF8
OLog ("Tool launched. PID=" + $proc.Id + " Transcript=" + $transcriptPath) 'Success'
OStatus ($toolTitle + " launched.")
# Hide orchestrator console window now that the GUI tool is running
try {
if (-not ([System.Management.Automation.PSTypeName]'OrcConsole').Type) {
Add-Type -TypeDefinition @"
using System.Runtime.InteropServices;
public class OrcConsole {
[DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int nCmdShow);
[DllImport("kernel32.dll")] public static extern System.IntPtr GetConsoleWindow();
}
"@
}
$null = [OrcConsole]::ShowWindow([OrcConsole]::GetConsoleWindow(), 0)
} catch {}
Start-Sleep -Milliseconds 800
$form.Close()
} catch {
$errMsg = "Launch failed: " + $_.Exception.Message
Add-Content -Path $orcLogPath -Value ("ERROR: " + $errMsg) -Encoding UTF8
OLog $errMsg 'Error'
OStatus 'Launch failed. Check log.'
$btnClose.Visible = $true
}
})
$form.ShowDialog() | Out-Null
exit 0
#========== BEGIN_GUI_TOOL_CONTENT ==========
# Note: #Requires omitted - orchestrator guarantees PS7
# Module requirements checked at runtime by the prereq window (see #region PREREQ CHECK)
# Values passed from orchestrator via environment variables (no param block -
# a param() in the middle of the orchestrator file causes PS5 parse errors)
$OverrideLogFolder = if ($env:EGM_LogFolder) { $env:EGM_LogFolder } else { "" }
$TranscriptPath = if ($env:EGM_Transcript) { $env:EGM_Transcript } else { "" }
# -- Early-crash diagnostic: written to TEMP before DevConfig initialised --
$_earlyLog = Join-Path $env:TEMP ("MDM-ODA_Early_{0}.log" -f (Get-Date -f 'yyyyMMdd_HHmmss'))
function Write-EarlyLog { param([string]$Msg) try { Add-Content -Path $_earlyLog -Value "[$(Get-Date -f 'HH:mm:ss')] $Msg" -Encoding UTF8 } catch {} }
Write-EarlyLog "GUI tool started. PS=$($PSVersionTable.PSVersion) User=$($env:USERNAME)"
# .SYNOPSIS
# MDM On-Demand Actions (MDM-ODA) - PowerShell 7 & WPF tool for Entra ID and Intune on-demand operations.
#
# .DESCRIPTION
# A single-file PowerShell 7+ WPF GUI application for Entra ID and Intune on-demand
# operations with an embedded WPF interface. Features include:
# - Group Management: Search, list members, create, rename, compare, bulk owners,
# add user devices, find common/distinct, dynamic membership, object membership
# - Device Management: Device info, Intune policy assignments across all policy types
# - Productivity: Session notes, keyword filter, verbose logging, XLSX export
# - Security: OAuth 2.0 delegated auth, read-only API scopes by default,
# validation-before-commit for all write operations, least privilege enforcement
# All write operations require mandatory Entra validation + user confirmation before execution.
# Real-time verbose logging in the lower pane, simultaneously written to a local log file.
#
# .NOTES
# Author : Satish Singhi
# Version : 0.66
# Requires : PowerShell 7+, Microsoft.Graph SDK
# Auth : Azure App Registration - Delegated permissions (interactive browser)
# Connect-MgGraph is called WITHOUT -Scopes parameter
# Permissions : User.Read, User.Read.All, Group.Read.All, GroupMember.Read.All,
# Directory.Read.All, Device.Read.All, DeviceManagementConfiguration.Read.All,
# DeviceManagementManagedDevices.Read.All, DeviceManagementRBAC.Read.All, offline_access
#
# ===================== DEVELOPER CONFIG - EDIT THIS SECTION =====================
# All developer-facing settings are in the #region DEVELOPER CONFIG block below.
# This includes: theme colours, background image, logo, log path, window size, etc.
# ================================================================================
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ============================================================
#region DEVELOPER CONFIG
# ============================================================
$DevConfig = [ordered]@{
# ---- Azure App Registration ----
TenantId = ""
ClientId = ""
# ---- Local log file path ----
# A timestamped log file will be created here each session
LogFolder = "C:\Logs\MDM-ODA\Logs"
# ---- Window ----
WindowTitle = "MDM On-Demand Actions v0.66"
WindowWidth = 1100
WindowHeight = 780
MinWidth = 900
MinHeight = 600
# ---- Verbose pane ----
VerbosePaneDefaultHeight = 200 # pixels; user can drag the splitter
# ---- Background image ----
# Local file path takes priority over URL. Set both to "" to use solid colour.
BackgroundImagePath = "" # e.g. "C:\Assets\bg.jpg" (local file)
BackgroundImageUrl = "" # e.g. "https://intranet/assets/bg.jpg" (web URL)
BackgroundImageOpacity = 0.08 # 0.0 (invisible) to 1.0 (fully opaque)
# ---- Logo ----
# Local file path takes priority over URL. Set both to "" to hide the logo.
LogoImagePath = "" # e.g. "C:\Assets\logo.png" (local file)
LogoImageUrl = "" # e.g. "https://intranet/assets/logo.png" (web URL)
LogoWidth = 240
LogoHeight = 80
LogoBase64 = "" # Base64-encoded image (PNG/JPG/ICO). Takes priority over Path/URL.
LogoTitleSpacing = 12 # Pixels between logo and title text
# To encode: [Convert]::ToBase64String([IO.File]::ReadAllBytes("C:\logo.png"))
# To encode from URL: [Convert]::ToBase64String((Invoke-WebRequest "https://...").Content)
# ---- Colour theme ----
# Primary colour used for header bar, buttons, accents
ColorPrimary = "#007A00" # Primary green
ColorPrimaryLight = "#009900" # Primary green (hover)
ColorPrimaryDark = "#006400" # Primary green (pressed)
ColorAccent = "#007A00" # Accent green (unified with primary)
ColorAccentLight = "#009900"
ColorBackground = "#F8FBF8" # Page background
ColorSurface = "#FFFFFF" # Card / panel background
ColorBorder = "#B0D0B0"
ColorText = "#1A1F2E"
ColorTextMuted = "#5A6480"
ColorSuccess = "#1E7A4A"
ColorWarning = "#B45B00"
ColorError = "#C0392B"
ColorInfo = "#1A5FA8"
# ---- Verbose pane log colours (foreground hex) ----
LogColorInfo = "#9DB8D8"
LogColorSuccess = "#5DC98B"
LogColorWarning = "#F5A623"
LogColorError = "#F06C6C"
LogColorAction = "#C8DEF5"
# ---- Font ----
FontFamily = "Segoe UI"
FontSizeBase = 13
# ---- Max objects per batch Graph request ----
GraphBatchSize = 20
# ---- PIM role auto-refresh interval (minutes). Set to 0 to disable.
PimRefreshIntervalMinutes = 5
# ---- Feedback URL (opened when Feedback button is clicked) ----
# Set to the URL of your feedback form, Teams channel, or any web page.
# Leave empty to hide the Feedback button.
FeedbackUrl = "" # e.g. "https://forms.office.com/yourformid"
# ---- M365 Group Mail Domain ----
# Domain suffix shown next to the mail nickname field when creating M365 groups.
# e.g. "contoso.onmicrosoft.com" or "contoso.com"
# Leave empty to show only "@" (tenant assigns the domain automatically).
M365MailDomain = "" # e.g. "contoso.onmicrosoft.com"
}
#endregion DEVELOPER CONFIG
# ============================================================
# ============================================================
#region BOOTSTRAP - STA check & assemblies
# ============================================================
# ── Start transcript immediately for startup diagnostics ─────────────────────
$script:TranscriptStarted = $false
try {
$tsPath = if ($TranscriptPath -and $TranscriptPath -ne "") {
$TranscriptPath
} else {
Join-Path $env:TEMP ("EGM_Transcript_{0}.log" -f (Get-Date -f "yyyyMMdd_HHmmss"))
}
$tsDir = Split-Path $tsPath -Parent
if (-not (Test-Path $tsDir)) { New-Item -ItemType Directory -Path $tsDir -Force | Out-Null }
Start-Transcript -Path $tsPath -Append -ErrorAction Stop
$script:TranscriptStarted = $true
Write-Host "[STARTUP] Transcript: $tsPath"
} catch {
Write-Host "[STARTUP] Transcript failed: $($_.Exception.Message)"
}
Add-Type -AssemblyName PresentationFramework
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName WindowsBase
Add-Type -AssemblyName System.Windows.Forms
# ── WPF Application singleton ─────────────────────────────────────────────────
# Required for transparent/custom-chrome windows (WindowStyle=None).
# ShutdownMode=OnExplicitShutdown keeps the dispatcher alive across
# multiple windows (prereq -> main). Each window uses DispatcherFrame
# to pump its own message loop without calling Application.Run().
if (-not [System.Windows.Application]::Current) {
$script:WpfApp = [System.Windows.Application]::new()
$script:WpfApp.ShutdownMode = [System.Windows.ShutdownMode]::OnExplicitShutdown
} else {
[System.Windows.Application]::Current.ShutdownMode = [System.Windows.ShutdownMode]::OnExplicitShutdown
}
# ── DPI awareness - must be set before any WPF window is created ──
# Per-Monitor v2 tells WPF to render at native DPI on each monitor,
# preventing the window from being clipped or mis-sized on high-DPI displays.
try {
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public class DpiAware {
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
// DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4
public static void SetPerMonitorV2() { SetProcessDpiAwarenessContext(new IntPtr(-4)); }
}
'@ -ErrorAction SilentlyContinue
[DpiAware]::SetPerMonitorV2()
} catch {}
# Ensure we are on an STA thread (required for WPF)
# The orchestrator launcher guarantees -STA; this is a safety net for direct launches.
if ([System.Threading.Thread]::CurrentThread.ApartmentState -ne 'STA') {
Write-Error "This script must run on an STA thread. Use the orchestrator launcher, or run: pwsh -STA -File '$PSCommandPath'"
exit 1
}
# Override LogFolder from orchestrator if provided
if (-not [string]::IsNullOrWhiteSpace($OverrideLogFolder)) {
$DevConfig.LogFolder = $OverrideLogFolder
Write-Host "[STARTUP] LogFolder overridden to: $OverrideLogFolder"
}
# Ensure log folder exists
if (-not [string]::IsNullOrWhiteSpace($DevConfig.LogFolder)) {
if (-not (Test-Path $DevConfig.LogFolder)) {
New-Item -ItemType Directory -Path $DevConfig.LogFolder -Force | Out-Null
}
}
$script:SessionStamp = (Get-Date).ToString("yyyyMMdd_HHmmss")
$script:LogFile = if (-not [string]::IsNullOrWhiteSpace($DevConfig.LogFolder)) {
Join-Path $DevConfig.LogFolder ("MDM-ODA_{0}.log" -f $script:SessionStamp)
} else { $null }
#endregion
# ============================================================
#region PREREQ CHECK WINDOW
# ============================================================
function Start-PrereqCheck {
# Prereq window - runs checks on a background runspace.
# Results are posted to a ConcurrentQueue drained by a DispatcherTimer.
# No STA thread blocking. Find-Module (network) runs in the same runspace.
$requiredModules = @(
[PSCustomObject]@{ Name = 'Microsoft.Graph.Authentication'; Label = 'Graph . Authentication' }
[PSCustomObject]@{ Name = 'Microsoft.Graph.Groups'; Label = 'Graph . Groups' }
[PSCustomObject]@{ Name = 'Microsoft.Graph.Users'; Label = 'Graph . Users' }
[PSCustomObject]@{ Name = 'Microsoft.Graph.Identity.DirectoryManagement'; Label = 'Graph . Directory Management' }
)
$optionalModules = @(
[PSCustomObject]@{ Name = 'ImportExcel'; Label = 'ImportExcel (XLSX export)' }
)
# ── Shared queue between runspace and UI ──
Write-Host "[PREREQ] Entering Start-PrereqCheck"
$queue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new()
Write-Host "[PREREQ] Queue created"
# ── Build XAML (simple Grid rows - no data binding) ──
[xml]$prereqXaml = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="$($DevConfig.WindowTitle) - System Check"
Width="640" Height="560" MinWidth="520" MinHeight="420"
WindowStartupLocation="CenterScreen" ResizeMode="CanResize"
Background="#F2FAF2" FontFamily="Segoe UI" FontSize="13"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
TextOptions.TextFormattingMode="Display"
TextOptions.TextRenderingMode="ClearType"
RenderOptions.ClearTypeHint="Enabled">
<Window.Resources>
<ControlTemplate x:Key="PrereqBtnTpl" TargetType="Button">
<Grid>
<Border x:Name="BtnBd"
Background="{TemplateBinding Background}"
CornerRadius="9"
Padding="{TemplateBinding Padding}"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border CornerRadius="9,9,0,0" Height="14" VerticalAlignment="Top"
IsHitTestVisible="False" Margin="1,1,1,0"
SnapsToDevicePixels="True"
RenderOptions.ClearTypeHint="Enabled">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#33FFFFFF" Offset="0"/>
<GradientStop Color="#0AFFFFFF" Offset="0.6"/>
<GradientStop Color="#00FFFFFF" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
</Border>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="BtnBd" Property="Opacity" Value="0.88"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="BtnBd" Property="Opacity" Value="0.72"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="BtnBd" Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Window.Resources>
<DockPanel>
<!-- Green gradient header bar (matches main tool) -->
<Border DockPanel.Dock="Top" Padding="14,12">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#006400" Offset="0"/>
<GradientStop Color="#009900" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<Border.BorderBrush>
<SolidColorBrush Color="#0FFFFFFF"/>
</Border.BorderBrush>
<Border.BorderThickness>0,0,0,1</Border.BorderThickness>
<StackPanel HorizontalAlignment="Center" RenderOptions.ClearTypeHint="Enabled">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Image x:Name="PrereqLogoImg" Height="32" MaxWidth="120"
Stretch="Uniform" VerticalAlignment="Center" Visibility="Collapsed"
Margin="0,0,10,0"/>
<TextBlock Text="System Prerequisites Check" FontSize="16" FontWeight="ExtraBold"
Foreground="#F1F9F1" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="Verifying all requirements before launching the tool."
FontSize="11" Foreground="#CCFFCC" HorizontalAlignment="Center" Margin="0,3,0,0"/>
</StackPanel>
</Border>
<!-- Content area -->
<Grid Margin="20,14,20,20">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="130"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="White" BorderBrush="#B0D0B0" BorderThickness="1" SnapsToDevicePixels="True"
CornerRadius="12" Margin="0,0,0,10">
<Border.Effect>
<DropShadowEffect BlurRadius="16" ShadowDepth="3" Direction="270" Color="#000000" Opacity="0.10"/>
</Border.Effect>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="SpChecks" Margin="4,4,4,4"/>
</ScrollViewer>
</Border>
<Border Grid.Row="1" Background="#0D1824" CornerRadius="8" Margin="0,0,0,10" SnapsToDevicePixels="True">
<RichTextBox x:Name="RtbPrereqLog" Background="Transparent" BorderThickness="0"
IsReadOnly="True" FontFamily="Consolas" FontSize="10.5"
Foreground="#9DB8D8" Padding="8,5"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"/>
</Border>
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TxtPrereqSummary" Grid.Column="0" FontSize="11"
Foreground="#5A6480" VerticalAlignment="Center" TextWrapping="Wrap"/>
<Button x:Name="BtnPrereqInstall" Grid.Column="1" Content="Install Missing"
Foreground="White" BorderThickness="0"
Padding="14,8" FontWeight="SemiBold" Cursor="Hand" Visibility="Collapsed"
Template="{StaticResource PrereqBtnTpl}">
<Button.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#006400" Offset="0"/>
<GradientStop Color="#009900" Offset="1"/>
</LinearGradientBrush>
</Button.Background>
</Button>
<Button x:Name="BtnPrereqContinue" Grid.Column="3" Content="Continue &#x2192;"
Foreground="White" BorderThickness="0"
Padding="14,8" FontWeight="SemiBold" Cursor="Hand" IsEnabled="False"
Template="{StaticResource PrereqBtnTpl}">
<Button.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#006400" Offset="0"/>
<GradientStop Color="#009900" Offset="1"/>
</LinearGradientBrush>
</Button.Background>
</Button>
</Grid>
</Grid>
</DockPanel>
</Window>
"@
Write-Host "[PREREQ] Parsing XAML..."
$reader = [System.Xml.XmlNodeReader]::new($prereqXaml)
Write-Host "[PREREQ] XmlNodeReader ready"
$prereqWin = [Windows.Markup.XamlReader]::Load($reader)
Write-Host "[PREREQ] Window loaded: $($prereqWin -ne $null)"
Write-Host "[PREREQ] Wiring elements..."
$spChecks = $prereqWin.FindName('SpChecks')
$rtbLog = $prereqWin.FindName('RtbPrereqLog')
$btnInstall = $prereqWin.FindName('BtnPrereqInstall')
$btnContinue = $prereqWin.FindName('BtnPrereqContinue')
$txtSummary = $prereqWin.FindName('TxtPrereqSummary')
$prereqLogo = $prereqWin.FindName('PrereqLogoImg')
# ── Load logo into prereq header (same DevConfig sources as main tool) ──
try {
$logoSrc = $null
# Priority 1: Base64
if (-not [string]::IsNullOrWhiteSpace($DevConfig.LogoBase64)) {
$bytes = [Convert]::FromBase64String($DevConfig.LogoBase64.Trim())
$ms = [System.IO.MemoryStream]::new($bytes)
$bmp = [System.Windows.Media.Imaging.BitmapImage]::new()
$bmp.BeginInit()
$bmp.StreamSource = $ms
$bmp.CacheOption = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
$bmp.EndInit(); $null = $bmp.Freeze()
$logoSrc = $bmp
}
# Priority 2: Local path
if (-not $logoSrc -and -not [string]::IsNullOrWhiteSpace($DevConfig.LogoImagePath) -and (Test-Path $DevConfig.LogoImagePath)) {
$logoSrc = [System.Windows.Media.Imaging.BitmapFrame]::Create([Uri]::new((Resolve-Path $DevConfig.LogoImagePath).Path))
}
# Priority 3: URL
if (-not $logoSrc -and -not [string]::IsNullOrWhiteSpace($DevConfig.LogoImageUrl)) {
$bmp = [System.Windows.Media.Imaging.BitmapImage]::new()
$bmp.BeginInit()
$bmp.UriSource = [Uri]::new($DevConfig.LogoImageUrl)
$bmp.CacheOption = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
$bmp.EndInit()
$logoSrc = $bmp
}
if ($logoSrc) {
$prereqLogo.Source = $logoSrc
$prereqLogo.Visibility = 'Visible'
Write-Host "[PREREQ] Logo loaded into header"
}
} catch { Write-Host "[PREREQ] Logo load skipped: $($_.Exception.Message)" }
Write-Host "[PREREQ] Elements wired ok"
$script:PrereqResult = $false
$script:MissingMandatory = [System.Collections.Generic.List[string]]::new()
$script:MissingOptional = [System.Collections.Generic.List[string]]::new()
$script:UpdateAvailable = [System.Collections.Generic.List[string]]::new()
# Row registry: key = row-id string, value = hashtable of TextBlock refs
$rowRegistry = [hashtable]::Synchronized(@{})
# ── UI helpers (run on dispatcher) ──────────────────────────────────────
function Add-CheckRow {
param([string]$RowId, [string]$Label, [string]$Detail)
$prereqWin.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
$outer = [System.Windows.Controls.Grid]::new()
$outer.Margin = [System.Windows.Thickness]::new(8,5,8,5)
$c0 = [System.Windows.Controls.ColumnDefinition]::new(); $c0.Width = [System.Windows.GridLength]::new(22)
$c1 = [System.Windows.Controls.ColumnDefinition]::new(); $c1.Width = [System.Windows.GridLength]::new(1,[System.Windows.GridUnitType]::Star)
$c2 = [System.Windows.Controls.ColumnDefinition]::new(); $c2.Width = [System.Windows.GridLength]::new(1,[System.Windows.GridUnitType]::Auto)
$outer.ColumnDefinitions.Add($c0); $outer.ColumnDefinitions.Add($c1); $outer.ColumnDefinitions.Add($c2)
# Icon — hourglass U+231B, rendered in Segoe UI Symbol for reliable glyph
$tbIcon = [System.Windows.Controls.TextBlock]::new()
$tbIcon.Text = [string][char]0x231B; $tbIcon.FontSize = 13
$tbIcon.FontFamily = [System.Windows.Media.FontFamily]::new("Segoe UI Symbol, Segoe UI Emoji, Segoe UI")
$tbIcon.VerticalAlignment = 'Center'
$tbIcon.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#999999'))
$tbIcon.Opacity = 0.45
# Opacity pulse animation (pre-built, started on demand)
$pulseAnim = [System.Windows.Media.Animation.DoubleAnimation]::new()
$pulseAnim.From = 0.35; $pulseAnim.To = 1.0
$pulseAnim.Duration = [System.Windows.Duration]::new([TimeSpan]::FromMilliseconds(700))
$pulseAnim.AutoReverse = $true
$pulseAnim.RepeatBehavior = [System.Windows.Media.Animation.RepeatBehavior]::Forever
[System.Windows.Controls.Grid]::SetColumn($tbIcon, 0)
$inner = [System.Windows.Controls.StackPanel]::new()
$inner.Margin = [System.Windows.Thickness]::new(6,0,0,0)
$inner.VerticalAlignment = 'Center'
$tbLabel = [System.Windows.Controls.TextBlock]::new()
$tbLabel.Text = $Label; $tbLabel.FontSize = 12; $tbLabel.FontWeight = 'SemiBold'
$tbLabel.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#888888'))
$tbDetail = [System.Windows.Controls.TextBlock]::new()
$tbDetail.Text = $Detail; $tbDetail.FontSize = 10.5; $tbDetail.TextWrapping = 'Wrap'
$tbDetail.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#AAAAAA'))
$tbDetail.Margin = [System.Windows.Thickness]::new(0,1,0,0)
$inner.Children.Add($tbLabel) | Out-Null
$inner.Children.Add($tbDetail) | Out-Null
[System.Windows.Controls.Grid]::SetColumn($inner, 1)
$tbStatus = [System.Windows.Controls.TextBlock]::new()
$tbStatus.Text = 'Queued'; $tbStatus.FontSize = 11; $tbStatus.FontWeight = 'SemiBold'
$tbStatus.VerticalAlignment = 'Center'
$tbStatus.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#AAAAAA'))
[System.Windows.Controls.Grid]::SetColumn($tbStatus, 2)
$outer.Children.Add($tbIcon) | Out-Null
$outer.Children.Add($inner) | Out-Null
$outer.Children.Add($tbStatus) | Out-Null
$spChecks.Children.Add($outer) | Out-Null
$rowRegistry[$RowId] = @{ Icon = $tbIcon; Label = $tbLabel; Detail = $tbDetail; Status = $tbStatus; Outer = $outer; PulseAnimation = $pulseAnim }
})
}
function Apply-RowUpdate {
param([hashtable]$Msg)
$rid = $Msg['RowId']
if (-not $rowRegistry.ContainsKey($rid)) { return }
$row = $rowRegistry[$rid]
# Stop opacity pulse animation
$row['Icon'].BeginAnimation([System.Windows.UIElement]::OpacityProperty, $null)
$row['Icon'].Opacity = 1.0
# Reset icon font to default for result glyphs (tick/cross render fine in Segoe UI)
$row['Icon'].FontFamily = [System.Windows.Media.FontFamily]::new("Segoe UI")
# Clear row highlight background
if ($row['Outer']) { $row['Outer'].Background = $null }
# Apply field updates
if ($Msg['Icon']) { $row['Icon'].Text = $Msg['Icon'] }
if ($Msg['Status']) { $row['Status'].Text = $Msg['Status'] }
if ($Msg['Detail']) { $row['Detail'].Text = $Msg['Detail'] }
if ($Msg['LabelColor']) { $row['Label'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString($Msg['LabelColor'])) }
if ($Msg['StatusColor']) { $row['Status'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString($Msg['StatusColor'])) }
if ($Msg['IconColor']) { $row['Icon'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString($Msg['IconColor'])) }
}
function Append-LogLine {
param([string]$Line, [string]$Color = '#9DB8D8')
$para = [System.Windows.Documents.Paragraph]::new()
$run = [System.Windows.Documents.Run]::new($Line)
$run.Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString($Color))
$para.Margin = [System.Windows.Thickness]::new(0)
$para.Inlines.Add($run)
$rtbLog.Document.Blocks.Add($para)
$rtbLog.ScrollToEnd()
}
# ── Pre-create all rows before runspace starts ───────────────────────────
Add-CheckRow 'ps' 'PowerShell 7+' 'Required for WPF and modern syntax'
Add-CheckRow 'os' 'Windows OS + WPF' 'WPF requires Windows'
Add-CheckRow 'ep' 'Execution Policy' 'Must allow script execution'
foreach ($m in $requiredModules) { Add-CheckRow $m.Name $m.Label "Required module - $($m.Name)" }
foreach ($m in $optionalModules) { Add-CheckRow $m.Name $m.Label "Optional - $($m.Name)" }
# ── Launch background runspace ───────────────────────────────────────────
$rs = [runspacefactory]::CreateRunspace()
$rs.ApartmentState = 'MTA'
$rs.ThreadOptions = 'ReuseThread'
$rs.Open()
$rs.SessionStateProxy.SetVariable('Queue', $queue)
$rs.SessionStateProxy.SetVariable('RequiredModules', $requiredModules)
$rs.SessionStateProxy.SetVariable('OptionalModules', $optionalModules)
$ps = [powershell]::Create()
$ps.Runspace = $rs
$null = $ps.AddScript({
function Q {
param([hashtable]$Msg)
$Queue.Enqueue($Msg)
}
function TS { (Get-Date).ToString('HH:mm:ss') }
Q @{ Type='log'; Line="[$(TS)] Starting prerequisite checks..."; Color='#C8DEF5' }
Start-Sleep -Milliseconds 200
# 1. PowerShell version
Q @{ Type='checking'; RowId='ps' }
Start-Sleep -Milliseconds 250
Q @{ Type='log'; Line="[$(TS)] Checking PowerShell version..."; Color='#9DB8D8' }
$psv = $PSVersionTable.PSVersion
if ($psv.Major -ge 7) {
Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2713) PowerShell $($psv.ToString())"; Color='#5DC98B'; RowId='ps'; Icon="$([char]0x2713)"; Status="OK v$($psv.Major).$($psv.Minor).$($psv.Build)"; StatusColor='#14532D'; IconColor='#14532D'; LabelColor='#14532D' }
} else {
Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2717) PowerShell $($psv.ToString()) - need 7+"; Color='#F06C6C'; RowId='ps'; Icon="$([char]0x2717)"; Status="FAIL v$($psv.ToString())"; LabelColor='#8B0000'; StatusColor='#8B0000'; IconColor='#8B0000' }
Q @{ Type='mandatory'; Item='PowerShell 7+ - https://github.com/PowerShell/PowerShell/releases' }
}
# 2. Windows OS
Q @{ Type='checking'; RowId='os' }
Start-Sleep -Milliseconds 250
Q @{ Type='log'; Line="[$(TS)] Checking OS..."; Color='#9DB8D8' }
if ($IsWindows) {
Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2713) Windows - WPF available"; Color='#5DC98B'; RowId='os'; Icon="$([char]0x2713)"; Status="OK Windows"; StatusColor='#14532D'; IconColor='#14532D'; LabelColor='#14532D'; Detail=[System.Environment]::OSVersion.VersionString }
} else {
Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2717) Not Windows - tool is Windows-only"; Color='#F06C6C'; RowId='os'; Icon="$([char]0x2717)"; Status='NOT WINDOWS'; LabelColor='#8B0000'; StatusColor='#8B0000'; IconColor='#8B0000' }
Q @{ Type='mandatory'; Item='Windows OS - WPF is Windows-only' }
}
# 3. Execution Policy
Q @{ Type='checking'; RowId='ep' }
Start-Sleep -Milliseconds 250
Q @{ Type='log'; Line="[$(TS)] Checking execution policy..."; Color='#9DB8D8' }
$ep = Get-ExecutionPolicy -Scope CurrentUser
$epm = Get-ExecutionPolicy -Scope LocalMachine
$epOk = $ep -in @('RemoteSigned','Unrestricted','Bypass') -or $epm -in @('RemoteSigned','Unrestricted','Bypass')
if ($epOk) {
Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2713) Execution policy: $ep"; Color='#5DC98B'; RowId='ep'; Icon="$([char]0x2713)"; Status="OK ($ep)"; StatusColor='#14532D'; IconColor='#14532D'; LabelColor='#14532D' }
} else {
Q @{ Type='rowlog'; Line="[$(TS)] [!] Policy '$ep' may block scripts"; Color='#F5A623'; RowId='ep'; Icon='[!]'; Status="$ep"; StatusColor='#B45B00' }
Q @{ Type='mandatory'; Item="ExecutionPolicy - run: Set-ExecutionPolicy RemoteSigned -Scope CurrentUser" }
}
# 4. Required modules (local check only, fast)
foreach ($mod in $RequiredModules) {
Q @{ Type='checking'; RowId=$mod.Name }
Start-Sleep -Milliseconds 250
Q @{ Type='log'; Line="[$(TS)] Checking $($mod.Name)..."; Color='#9DB8D8' }
$inst = Get-Module -ListAvailable -Name $mod.Name -ErrorAction SilentlyContinue |
Sort-Object Version -Descending | Select-Object -First 1
if ($inst) {
$v = $inst.Version.ToString()
Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2713) $($mod.Name) v$v"; Color='#5DC98B'; RowId=$mod.Name; Icon="$([char]0x2713)"; Status="OK v$v"; StatusColor='#14532D'; IconColor='#14532D'; LabelColor='#14532D'; Detail="Installed: v$v" }
Q @{ Type='installed'; ModName=$mod.Name; Version=$v }
} else {
Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2717) $($mod.Name) - NOT INSTALLED"; Color='#F06C6C'; RowId=$mod.Name; Icon="$([char]0x2717)"; Status='NOT INSTALLED'; LabelColor='#8B0000'; StatusColor='#8B0000'; IconColor='#8B0000'; Detail="Missing" }
Q @{ Type='mandatory'; Item=$mod.Name }
}
}
# 5. Optional modules
foreach ($mod in $OptionalModules) {
Q @{ Type='checking'; RowId=$mod.Name }
Start-Sleep -Milliseconds 250
Q @{ Type='log'; Line="[$(TS)] Checking $($mod.Name) (optional)..."; Color='#9DB8D8' }
$inst = Get-Module -ListAvailable -Name $mod.Name -ErrorAction SilentlyContinue |
Sort-Object Version -Descending | Select-Object -First 1
if ($inst) {
$v = $inst.Version.ToString()
Q @{ Type='rowlog'; Line="[$(TS)] $([char]0x2713) $($mod.Name) v$v"; Color='#5DC98B'; RowId=$mod.Name; Icon="$([char]0x2713)"; Status="OK v$v"; StatusColor='#14532D'; IconColor='#14532D'; LabelColor='#14532D'; Detail="Installed: v$v" }
Q @{ Type='installed'; ModName=$mod.Name; Version=$v }
} else {
Q @{ Type='rowlog'; Line="[$(TS)] o $($mod.Name) not installed (optional)"; Color='#F5A623'; RowId=$mod.Name; Icon='o'; Status='Not installed'; StatusColor='#B45B00'; Detail='Optional - export falls back to CSV' }
Q @{ Type='optional'; Item=$mod.Name }
}
}
Q @{ Type='log'; Line="[$(TS)] ---------------------------------"; Color='#333D4D' }
Q @{ Type='log'; Line="[$(TS)] Local checks complete. Checking for module updates online..."; Color='#9DB8D8' }
# 6. Online update check (slow - runs after local checks)
$allModNames = ($RequiredModules + $OptionalModules).Name
foreach ($modName in $allModNames) {
$inst = Get-Module -ListAvailable -Name $modName -ErrorAction SilentlyContinue |
Sort-Object Version -Descending | Select-Object -First 1
if (-not $inst) { continue }
try {
Q @{ Type='log'; Line="[$(TS)] Checking online: $modName..."; Color='#9DB8D8' }
$gallery = Find-Module -Name $modName -ErrorAction SilentlyContinue
if ($gallery -and [version]$gallery.Version -gt [version]$inst.Version) {
Q @{ Type='rowlog'; Line="[$(TS)] ^ Update: $modName $($inst.Version) -> $($gallery.Version)"; Color='#9DB8D8'; RowId=$modName; Icon='^'; Status="Update $($gallery.Version)"; StatusColor='#1A5FA8'; Detail="Installed: v$($inst.Version) -> Available: v$($gallery.Version)" }
Q @{ Type='update'; Item=$modName }
} else {
Q @{ Type='log'; Line="[$(TS)] $([char]0x2713) $modName up to date"; Color='#5DC98B' }
}
} catch {
Q @{ Type='log'; Line="[$(TS)] [!] Could not check online for $modName"; Color='#F5A623' }
}
}
Q @{ Type='log'; Line="[$(TS)] ---------------------------------"; Color='#333D4D' }
Q @{ Type='done' }
})
$bgHandle = $ps.BeginInvoke()
# ── DispatcherTimer - drains queue on UI thread ──────────────────────────
$timer = [System.Windows.Threading.DispatcherTimer]::new()
$timer.Interval = [TimeSpan]::FromMilliseconds(120)
$timer.Add_Tick({
try {
$msg = $null
while ($queue.TryDequeue([ref]$msg)) {
switch ($msg['Type']) {
'log' {
Append-LogLine -Line $msg['Line'] -Color $msg['Color']
}
'checking' {
$rid = $msg['RowId']
if ($rowRegistry.ContainsKey($rid)) {
$row = $rowRegistry[$rid]
# Activate: pulsing hourglass, teal colour, row highlight
$row['Icon'].Text = [string][char]0x231B
$row['Icon'].FontFamily = [System.Windows.Media.FontFamily]::new('Segoe UI Symbol, Segoe UI Emoji, Segoe UI')
$row['Icon'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#0A9396'))
$row['Status'].Text = 'Checking...'
$row['Status'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#0A9396'))
$row['Label'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#1A1F2E'))
$row['Detail'].Foreground = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#5A6480'))
$row['Outer'].Background = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString('#F0FAF0'))
# Start opacity pulse animation
$row['Icon'].BeginAnimation([System.Windows.UIElement]::OpacityProperty, $row['PulseAnimation'])
}
}
'row' {
Apply-RowUpdate -Msg $msg
}
'rowlog' {
# Atomic: render log line THEN row update in same dispatcher callback
Append-LogLine -Line $msg['Line'] -Color $msg['Color']
Apply-RowUpdate -Msg $msg
}
'mandatory' {
$script:MissingMandatory.Add($msg['Item'])
}
'optional' {
$script:MissingOptional.Add($msg['Item'])
}
'update' {
$script:UpdateAvailable.Add($msg['Item'])
}
'done' {
$timer.Stop()
$ps.EndInvoke($bgHandle)
$rs.Close(); $ps.Dispose()
$hasMand = $script:MissingMandatory.Count -gt 0
$hasOpt = $script:MissingOptional.Count -gt 0
$hasUpd = $script:UpdateAvailable.Count -gt 0
# Summary
$parts = @()
if ($hasMand) { $parts += "$($script:MissingMandatory.Count) required missing" }
if ($hasOpt) { $parts += "$($script:MissingOptional.Count) optional missing" }
if ($hasUpd) { $parts += "$($script:UpdateAvailable.Count) update(s) available" }
if ($parts.Count -eq 0) {
$txtSummary.Text = "$([char]0x2713) All checks passed."
Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] $([char]0x2713) All checks passed - ready to launch." '#5DC98B'
} else {
$txtSummary.Text = $parts -join ' | '
if ($hasMand) { Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] $([char]0x2717) Mandatory items missing." '#F06C6C' }
}
# Install button
$installable = [System.Collections.Generic.List[string]]::new()
$allModNames = ($requiredModules + $optionalModules).Name
foreach ($item in ($script:MissingMandatory + $script:MissingOptional + $script:UpdateAvailable)) {
if ($allModNames -contains $item) { $installable.Add($item) }
}
if ($installable.Count -gt 0) {
$btnInstall.Visibility = 'Visible'
if ($hasMand -and $hasUpd) { $btnInstall.Content = 'Install Missing + Update' }
elseif ($hasMand) { $btnInstall.Content = 'Install Missing' }
elseif ($hasUpd) { $btnInstall.Content = 'Update Modules' }
else { $btnInstall.Content = 'Install Optional' }
}
if (-not $hasMand) {
$btnContinue.IsEnabled = $true
$txtSummary.Text = "✓ All checks passed - click Continue to proceed"
}
}
}
}
} catch {
Write-ErrorLog "PrereqTimer error: $($_.Exception.Message)"
}
})
$timer.Start()
# ── Native WPF confirm dialog (replaces MessageBox) ──────────────────────
function Show-PrereqConfirm {
param([string]$Title, [string]$Message, [bool]$IsWarning = $false)
# Title and Message set in code - never interpolated into XML (avoids
# XML-special chars: &, <, >, newlines breaking the document parse)
[xml]$dlgXml = @'
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Confirm" Width="480" SizeToContent="Height"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
Background="#F8FBF8" FontFamily="Segoe UI" FontSize="13"
UseLayoutRounding="True" SnapsToDevicePixels="True"
TextOptions.TextFormattingMode="Display"
TextOptions.TextRenderingMode="ClearType">
<Grid Margin="22,18,22,18">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="14"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock x:Name="TxtMsg" Grid.Row="0" TextWrapping="Wrap" LineHeight="20"
Foreground="#1A1F2E" FontSize="12"/>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="BtnNo" Content="Cancel" Width="90" Padding="0,8" Margin="0,0,10,0"
Background="Transparent" BorderBrush="#B0D0B0" BorderThickness="1"
Cursor="Hand" FontSize="12">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="5" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#E8F5E8"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
<Button x:Name="BtnYes" Content="Proceed" Width="100" Padding="0,8"
Background="#007A00" BorderThickness="0" Cursor="Hand"
FontSize="12" FontWeight="SemiBold" Foreground="White">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="5"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#009900"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</StackPanel>
</Grid>
</Window>
'@
$dlgReader = [System.Xml.XmlNodeReader]::new($dlgXml)
$dlg = [Windows.Markup.XamlReader]::Load($dlgReader)
$dlg.Owner = $prereqWin
$dlg.Title = $Title
$dlg.FindName('TxtMsg').Text = $Message
# Colour the Proceed button - evaluate colour before passing to method
$yesBtn = $dlg.FindName('BtnYes')
$btnColor = if ($IsWarning) { '#C0392B' } else { '#007A00' }
$yesBtn.Background = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString($btnColor))
$script:PrereqDlgResult = $false
$dlg.FindName('BtnNo').Add_Click({ $script:PrereqDlgResult = $false; $dlg.Close() })
$yesBtn.Add_Click({ $script:PrereqDlgResult = $true; $dlg.Close() })
$dlg.ShowDialog() | Out-Null
return $script:PrereqDlgResult
}
# ── Install button ────────────────────────────────────────────────────────
$btnInstall.Add_Click({
$allModNames = ($requiredModules + $optionalModules).Name
$toInstall = [System.Collections.Generic.List[string]]::new()
foreach ($item in ($script:MissingMandatory + $script:MissingOptional + $script:UpdateAvailable)) {
if ($allModNames -contains $item -and -not $toInstall.Contains($item)) {
$toInstall.Add($item)
}
}
if ($toInstall.Count -eq 0) {
$null = Show-PrereqConfirm -Title "Nothing to Install" `
-Message "No installable modules found.`nPS version, OS and execution policy issues must be resolved manually."
return
}
$listTxt = ($toInstall | ForEach-Object { " - $_" }) -join "`n"
# Install using CurrentUser scope - no admin rights required
$confirmMsg = "The following modules will be installed or updated:`n`n$listTxt`n`n"
$confirmMsg += "Modules will be installed in CurrentUser scope (no admin required)."
if (-not (Show-PrereqConfirm -Title "Install Modules" -Message $confirmMsg)) { return }
$btnInstall.IsEnabled = $false
$btnContinue.IsEnabled = $false
$btnInstall.Content = "[wait] Installing..."
Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] -- Installing modules (elevated session) --" '#C8DEF5'
# Use a runspace so Install-Module output streams to our queue live
# All vars promoted to script scope - closure capture is unreliable in WPF handlers
$script:InstallQueue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new()
$script:InstallRs = [runspacefactory]::CreateRunspace()
$script:InstallRs.ApartmentState = 'MTA'
$script:InstallRs.ThreadOptions = 'ReuseThread'
$script:InstallRs.Open()
$script:InstallRs.SessionStateProxy.SetVariable('InstallQueue', $script:InstallQueue)
$script:InstallRs.SessionStateProxy.SetVariable('ModulesToInstall', $toInstall)
$script:InstallPs = [powershell]::Create()
$script:InstallPs.Runspace = $script:InstallRs
$null = $script:InstallPs.AddScript({
function IQ { param([hashtable]$M) $InstallQueue.Enqueue($M) }
function TS { (Get-Date).ToString('HH:mm:ss') }
$allOk = $true
foreach ($modName in $ModulesToInstall) {
IQ @{ Type='log'; Line="[$(TS)] Installing $modName ..."; Color='#C8DEF5' }
try {
# Redirect verbose/progress into variable to avoid console output
$null = Install-Module $modName -Scope CurrentUser -Force -AllowClobber `
-ErrorAction Stop -Verbose 4>&1 | ForEach-Object {
IQ @{ Type='log'; Line="[$(TS)] $_"; Color='#9DB8D8' }
}
IQ @{ Type='log'; Line="[$(TS)] $([char]0x2713) $modName - done."; Color='#5DC98B' }
} catch {
IQ @{ Type='log'; Line="[$(TS)] $([char]0x2717) $modName - failed: $($_.Exception.Message)"; Color='#F06C6C' }
$allOk = $false
}
}
IQ @{ Type='done'; AllOk=$allOk }
})
$script:InstallHandle = $script:InstallPs.BeginInvoke()
# Drain install queue via a separate timer
$script:InstallTimer = [System.Windows.Threading.DispatcherTimer]::new()
$script:InstallTimer.Interval = [TimeSpan]::FromMilliseconds(150)
$script:InstallTimer.Add_Tick({
$m = $null
while ($script:InstallQueue.TryDequeue([ref]$m)) {
if ($m['Type'] -eq 'log') {
Append-LogLine $m['Line'] $m['Color']
} elseif ($m['Type'] -eq 'done') {
$script:InstallTimer.Stop()
$script:InstallPs.EndInvoke($script:InstallHandle)
$script:InstallRs.Close(); $script:InstallPs.Dispose()
if ($m['AllOk']) {
Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] $([char]0x2713) All modules installed successfully." '#5DC98B'
Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] Note: A PowerShell session restart may be needed" '#F5A623'
Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] for newly installed modules to load correctly." '#F5A623'
} else {
Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] [!] Some modules failed - see log above." '#F5A623'
}
# Show native restart prompt
$restartMsg = "Modules installed successfully.`n`n"
$restartMsg += "PowerShell modules load at session start - to ensure the`n"
$restartMsg += "tool uses the updated versions, it needs to relaunch in a`n"
$restartMsg += "fresh session.`n`nRelaunch now?"
$btnInstall.Content = "Install Missing"
$btnInstall.IsEnabled = $true
if (Show-PrereqConfirm -Title "Relaunch Required" -Message $restartMsg) {
Append-LogLine "[$(Get-Date -f 'HH:mm:ss')] Relaunching tool in new session..." '#C8DEF5'
$scriptPath = $PSCommandPath
Start-Process pwsh -ArgumentList "-STA -File `"$scriptPath`""
$prereqWin.Close()
[System.Environment]::Exit(0)
} else {
# User declined relaunch - re-run checks so they see current state
$script:RerunPrereq = $true
$script:PrereqResult = $false
$prereqWin.Close()
}
}
}
})
$script:InstallTimer.Start()
return
})
# ── Continue / Close ──────────────────────────────────────────────────────
$btnContinue.Add_Click({
$script:PrereqResult = $true
# Show feedback before window closes - main window takes ~20s to build
$btnContinue.IsEnabled = $false
$btnContinue.Content = 'Loading...'
$btnInstall.IsEnabled = $false
if ($txtSummary) { $txtSummary.Text = 'Building main window, please wait...' }
$prereqWin.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Render, [Action]{})
$prereqWin.Close()
})
$prereqWin.Add_Closed({
$timer.Stop()
if (-not $script:PrereqResult) { exit 0 }
})
$prereqWin.ShowDialog() | Out-Null
return $script:PrereqResult
}
# ── Error log + trap defined BEFORE prereq so the trap can log errors ──
# ── Diagnostic: log any unhandled errors to a temp file ──────────────────────
# Visible error log so we can diagnose failures when the console is hidden.
# Remove once stable.
$_errDir = if (-not [string]::IsNullOrWhiteSpace($DevConfig.LogFolder) -and
(Test-Path $DevConfig.LogFolder)) { $DevConfig.LogFolder } else { $env:TEMP }
$script:ErrorLogPath = Join-Path $_errDir ("EGM_Error_{0}.log" -f $script:SessionStamp)
try { Remove-Item $script:ErrorLogPath -Force -ErrorAction SilentlyContinue } catch {}
Write-Host "[STARTUP] Error log: $script:ErrorLogPath"
function Write-ErrorLog {
param([string]$Msg)
try {
$ts = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
Add-Content -Path $script:ErrorLogPath -Value "[$ts] $Msg" -Encoding UTF8
} catch {}
}
Write-ErrorLog "Prereq passed. Building main window..."
Write-ErrorLog "PS Version : $($PSVersionTable.PSVersion)"
Write-ErrorLog "OS Version : $([System.Environment]::OSVersion.VersionString)"
Write-ErrorLog "User : $($env:USERNAME)@$($env:USERDOMAIN)"
Write-ErrorLog "LogFolder : $($DevConfig.LogFolder)"
Write-ErrorLog "Error log : $script:ErrorLogPath"
Write-ErrorLog "Transcript : $(if ($script:TranscriptStarted) { $tsPath } else { 'NOT STARTED' })"
$Error.Clear()
Write-EarlyLog "Checkpoint 1: Starting XAML definition..."
Write-ErrorLog "Checkpoint 1: Starting XAML definition..."
# ── Catch-all trap - logs any terminating error to the error log ─────────────
trap {
$errMsg = "TRAP at line $($_.InvocationInfo.ScriptLineNumber): $($_.Exception.Message)"
$errStack = $_.ScriptStackTrace
Write-ErrorLog $errMsg
Write-ErrorLog "Stack: $errStack"
try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
[System.Windows.Forms.MessageBox]::Show(
"$errMsg`n`nSee: $script:ErrorLogPath",
"Entra Group Manager - Error",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null
} catch {}
break
}
# Run the prereq check - loop supports in-session reinstall when already admin
$script:RerunPrereq = $false
do {
$script:RerunPrereq = $false
if (-not (Start-PrereqCheck)) { exit 0 }
} while ($script:RerunPrereq)
#endregion PREREQ CHECK
# ============================================================
#region XAML - UI Definition
# ============================================================
# ── Compute window size from available screen working area ──
# Uses SystemParameters (WPF logical pixels already DPI-adjusted) so the
# window fits on any screen size / scaling factor without clipping.
$_screen = [System.Windows.SystemParameters]
$_workW = [int]$_screen::WorkArea.Width
$_workH = [int]$_screen::WorkArea.Height
# Target 92% of working area, clamped to config defaults as ceiling
$_winW = [int][Math]::Min([Math]::Floor($_workW * 0.92), $DevConfig.WindowWidth)
$_winH = [int][Math]::Min([Math]::Floor($_workH * 0.92), $DevConfig.WindowHeight)
# Never go below the minimum sizes
$_winW = [Math]::Max($_winW, $DevConfig.MinWidth)
$_winH = [Math]::Max($_winH, $DevConfig.MinHeight)
[xml]$Xaml = @"
<Window
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="$($DevConfig.WindowTitle) &#x26A1;"
Width="$_winW"
Height="$_winH"
MinWidth="$($DevConfig.MinWidth)"
MinHeight="$($DevConfig.MinHeight)"
WindowStartupLocation="CenterScreen"
Background="#F2FAF2"
FontFamily="$($DevConfig.FontFamily)"
FontSize="$($DevConfig.FontSizeBase)"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
TextOptions.TextFormattingMode="Display"
TextOptions.TextRenderingMode="ClearType"
RenderOptions.ClearTypeHint="Enabled">
<Window.Resources>
<!-- Glassy button base template -->
<ControlTemplate x:Key="GlassyBtnTemplate" TargetType="Button">
<Grid>
<Border x:Name="BtnBorder"
Background="{TemplateBinding Background}"
CornerRadius="9"
Padding="{TemplateBinding Padding}"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border CornerRadius="9,9,0,0" Height="14" VerticalAlignment="Top"
IsHitTestVisible="False" Margin="1,1,1,0"
SnapsToDevicePixels="True"
RenderOptions.ClearTypeHint="Enabled">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#33FFFFFF" Offset="0"/>
<GradientStop Color="#0AFFFFFF" Offset="0.6"/>
<GradientStop Color="#00FFFFFF" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
</Border>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="BtnBorder" Property="Opacity" Value="0.88"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="BtnBorder" Property="Opacity" Value="0.72"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="BtnBorder" Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<!-- Primary button (aqua blue) -->
<Style x:Key="PrimaryBtn" TargetType="Button">
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="14,9"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template" Value="{StaticResource GlassyBtnTemplate}"/>
<Setter Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#005F73" Offset="0"/>
<GradientStop Color="#0A9396" Offset="1"/>
</LinearGradientBrush>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- Accent button (Connect - aqua blue with glow) -->
<Style x:Key="AccentBtn" TargetType="Button" BasedOn="{StaticResource PrimaryBtn}">
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect BlurRadius="18" ShadowDepth="0" Color="#0A9396" Opacity="0.30"/>
</Setter.Value>
</Setter>
</Style>
<!-- Disconnect button (amber) -->
<Style x:Key="AmberBtn" TargetType="Button">
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="14,9"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template" Value="{StaticResource GlassyBtnTemplate}"/>
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect BlurRadius="14" ShadowDepth="0" Color="#F59E0B" Opacity="0.22"/>
</Setter.Value>
</Setter>
<Setter Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#78350F" Offset="0"/>
<GradientStop Color="#D97706" Offset="1"/>
</LinearGradientBrush>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- Stop button (blood red) -->
<Style x:Key="StopBtn" TargetType="Button">
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="12,9"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template" Value="{StaticResource GlassyBtnTemplate}"/>
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect BlurRadius="16" ShadowDepth="0" Color="#DC2626" Opacity="0.30"/>
</Setter.Value>
</Setter>
<Setter Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#7F1D1D" Offset="0"/>
<GradientStop Color="#DC2626" Offset="1"/>
</LinearGradientBrush>
</Setter.Value>
</Setter>
</Style>
<!-- Ghost / icon button -->
<Style x:Key="GhostBtn" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="#4A7060"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
CornerRadius="7" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#E8F5E8"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Outline button -->
<Style x:Key="OutlineBtn" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="#22C55E"/>
<Setter Property="BorderBrush" Value="#22C55E"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="12,7"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="7" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#D4EDDA"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Op tile button (sidebar) -->
<Style x:Key="OpTileBtn" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="#374151"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid>
<Border x:Name="AccentBar" Width="3" HorizontalAlignment="Left"
VerticalAlignment="Stretch" CornerRadius="0,2,2,0" Opacity="0"/>
<Border x:Name="TileBg" Background="{TemplateBinding Background}"
BorderBrush="#B0D0B0" BorderThickness="1"
CornerRadius="8" Margin="0,1,0,1" Padding="10,9,10,9">
<ContentPresenter HorizontalAlignment="Stretch"/>
</Border>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="TileBg" Property="Background" Value="#E8F5E8"/>
<Setter TargetName="TileBg" Property="BorderBrush" Value="#B0D0B0"/>
<Setter TargetName="AccentBar" Property="Opacity" Value="0.6"/>
<Setter TargetName="AccentBar" Property="Background" Value="#22C55E"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="TileBg" Property="Background" Value="#D4EDDA"/>
<Setter TargetName="TileBg" Property="BorderBrush" Value="#86C18A"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- TextBox -->
<Style x:Key="InputBox" TargetType="TextBox">
<Setter Property="Background" Value="#FFFFFF"/>
<Setter Property="Foreground" Value="#374151"/>
<Setter Property="BorderBrush" Value="#B0D0B0"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="CaretBrush" Value="#22C55E"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8">
<Grid TextElement.Foreground="{TemplateBinding Foreground}">
<ScrollViewer x:Name="PART_ContentHost" TextElement.Foreground="{TemplateBinding Foreground}" Margin="{TemplateBinding Padding}"
VerticalAlignment="Center"/>
<TextBlock x:Name="Placeholder" Text="{TemplateBinding Tag}"
Foreground="#9CA3AF" FontSize="{TemplateBinding FontSize}"
Margin="{TemplateBinding Padding}" VerticalAlignment="Top"
IsHitTestVisible="False" TextWrapping="Wrap" Visibility="Collapsed"/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="#4022C55E"/>
<Setter Property="Foreground" Value="#1A1F2E"/>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="Text" Value=""/>
<Condition Property="IsFocused" Value="False"/>
</MultiTrigger.Conditions>
<Setter TargetName="Placeholder" Property="Visibility" Value="Visible"/>
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ComboBox -->
<Style x:Key="StyledCombo" TargetType="ComboBox">
<Setter Property="Background" Value="#FFFFFF"/>
<Setter Property="Foreground" Value="#374151"/>
<Setter Property="BorderBrush" Value="#B0D0B0"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="FontSize" Value="12"/>
</Style>
<!-- Field label -->
<Style x:Key="FieldLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="#14532D"/>
<Setter Property="FontSize" Value="10"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Margin" Value="0,0,0,5"/>
</Style>
<!-- Card panel -->
<Style x:Key="Card" TargetType="Border">
<Setter Property="Background" Value="#FFFFFF"/>
<Setter Property="BorderBrush" Value="#B0D0B0"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="Padding" Value="18"/>
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect BlurRadius="20" ShadowDepth="4" Direction="270"
Color="#000000" Opacity="0.35"/>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Grid>
<!-- Background image layer -->
<Image x:Name="BgImage" Stretch="UniformToFill"
Opacity="$($DevConfig.BackgroundImageOpacity)"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
IsHitTestVisible="False" SnapsToDevicePixels="True"/>
<DockPanel>
<!-- HEADER BAR -->
<Border x:Name="HeaderBorder" DockPanel.Dock="Top" Padding="18,14" SnapsToDevicePixels="True" RenderOptions.ClearTypeHint="Enabled">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#006400" Offset="0"/>
<GradientStop Color="#009900" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<Border.BorderBrush>
<SolidColorBrush Color="#0FFFFFFF"/>
</Border.BorderBrush>
<Border.BorderThickness>0,0,0,1</Border.BorderThickness>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Width="Auto" MaxWidth="$($DevConfig.LogoWidth)"
Height="$($DevConfig.LogoHeight)" Margin="0,0,0,0"
VerticalAlignment="Center" ClipToBounds="True" Background="Transparent">
<Image x:Name="LogoImg" Height="$($DevConfig.LogoHeight)" MaxWidth="$($DevConfig.LogoWidth)"
Stretch="Uniform" HorizontalAlignment="Left"
VerticalAlignment="Center" Visibility="Collapsed"/>
</Border>
<!-- Title + bolt + status -->
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="$($DevConfig.LogoTitleSpacing),0,0,0">
<StackPanel Orientation="Horizontal">
<TextBlock Text="$($DevConfig.WindowTitle)" Foreground="#F1F9F1"
FontSize="17" FontWeight="Black" VerticalAlignment="Center"/>
<TextBlock Text=" &#x26A1;" Foreground="#FFD700" FontSize="15"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock x:Name="TxtStatusBar" Text="Not connected"
Foreground="#CCFFCC" FontSize="11" Margin="0,2,0,0"/>
</StackPanel>
<Border Grid.Column="2"/>
<!-- Button cluster -->
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="BtnConnect" Content="Connect" Style="{StaticResource AccentBtn}"
Width="90" Margin="0,0,7,0"/>
<Button x:Name="BtnClearInputs" Content="x Clear" Visibility="Collapsed"
Padding="10,9" FontSize="12" FontWeight="Bold" Cursor="Hand"
Width="80" Margin="0,0,7,0" Foreground="White" BorderThickness="0">
<Button.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#374151" Offset="0"/>
<GradientStop Color="#6B7280" Offset="1"/>
</LinearGradientBrush>
</Button.Background>
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="9"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Opacity" Value="0.85"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
<Button x:Name="BtnStop" Style="{StaticResource StopBtn}"
Visibility="Collapsed" Width="88" Margin="0,0,7,0">
<Button.Content>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE71A;" FontFamily="Segoe MDL2 Assets" FontSize="12"
VerticalAlignment="Center" Margin="0,0,5,0" Foreground="White"/>
<TextBlock Text="Stop" VerticalAlignment="Center"/>
</StackPanel>
</Button.Content>
</Button>
<Button x:Name="BtnDisconnect" Content="Disconnect" Style="{StaticResource AmberBtn}"
Width="106" IsEnabled="False" Margin="0,0,7,0"/>
<Button x:Name="BtnFeedback"
Padding="10,9" FontSize="11" FontWeight="SemiBold" Cursor="Hand"
Width="96" Margin="0,0,7,0" Foreground="#1C1C00" BorderThickness="1"
BorderBrush="#D4A017" ToolTip="Open feedback link">
<Button.Content>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE72D;" FontFamily="Segoe MDL2 Assets" FontSize="12"
VerticalAlignment="Center" Margin="0,0,5,0"
Foreground="#1C1C00"/>
<TextBlock Text="Feedback" VerticalAlignment="Center"/>
</StackPanel>
</Button.Content>
<Button.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#FFCE3A" Offset="0"/>
<GradientStop Color="#F5A800" Offset="1"/>
</LinearGradientBrush>
</Button.Background>
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="FbBg" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="9" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="FbBg" Property="Background" Value="#EDBA00"/>
<Setter TargetName="FbBg" Property="BorderBrush" Value="#B8860B"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="FbBg" Property="Background" Value="#CC9900"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
<Button x:Name="BtnSessionNotes"
Width="30" Height="30" Margin="2,0,4,0"
Background="Transparent" BorderThickness="0" Cursor="Hand"
VerticalAlignment="Center"
ToolTip="Session Notes (in-memory only — cleared on close)">
<TextBlock Text="&#x1F5D2;" FontSize="15" Foreground="#B3FFB3"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Button>
<Button x:Name="BtnAuthCancel" Style="{StaticResource StopBtn}"
Visibility="Collapsed" Width="114" FontSize="11">
<Button.Content>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE71A;" FontFamily="Segoe MDL2 Assets" FontSize="12"
VerticalAlignment="Center" Margin="0,0,5,0" Foreground="White"/>
<TextBlock Text="Cancel Auth" VerticalAlignment="Center"/>
</StackPanel>
</Button.Content>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- Author attribution strip -->
<Border DockPanel.Dock="Top" Padding="16,5" Background="#F8FAFC">
<Border.BorderBrush><SolidColorBrush Color="#20000000"/></Border.BorderBrush>
<Border.BorderThickness>0,0,0,1</Border.BorderThickness>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<TextBlock Text="Author: " FontSize="10.5" Foreground="#6B7280" VerticalAlignment="Center" FontWeight="Normal"/>
<TextBlock x:Name="TxtAuthorLink" Text="Satish Singhi" FontSize="10.5" Foreground="#0A66C2" FontWeight="SemiBold"
VerticalAlignment="Center" Cursor="Hand" TextDecorations="Underline"
ToolTip="https://www.linkedin.com/in/satish-singhi-791163167/"/>
<TextBlock Text=" &#x1F517;" FontSize="10" Foreground="#0A66C2" VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- Notification strip -->
<Border x:Name="NotifStrip" DockPanel.Dock="Top" Visibility="Collapsed"
Padding="16,8" Background="#FFFBEB">
<Border.BorderBrush><SolidColorBrush Color="#40F59E0B"/></Border.BorderBrush>
<Border.BorderThickness>0,0,0,1</Border.BorderThickness>
<TextBlock x:Name="TxtNotif" Foreground="#F59E0B" FontSize="12" TextWrapping="Wrap"/>
</Border>
<!-- PIM role strip -->
<Border x:Name="PimStrip" DockPanel.Dock="Top" Visibility="Collapsed"
Padding="18,0" Background="#EFF7EF">
<Border.BorderBrush><SolidColorBrush Color="#0FFFFFFF"/></Border.BorderBrush>
<Border.BorderThickness>0,0,0,1</Border.BorderThickness>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="&#x1F511; Active PIM Roles"
Foreground="#14532D" FontSize="10" FontWeight="Bold"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<ScrollViewer Grid.Column="1" HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled" Margin="0,5,0,5">
<StackPanel x:Name="SpPimRoles" Orientation="Horizontal"/>
</ScrollViewer>
<Button x:Name="BtnPimRefresh" Grid.Column="2" Content="&#x21BB;"
Style="{StaticResource GhostBtn}"
Foreground="#14532D" FontSize="14" Padding="6,4"
ToolTip="Refresh PIM roles"/>
</Grid>
</Border>
<!-- LOG PANE (bottom docked, resizable) -->
<Grid DockPanel.Dock="Bottom">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto" MinHeight="0"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Padding="12,5" Cursor="SizeNS">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#D4EDDA" Offset="0"/>
<GradientStop Color="#C8E6C9" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<Border.BorderBrush><SolidColorBrush Color="#80B0D0B0"/></Border.BorderBrush>
<Border.BorderThickness>0,1,0,0</Border.BorderThickness>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<Border Width="6" Height="6" CornerRadius="3" Background="#166534"
Margin="0,0,8,0" VerticalAlignment="Center">
<Border.Effect>
<DropShadowEffect BlurRadius="4" ShadowDepth="0" Color="#22C55E" Opacity="0.8"/>
</Border.Effect>
</Border>
<TextBlock Text="VERBOSE LOG" Foreground="#132210" FontSize="10"
FontWeight="Bold" FontFamily="Consolas" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal">
<Button x:Name="BtnLogClear" Content="Clear" Style="{StaticResource GhostBtn}"
Foreground="#132210" FontSize="10" Padding="6,3" Margin="0,0,4,0"/>
<Button x:Name="BtnLogToggle" Content="&#x25BC; Hide"
Style="{StaticResource GhostBtn}"
Foreground="#132210" FontSize="10" Padding="6,3"/>
</StackPanel>
</Grid>
</Border>
<Border Grid.Row="1" x:Name="LogPane" Background="#0D1824"
Height="$($DevConfig.VerbosePaneDefaultHeight)">
<RichTextBox x:Name="RtbLog" Background="Transparent" BorderThickness="0"
IsReadOnly="True" FontFamily="Consolas" FontSize="11"
Foreground="#9DB8D8" Padding="14,8"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
IsDocumentEnabled="True"/>
</Border>
<GridSplitter Grid.Row="0" Height="5" HorizontalAlignment="Stretch"
Background="Transparent" Cursor="SizeNS" VerticalAlignment="Bottom"
ResizeDirection="Rows" ResizeBehavior="PreviousAndNext"/>
</Grid>
<!-- MAIN CONTENT AREA -->
<Grid Margin="16,12,16,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="224"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Left nav -->
<ScrollViewer Grid.Column="0" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled" PanningMode="VerticalOnly">
<StackPanel>
<TextBlock Text="OPERATIONS" Foreground="#0F1F14" FontSize="9.5"
FontWeight="Black" Margin="10,0,0,8"/>
<!-- Group Management blade -->
<Border Background="#F5FBF5" BorderBrush="#B0D0B0"
BorderThickness="1" CornerRadius="10" Margin="0,0,0,6">
<StackPanel>
<Button x:Name="BtnBladeGroupMgmt" Background="#E8F5E8"
BorderThickness="0" Cursor="Hand" Padding="10,10">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="9,9,0,0" Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#D4EDDA"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<TextBlock Text="&#x1F5C2;" FontSize="14" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="Group Management" FontWeight="Bold" FontSize="12"
Foreground="#166534" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock x:Name="TxtBladeArrow" Grid.Column="1" Text="&#x2212;"
FontSize="15" FontWeight="Bold" Foreground="#2A7A4A" VerticalAlignment="Center"/>
</Grid>
</Button>
<Border x:Name="BladeDivider" Height="1"><Border.Background><SolidColorBrush Color="#B0D0B0"/></Border.Background>
</Border>
<StackPanel x:Name="BladeGroupMgmtContent" Margin="6,5,6,6">
<Button x:Name="BtnOpSearch" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x1F50D;" FontSize="13" Foreground="#0EA5E9" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Search Entra Objects" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Users · Devices · Groups" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpListMembers" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x1F4CB;" FontSize="13" Foreground="#8B5CF6" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="List Group Members" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Users · Devices · Groups" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpObjectMembership" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x1F517;" FontSize="13" Foreground="#D97706" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Object Membership" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Transitive group memberships" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpFindGroupsByOwners" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x1F464;" FontSize="13" Foreground="#7C3AED" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Find Groups by Owners" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Groups owned by specified UPNs" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpCreate" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x2295;" FontSize="14" Foreground="#22C55E" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Create Group" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Security or M365" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpAdd" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x2295;" FontSize="14" Foreground="#3B82F6" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Add Members" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Users &#xB7; Groups &#xB7; Devices" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpRemove" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x2296;" FontSize="14" Foreground="#EF4444" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Remove Members" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Users &#xB7; Groups &#xB7; Devices" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpExport" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x2191;" FontSize="15" Foreground="#F59E0B" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Export Members" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="To XLSX file" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpDynamic" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x26A1;" FontSize="13" Foreground="#8B5CF6" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Set Dynamic Query" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="MembershipRule string" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpRename" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x270F;" FontSize="13" Foreground="#F59E0B" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Rename Group" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Change display name" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpOwner" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x2299;" FontSize="14" Foreground="#3B82F6" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Set Group Owner" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Assign owner UPN(s)" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpUserDevices" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x25A3;" FontSize="13" Foreground="#6366F1" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Add User Devices" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="By UPN &#xB7; Platform &#xB7; Ownership" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpFindCommon" Style="{StaticResource OpTileBtn}">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x2229;" FontSize="15" Foreground="#0EA5E9" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Find Common Groups" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Groups shared by all inputs" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpFindDistinct" Style="{StaticResource OpTileBtn}">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x2296;" FontSize="15" Foreground="#7C3AED" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Find Distinct Groups" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Groups unique to each input" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpCompareGroups" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x21D4;" FontSize="13" Foreground="#D97706" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Compare Groups" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Membership across groups" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpRemoveGroups" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x1F5D1;" FontSize="13" Foreground="#DC2626" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Remove Groups" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Delete multiple Entra groups" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</Border>
<!-- Device Management blade -->
<Border Background="#F5FBF5" BorderBrush="#B0D0B0"
BorderThickness="1" CornerRadius="10" Margin="0,0,0,6">
<StackPanel>
<Button x:Name="BtnBladeDevMgmt" Background="#E8F5E8"
BorderThickness="0" Cursor="Hand" Padding="10,10">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="9,9,0,0" Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#D4EDDA"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<TextBlock Text="&#x1F4BB;" FontSize="14" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="Device Management" FontWeight="Bold" FontSize="12"
Foreground="#166534" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock x:Name="TxtBladeDevArrow" Grid.Column="1" Text="&#x2212;"
FontSize="15" FontWeight="Bold" Foreground="#2A7A4A" VerticalAlignment="Center"/>
</Grid>
</Button>
<Border x:Name="BladeDevDivider" Height="1"><Border.Background><SolidColorBrush Color="#B0D0B0"/></Border.Background>
</Border>
<StackPanel x:Name="BladeDevMgmtContent" Margin="6,5,6,6">
<Button x:Name="BtnOpGetPolicyAssignments" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x1F4CB;" FontSize="13" Foreground="#7C3AED" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Get Policy Assignments" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Intune policy assignment report" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpGetDeviceInfo" Style="{StaticResource OpTileBtn}">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x2139;" FontSize="14" Foreground="#14B8A6" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Get Device Info" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Entra + Intune combined report" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
<Button x:Name="BtnOpGetDiscoveredApps" Style="{StaticResource OpTileBtn}" Margin="0,1,0,0">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x1F4E6;" FontSize="13" Foreground="#7C3AED" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Get Discovered Apps" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
</StackPanel>
<TextBlock Text="Apps detected on managed devices" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<!-- Separator -->
<Border Grid.Column="1" Width="1" HorizontalAlignment="Center">
<Border.Background><SolidColorBrush Color="#D0D8D0"/></Border.Background>
</Border>
<!-- Right content: op panels -->
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<Grid>
<!-- === WELCOME panel === -->
<Border x:Name="PanelWelcome" Style="{StaticResource Card}" Visibility="Visible">
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Margin="0,40">
<TextBlock Text="Welcome to $($DevConfig.WindowTitle)"
FontSize="18" FontWeight="SemiBold"
Foreground="$($DevConfig.ColorPrimary)"
HorizontalAlignment="Center"/>
<TextBlock Text="Connect to Microsoft Graph, then select an operation from the left panel."
FontSize="13" Foreground="$($DevConfig.ColorTextMuted)"
HorizontalAlignment="Center" Margin="0,8,0,0" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<!-- === SEARCH ENTRA OBJECTS panel === -->
<Border x:Name="PanelSearch" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Search Entra Objects" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock Text="Search for Users, Devices, Security Groups and M365 Groups by display name."
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,16" TextWrapping="Wrap"/>
<StackPanel Margin="0,0,0,12">
<TextBlock Text="SEARCH KEYWORD *" Style="{StaticResource FieldLabel}"/>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TxtSearchKeyword" Grid.Column="0" Style="{StaticResource InputBox}"
Tag="Display name, UPN, email address or device name"/>
<Button x:Name="BtnEntraSearch" Grid.Column="2"
Content="&#x1F50D; Search"
Style="{StaticResource PrimaryBtn}" Width="110"/>
</Grid>
</StackPanel>
<StackPanel Margin="0,0,0,16">
<TextBlock Text="OBJECT TYPE FILTER (none checked = all types)" Style="{StaticResource FieldLabel}"/>
<WrapPanel Margin="0,6,0,0">
<CheckBox x:Name="ChkSrchUsers" Content="Users" IsChecked="True" Margin="0,0,20,4" FontSize="12"/>
<CheckBox x:Name="ChkSrchDevices" Content="Devices" IsChecked="True" Margin="0,0,20,4" FontSize="12"/>
<CheckBox x:Name="ChkSrchSG" Content="Security Groups" IsChecked="True" Margin="0,0,20,4" FontSize="12"/>
<CheckBox x:Name="ChkSrchM365" Content="M365 Groups" IsChecked="True" Margin="0,0,0,4" FontSize="12"/>
</WrapPanel>
<CheckBox x:Name="ChkSrchExactMatch" Content="Exact Match only" IsChecked="False" Margin="0,8,0,0" FontSize="12"/>
</StackPanel>
<Border x:Name="PnlSearchResults" Visibility="Collapsed">
<StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TxtSearchCount" Grid.Column="0"
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button x:Name="BtnSearchFilterClear" Content="&#x2715;"
Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
IsEnabled="False" Height="30" Width="30" FontSize="12"
Padding="0" Margin="0,0,4,0"/>
<TextBox x:Name="TxtSearchFilter" Width="150" Height="30" FontSize="12"
VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
IsEnabled="False" Tag="keyword filter..."/>
<Button x:Name="BtnSearchFilter" Content="&#x1F50D; Filter"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,12,0"/>
<Button x:Name="BtnSearchCopyValue"
Content="Copy Value"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnSearchCopyRow"
Content="Copy Row"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnSearchCopyAll"
Content="Copy All"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnSearchExportXlsx"
Content="Export XLSX"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4"/>
</StackPanel>
</Grid>
<DataGrid x:Name="DgSearchResults"
AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
HeadersVisibility="Column"
GridLinesVisibility="All"
VerticalGridLinesBrush="#555555"
HorizontalGridLinesBrush="#D0D0D0"
RowBackground="White" AlternatingRowBackground="#F5FBF5"
BorderBrush="#B0D0B0" BorderThickness="1"
CanUserReorderColumns="False" CanUserResizeRows="False"
CanUserSortColumns="True" MaxHeight="420" FontSize="12">
<DataGrid.ColumnHeaderStyle>
<Style TargetType="DataGridColumnHeader">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Background" Value="#E8F5E9"/>
<Setter Property="Foreground" Value="#14532D"/>
<Setter Property="Padding" Value="8,5"/>
<Setter Property="BorderBrush" Value="#555555"/>
<Setter Property="BorderThickness" Value="0,0,1,1"/>
</Style>
</DataGrid.ColumnHeaderStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="Object Name" Binding="{Binding DisplayName}" Width="*" MinWidth="160"/>
<DataGridTextColumn Header="Details" Binding="{Binding Detail}" Width="220"/>
<DataGridTextColumn Header="Object Type" Binding="{Binding ObjectType}" Width="140"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<TextBlock x:Name="TxtSearchNoResults" Visibility="Collapsed"
Text="No matching objects found." FontSize="12"
Foreground="#6B7280" Margin="0,10,0,0"/>
</StackPanel>
</Border>
<!-- === LIST GROUP MEMBERS panel === -->
<Border x:Name="PanelListMembers" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="List Group Members" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock Text="Query members from one or more groups. Enter display names or Object IDs, one per line."
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,16" TextWrapping="Wrap"/>
<!-- Group input -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="GROUPS * (one per line — display name or Object ID)" Style="{StaticResource FieldLabel}"/>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TxtLMGroupInput" Grid.Column="0" Style="{StaticResource InputBox}"
Height="80" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto"
Tag="SG-Marketing-All&#10;a1b2c3d4-0000-0000-0000-000000000000&#10;IT-Devices"/>
<Button x:Name="BtnLMQuery" Grid.Column="2"
Content="&#x1F4CB; List Members"
Style="{StaticResource PrimaryBtn}" Width="130" VerticalAlignment="Top"/>
</Grid>
</StackPanel>
<!-- Output type filter -->
<StackPanel Margin="0,0,0,16">
<TextBlock Text="OUTPUT TYPE FILTER (none checked = all types)" Style="{StaticResource FieldLabel}"/>
<WrapPanel Margin="0,6,0,0">
<CheckBox x:Name="ChkLMUsers" Content="Users" IsChecked="True" Margin="0,0,20,4" FontSize="12"/>
<CheckBox x:Name="ChkLMDevices" Content="Devices" IsChecked="True" Margin="0,0,20,4" FontSize="12"/>
<CheckBox x:Name="ChkLMGroups" Content="Security &amp; M365 Groups" IsChecked="True" Margin="0,0,0,4" FontSize="12"/>
</WrapPanel>
</StackPanel>
<!-- Progress overlay (shown while querying) -->
<Border x:Name="PnlLMProgress" Visibility="Collapsed"
Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
<StackPanel>
<TextBlock x:Name="TxtLMProgressMsg" Text="Querying groups..."
Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ProgressBar x:Name="PbLMProgress" Height="6" IsIndeterminate="True"
Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
<TextBlock x:Name="TxtLMProgressDetail" Text=""
Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
</StackPanel>
</Border>
<!-- Results area -->
<Border x:Name="PnlLMResults" Visibility="Collapsed">
<StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TxtLMCount" Grid.Column="0"
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button x:Name="BtnLMFilterClear" Content="&#x2715;"
Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
IsEnabled="False" Height="30" Width="30" FontSize="12"
Padding="0" Margin="0,0,4,0"/>
<TextBox x:Name="TxtLMFilter" Width="150" Height="30" FontSize="12"
VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
IsEnabled="False" Tag="keyword filter..."/>
<Button x:Name="BtnLMFilter" Content="&#x1F50D; Filter"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,12,0"/>
<Button x:Name="BtnLMCopyValue" Content="Copy Value"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnLMCopyRow" Content="Copy Row"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnLMCopyAll" Content="Copy All"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnLMExportXlsx" Content="Export XLSX"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4"/>
</StackPanel>
</Grid>
<DataGrid x:Name="DgLMResults"
AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
HeadersVisibility="Column"
GridLinesVisibility="All"
VerticalGridLinesBrush="#555555"
HorizontalGridLinesBrush="#D0D0D0"
RowBackground="White" AlternatingRowBackground="#F5FBF5"
BorderBrush="#B0D0B0" BorderThickness="1"
CanUserReorderColumns="False" CanUserResizeRows="False"
CanUserSortColumns="True" MaxHeight="420" FontSize="12">
<DataGrid.ColumnHeaderStyle>
<Style TargetType="DataGridColumnHeader">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Background" Value="#E8F5E9"/>
<Setter Property="Foreground" Value="#14532D"/>
<Setter Property="Padding" Value="8,5"/>
<Setter Property="BorderBrush" Value="#555555"/>
<Setter Property="BorderThickness" Value="0,0,1,1"/>
</Style>
</DataGrid.ColumnHeaderStyle>
<!-- Columns built dynamically in code-behind based on result schema -->
</DataGrid>
</StackPanel>
</Border>
<TextBlock x:Name="TxtLMNoResults" Visibility="Collapsed"
Text="No members found matching the current filters."
FontSize="12" Foreground="#6B7280" Margin="0,10,0,0"/>
</StackPanel>
</Border>
<!-- === OBJECT MEMBERSHIP panel === -->
<Border x:Name="PanelObjectMembership" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Object Membership" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock Text="Resolve transitive group memberships for users, devices, or groups. Enter identifiers one per line."
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>
<!-- Object type -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="OBJECT TYPE" Style="{StaticResource FieldLabel}"/>
<ComboBox x:Name="CmbOMObjectType" Style="{StaticResource StyledCombo}"
Width="260" HorizontalAlignment="Left" Margin="0,4,0,0">
<ComboBoxItem Content="Users (UPN)" IsSelected="True"/>
<ComboBoxItem Content="Devices (Name)"/>
<ComboBoxItem Content="Devices (ID)"/>
<ComboBoxItem Content="Security Groups"/>
<ComboBoxItem Content="M365 Groups"/>
</ComboBox>
</StackPanel>
<!-- Input list -->
<StackPanel Margin="0,0,0,12">
<TextBlock x:Name="TxtOMInputLabel" Text="USER UPNs - one per line"
Style="{StaticResource FieldLabel}"/>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TxtOMInputList" Grid.Column="0" Style="{StaticResource InputBox}"
Height="110" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto" Tag="user@domain.com"/>
<Button x:Name="BtnOMRun" Grid.Column="2"
Content="&#x25B6; Get Memberships"
Style="{StaticResource PrimaryBtn}" Width="180" VerticalAlignment="Top"/>
</Grid>
</StackPanel>
<!-- Progress -->
<Border x:Name="PnlOMProgress" Visibility="Collapsed"
Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
<StackPanel>
<TextBlock x:Name="TxtOMProgressMsg" Text="Resolving objects..."
Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ProgressBar x:Name="PbOMProgress" Height="6" IsIndeterminate="True"
Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
<TextBlock x:Name="TxtOMProgressDetail" Text=""
Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
</StackPanel>
</Border>
<!-- Results -->
<Border x:Name="PnlOMResults" Visibility="Collapsed">
<StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TxtOMCount" Grid.Column="0"
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button x:Name="BtnOMFilterClear" Content="&#x2715;"
Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
IsEnabled="False" Height="30" Width="30" FontSize="12"
Padding="0" Margin="0,0,4,0"/>
<TextBox x:Name="TxtOMFilter" Width="150" Height="30" FontSize="12"
VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
IsEnabled="False" Tag="keyword filter..."/>
<Button x:Name="BtnOMFilter" Content="&#x1F50D; Filter"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,12,0"/>
<Button x:Name="BtnOMCopyValue" Content="Copy Value"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnOMCopyRow" Content="Copy Row"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnOMCopyAll" Content="Copy All"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnOMExportXlsx" Content="Export XLSX"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4"/>
</StackPanel>
</Grid>
<DataGrid x:Name="DgOMResults"
AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
HeadersVisibility="Column"
GridLinesVisibility="All"
VerticalGridLinesBrush="#555555"
HorizontalGridLinesBrush="#D0D0D0"
RowBackground="White" AlternatingRowBackground="#F5FBF5"
BorderBrush="#B0D0B0" BorderThickness="1"
CanUserReorderColumns="False" CanUserResizeRows="False"
CanUserSortColumns="True" MaxHeight="420" FontSize="12">
<DataGrid.ColumnHeaderStyle>
<Style TargetType="DataGridColumnHeader">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Background" Value="#E8F5E9"/>
<Setter Property="Foreground" Value="#14532D"/>
<Setter Property="Padding" Value="8,5"/>
<Setter Property="BorderBrush" Value="#555555"/>
<Setter Property="BorderThickness" Value="0,0,1,1"/>
</Style>
</DataGrid.ColumnHeaderStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="Input Object" Binding="{Binding InputObject}" Width="2*" MinWidth="150"/>
<DataGridTextColumn Header="Object Type" Binding="{Binding ObjectType}" Width="*" MinWidth="80"/>
<DataGridTextColumn Header="Group Name" Binding="{Binding GroupName}" Width="2*" MinWidth="160"/>
<DataGridTextColumn Header="Group ID" Binding="{Binding GroupId}" Width="2*" MinWidth="130"/>
<DataGridTextColumn Header="Group Type" Binding="{Binding GroupType}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="Description" Binding="{Binding Description}" Width="2*" MinWidth="150"/>
<DataGridTextColumn Header="Membership Type" Binding="{Binding MembershipType}" Width="*" MinWidth="100"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<TextBlock x:Name="TxtOMNoResults" Visibility="Collapsed"
Text="No group memberships found for the provided input."
FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<!-- === FIND GROUPS BY OWNERS panel === -->
<Border x:Name="PanelFindGroupsByOwners" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Find Groups by Owners" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock Text="Enter owner UPNs (one per line) to find all groups they own."
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>
<!-- Input list -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="OWNER UPNs - one per line" Style="{StaticResource FieldLabel}"/>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TxtFGOInputList" Grid.Column="0" Style="{StaticResource InputBox}"
Height="110" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto" Tag="user@domain.com"/>
<Button x:Name="BtnFGORun" Grid.Column="2"
Content="&#x25B6; Find Groups"
Style="{StaticResource PrimaryBtn}" Width="180" VerticalAlignment="Top"/>
</Grid>
</StackPanel>
<!-- Progress -->
<Border x:Name="PnlFGOProgress" Visibility="Collapsed"
Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
<StackPanel>
<TextBlock x:Name="TxtFGOProgressMsg" Text="Resolving owners..."
Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ProgressBar x:Name="PbFGOProgress" Height="6" IsIndeterminate="True"
Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
<TextBlock x:Name="TxtFGOProgressDetail" Text=""
Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
</StackPanel>
</Border>
<!-- Results -->
<Border x:Name="PnlFGOResults" Visibility="Collapsed">
<StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TxtFGOCount" Grid.Column="0"
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button x:Name="BtnFGOFilterClear" Content="&#x2715;"
Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
IsEnabled="False" Height="30" Width="30" FontSize="12"
Padding="0" Margin="0,0,4,0"/>
<TextBox x:Name="TxtFGOFilter" Width="150" Height="30" FontSize="12"
VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
IsEnabled="False" Tag="keyword filter..."/>
<Button x:Name="BtnFGOFilter" Content="&#x1F50D; Filter"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,12,0"/>
<Button x:Name="BtnFGOCopyValue" Content="Copy Value"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnFGOCopyRow" Content="Copy Row"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnFGOCopyAll" Content="Copy All"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnFGOExportXlsx" Content="Export XLSX"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4"/>
</StackPanel>
</Grid>
<DataGrid x:Name="DgFGOResults"
AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
HeadersVisibility="Column"
GridLinesVisibility="All"
VerticalGridLinesBrush="#555555"
HorizontalGridLinesBrush="#D0D0D0"
RowBackground="White" AlternatingRowBackground="#F5FBF5"
BorderBrush="#B0D0B0" BorderThickness="1"
CanUserReorderColumns="False" CanUserResizeRows="False"
CanUserSortColumns="True" MaxHeight="420" FontSize="12">
<DataGrid.ColumnHeaderStyle>
<Style TargetType="DataGridColumnHeader">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Background" Value="#E8F5E9"/>
<Setter Property="Foreground" Value="#14532D"/>
<Setter Property="Padding" Value="8,5"/>
<Setter Property="BorderBrush" Value="#555555"/>
<Setter Property="BorderThickness" Value="0,0,1,1"/>
</Style>
</DataGrid.ColumnHeaderStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="Owner" Binding="{Binding Owner}" Width="2*" MinWidth="160"/>
<DataGridTextColumn Header="Group Name" Binding="{Binding GroupName}" Width="2*" MinWidth="160"/>
<DataGridTextColumn Header="Group ID" Binding="{Binding GroupId}" Width="2*" MinWidth="130"/>
<DataGridTextColumn Header="Group Type" Binding="{Binding GroupType}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="Membership Type" Binding="{Binding MembershipType}" Width="*" MinWidth="110"/>
<DataGridTextColumn Header="Member Count" Binding="{Binding MemberCount}" Width="*" MinWidth="90"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<TextBlock x:Name="TxtFGONoResults" Visibility="Collapsed"
Text="No owned groups found for the provided UPNs."
FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<!-- === CREATE GROUP panel === -->
<Border x:Name="PanelCreate" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Create Group" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,12"
Foreground="$($DevConfig.ColorPrimary)"/>
<!-- Group type -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="GROUP TYPE" Style="{StaticResource FieldLabel}"/>
<ComboBox x:Name="CmbCreateType" Style="{StaticResource StyledCombo}" Width="220"
HorizontalAlignment="Left">
<ComboBoxItem Content="Security" IsSelected="True"/>
<ComboBoxItem Content="Microsoft 365 (M365)"/>
</ComboBox>
</StackPanel>
<!-- Display name -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="DISPLAY NAME *" Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="TxtCreateName" Style="{StaticResource InputBox}"
Tag="e.g. SG-Marketing-All"/>
</StackPanel>
<!-- M365-only fields (email address + sensitivity label) -->
<StackPanel x:Name="PnlMailNick" Margin="0,0,0,0">
<!-- Group Email Address -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="GROUP EMAIL ADDRESS *" Style="{StaticResource FieldLabel}"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TxtCreateMailNick" Grid.Column="0"
Style="{StaticResource InputBox}"
Tag="mail-nickname (local part only)"/>
<Border Grid.Column="1" Background="$($DevConfig.ColorBackground)"
BorderBrush="$($DevConfig.ColorBorder)" BorderThickness="0,1,1,1"
CornerRadius="0,4,4,0" Padding="10,0,10,0">
<TextBlock x:Name="TxtMailDomainSuffix" VerticalAlignment="Center"
FontSize="13" Foreground="$($DevConfig.ColorTextMuted)"
Text="@"/>
</Border>
</Grid>
</StackPanel>
<!-- Sensitivity Label (populated at runtime; hidden if tenant has none) -->
<StackPanel x:Name="PnlSensitivityLabel" Margin="0,0,0,12" Visibility="Collapsed">
<TextBlock Text="SENSITIVITY LABEL (optional)" Style="{StaticResource FieldLabel}"/>
<ComboBox x:Name="CmbSensitivityLabel" Style="{StaticResource StyledCombo}"
HorizontalAlignment="Left" Width="320">
<ComboBoxItem Content="(None)" IsSelected="True" Tag=""/>
</ComboBox>
</StackPanel>
</StackPanel>
<!-- Description -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="DESCRIPTION" Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="TxtCreateDesc" Style="{StaticResource InputBox}"
Height="60" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto"
Tag="Optional description"/>
</StackPanel>
<!-- Initial owner UPNs -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="INITIAL OWNER UPNs (optional - one per line)"
Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="TxtCreateOwner" Style="{StaticResource InputBox}"
Height="60" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto"
Tag="owner1@domain.com&#10;owner2@domain.com"/>
</StackPanel>
<!-- Initial members -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="INITIAL MEMBERS (optional - one per line, UPNs / Group names or IDs / Device names or IDs)"
Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="TxtCreateMembers" Style="{StaticResource InputBox}"
Height="80" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto"
Tag="user@domain.com&#10;GroupName or GroupObjectID&#10;DeviceName or DeviceObjectID"/>
</StackPanel>
<!-- Dynamic query -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="DYNAMIC MEMBERSHIP RULE (optional - converts group to dynamic)"
Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="TxtCreateDynamic" Style="{StaticResource InputBox}"
Tag='e.g. (user.department -eq "Marketing")'/>
</StackPanel>
<Button Margin="4,4,0,0" x:Name="BtnCreateValidate" Content="Validate &amp; Preview &#x2192;"
Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="180"/>
</StackPanel>
</Border>
<!-- === ADD / REMOVE MEMBERS panel (shared) === -->
<Border x:Name="PanelMembership" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock x:Name="TxtMembershipTitle" Text="Add Members"
FontSize="15" FontWeight="SemiBold"
Foreground="$($DevConfig.ColorPrimary)"/>
<!-- Group picker -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="TARGET GROUP" Style="{StaticResource FieldLabel}"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TxtGroupSearch" Grid.Column="0" Style="{StaticResource InputBox}"
Tag="Type group name, paste Object ID, or enter display name"/>
<Button x:Name="BtnGroupSearch" Grid.Column="2" Content="Search"
Style="{StaticResource OutlineBtn}" Width="70" Margin="0,0,8,0"/>
<Button x:Name="BtnGroupById" Grid.Column="4" Content="Use as ID"
Style="{StaticResource OutlineBtn}" Width="80"/>
</Grid>
<!-- Search results -->
<Border Margin="0,4,0,0" BorderBrush="$($DevConfig.ColorBorder)"
BorderThickness="1" CornerRadius="5"
x:Name="GroupSearchResults" Visibility="Collapsed" MaxHeight="150">
<ListBox x:Name="LstGroupResults" Background="$($DevConfig.ColorSurface)"
BorderThickness="0" FontSize="12"/>
</Border>
<!-- Selected group display -->
<Border x:Name="SelectedGroupBadge" Margin="0,6,0,0" Visibility="Collapsed"
Background="#EEF5FF" BorderBrush="$($DevConfig.ColorPrimary)"
BorderThickness="1" CornerRadius="5" Padding="10,6">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Selected:" FontSize="11"
Foreground="$($DevConfig.ColorTextMuted)"/>
<TextBlock x:Name="TxtSelectedGroupName" FontSize="12" FontWeight="SemiBold"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock x:Name="TxtSelectedGroupId" FontSize="10"
Foreground="$($DevConfig.ColorTextMuted)"/>
<TextBlock x:Name="TxtSelectedGroupMemCount" FontSize="10"
Foreground="$($DevConfig.ColorTextMuted)"/>
</StackPanel>
</Border>
</StackPanel>
<!-- Member input -->
<StackPanel Margin="0,0,0,12">
<TextBlock x:Name="TxtMemberInputLabel"
Text="MEMBERS - one per line (User UPNs / Group names or IDs / Device names or IDs)"
Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="TxtMemberList" Style="{StaticResource InputBox}"
Height="140" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto"
Tag="user@domain.com&#10;GroupName or GroupObjectID&#10;DeviceName or DeviceObjectID"/>
</StackPanel>
<Button Margin="4,4,0,0" x:Name="BtnMemberValidate" Content="Validate &amp; Preview &#x2192;"
Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="180"/>
</StackPanel>
</Border>
<!-- === EXPORT panel === -->
<Border x:Name="PanelExport" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Export Group Members to XLSX"
FontSize="15" FontWeight="SemiBold"
Foreground="$($DevConfig.ColorPrimary)"/>
<!-- Group picker (same pattern) -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="TARGET GROUP" Style="{StaticResource FieldLabel}"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TxtExportGroupSearch" Grid.Column="0"
Style="{StaticResource InputBox}"
Tag="Type group name, paste Object ID, or enter display name"/>
<Button x:Name="BtnExportGroupSearch" Grid.Column="2" Content="Search"
Style="{StaticResource OutlineBtn}" Width="70" Margin="0,0,8,0"/>
<Button x:Name="BtnExportGroupById" Grid.Column="4" Content="Use as ID"
Style="{StaticResource OutlineBtn}" Width="80"/>
</Grid>
<Border Margin="0,4,0,0" BorderBrush="$($DevConfig.ColorBorder)"
BorderThickness="1" CornerRadius="5"
x:Name="ExportGroupSearchResults" Visibility="Collapsed" MaxHeight="150">
<ListBox x:Name="LstExportGroupResults" Background="$($DevConfig.ColorSurface)"
BorderThickness="0" FontSize="12"/>
</Border>
<Border x:Name="ExportSelectedGroupBadge" Margin="0,6,0,0" Visibility="Collapsed"
Background="#EEF5FF" BorderBrush="$($DevConfig.ColorPrimary)"
BorderThickness="1" CornerRadius="5" Padding="10,6">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Selected:" FontSize="11" Foreground="$($DevConfig.ColorTextMuted)"/>
<TextBlock x:Name="TxtExportGroupName" FontSize="12" FontWeight="SemiBold"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock x:Name="TxtExportGroupId" FontSize="10"
Foreground="$($DevConfig.ColorTextMuted)"/>
</StackPanel>
</Border>
</StackPanel>
<!-- Output path -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="OUTPUT XLSX PATH" Style="{StaticResource FieldLabel}"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TxtExportPath" Grid.Column="0"
Style="{StaticResource InputBox}"
Tag="C:\Reports\GroupMembers.xlsx"/>
<Button x:Name="BtnExportBrowse" Grid.Column="2" Content="Browse&#x2026;"
Style="{StaticResource OutlineBtn}" Width="80"/>
</Grid>
</StackPanel>
<Button Margin="4,4,0,0" x:Name="BtnRunExport" Content="Export Members &#x2192;"
Style="{StaticResource AccentBtn}" HorizontalAlignment="Left" Width="160"/>
</StackPanel>
</Border>
<!-- === DYNAMIC QUERY panel === -->
<Border x:Name="PanelDynamic" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Set Dynamic Membership Query"
FontSize="15" FontWeight="SemiBold"
Foreground="$($DevConfig.ColorPrimary)"/>
<!-- Group picker -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="TARGET GROUP" Style="{StaticResource FieldLabel}"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TxtDynGroupSearch" Grid.Column="0"
Style="{StaticResource InputBox}"
Tag="Type group name, paste Object ID, or enter display name"/>
<Button x:Name="BtnDynGroupSearch" Grid.Column="2" Content="Search"
Style="{StaticResource OutlineBtn}" Width="70" Margin="0,0,8,0"/>
<Button x:Name="BtnDynGroupById" Grid.Column="4" Content="Use as ID"
Style="{StaticResource OutlineBtn}" Width="80"/>
</Grid>
<Border Margin="0,4,0,0" BorderBrush="$($DevConfig.ColorBorder)"
BorderThickness="1" CornerRadius="5"
x:Name="DynGroupSearchResults" Visibility="Collapsed" MaxHeight="150">
<ListBox x:Name="LstDynGroupResults" Background="$($DevConfig.ColorSurface)"
BorderThickness="0" FontSize="12"/>
</Border>
<Border x:Name="DynSelectedGroupBadge" Margin="0,6,0,0" Visibility="Collapsed"
Background="#EEF5FF" BorderBrush="$($DevConfig.ColorPrimary)"
BorderThickness="1" CornerRadius="5" Padding="10,6">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Selected:" FontSize="11" Foreground="$($DevConfig.ColorTextMuted)"/>
<TextBlock x:Name="TxtDynGroupName" FontSize="12" FontWeight="SemiBold"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock x:Name="TxtDynGroupId" FontSize="10"
Foreground="$($DevConfig.ColorTextMuted)"/>
<TextBlock x:Name="TxtDynCurrentRule" FontSize="10" FontStyle="Italic"
Foreground="$($DevConfig.ColorTextMuted)"/>
</StackPanel>
</Border>
</StackPanel>
<!-- Rule input -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="MEMBERSHIP RULE" Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="TxtDynRule" Style="{StaticResource InputBox}"
Height="80" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto"
Tag='e.g. (user.department -eq "Marketing") -and (user.accountEnabled -eq true)'/>
</StackPanel>
<TextBlock Margin="0,0,0,12" Text="&#x26A0; Setting a dynamic rule will convert a static group to dynamic. This removes all current members."
FontSize="11" Foreground="$($DevConfig.ColorWarning)"
TextWrapping="Wrap"/>
<Button Margin="4,4,0,0" x:Name="BtnDynValidate" Content="Validate Rule &amp; Preview &#x2192;"
Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="200"/>
</StackPanel>
</Border>
<!-- === RENAME panel === -->
<Border x:Name="PanelRename" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Rename Group" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,12"
Foreground="$($DevConfig.ColorPrimary)"/>
<!-- Group picker -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="TARGET GROUP" Style="{StaticResource FieldLabel}"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TxtRenGroupSearch" Grid.Column="0"
Style="{StaticResource InputBox}"
Tag="Type group name, paste Object ID, or enter display name"/>
<Button x:Name="BtnRenGroupSearch" Grid.Column="2" Content="Search"
Style="{StaticResource OutlineBtn}" Width="70" Margin="0,0,8,0"/>
<Button x:Name="BtnRenGroupById" Grid.Column="4" Content="Use as ID"
Style="{StaticResource OutlineBtn}" Width="80"/>
</Grid>
<Border Margin="0,4,0,0" BorderBrush="$($DevConfig.ColorBorder)"
BorderThickness="1" CornerRadius="5"
x:Name="RenGroupSearchResults" Visibility="Collapsed" MaxHeight="150">
<ListBox x:Name="LstRenGroupResults" Background="$($DevConfig.ColorSurface)"
BorderThickness="0" FontSize="12"/>
</Border>
<Border x:Name="RenSelectedGroupBadge" Margin="0,6,0,0" Visibility="Collapsed"
Background="#EEF5FF" BorderBrush="$($DevConfig.ColorPrimary)"
BorderThickness="1" CornerRadius="5" Padding="10,6">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Current name:" FontSize="11" Foreground="$($DevConfig.ColorTextMuted)"/>
<TextBlock x:Name="TxtRenGroupCurrentName" FontSize="12" FontWeight="SemiBold"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock x:Name="TxtRenGroupId" FontSize="10"
Foreground="$($DevConfig.ColorTextMuted)"/>
</StackPanel>
</Border>
</StackPanel>
<!-- New name -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="NEW DISPLAY NAME *" Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="TxtNewGroupName" Style="{StaticResource InputBox}"
Tag="Enter new display name"/>
</StackPanel>
<Button Margin="4,4,0,0" x:Name="BtnRenValidate" Content="Validate &amp; Preview &#x2192;"
Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="180"/>
</StackPanel>
</Border>
<!-- === SET OWNER panel === -->
<Border x:Name="PanelOwner" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Set Group Owner" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,12"
Foreground="$($DevConfig.ColorPrimary)"/>
<!-- Group list input -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="TARGET GROUPS - one per line (display name or Object ID)"
Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="TxtOwnerGroupList" Style="{StaticResource InputBox}"
Height="90" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto"
Tag="Group-Finance-APAC&#10;Group-Finance-EMEA&#10;b3f1a2c0-..."/>
</StackPanel>
<!-- Owner UPNs -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="OWNER UPNs - one per line"
Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="TxtOwnerList" Style="{StaticResource InputBox}"
Height="100" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto"
Tag="owner1@domain.com&#10;owner2@domain.com"/>
</StackPanel>
<Button Margin="4,4,0,0" x:Name="BtnOwnerValidate" Content="Validate &amp; Preview &#x2192;"
Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="180"/>
</StackPanel>
</Border>
<!-- === ADD USER DEVICES TO GROUP panel === -->
<Border x:Name="PanelUserDevices" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Add User Devices to Group" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,12"
Foreground="$($DevConfig.ColorPrimary)"/>
<!-- Target group picker -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="TARGET GROUP" Style="{StaticResource FieldLabel}"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TxtUDGroupSearch" Grid.Column="0"
Style="{StaticResource InputBox}"
Tag="Type group name, paste Object ID, or enter display name"/>
<Button x:Name="BtnUDGroupSearch" Grid.Column="2" Content="Search"
Style="{StaticResource OutlineBtn}" Width="70" Margin="0,0,8,0"/>
<Button x:Name="BtnUDGroupById" Grid.Column="4" Content="Use as ID"
Style="{StaticResource OutlineBtn}" Width="80"/>
</Grid>
<Border Margin="0,4,0,0" BorderBrush="$($DevConfig.ColorBorder)"
BorderThickness="1" CornerRadius="5"
x:Name="UDGroupSearchResults" Visibility="Collapsed" MaxHeight="150">
<ListBox x:Name="LstUDGroupResults" Background="$($DevConfig.ColorSurface)"
BorderThickness="0" FontSize="12"/>
</Border>
<Border x:Name="UDSelectedGroupBadge" Margin="0,6,0,0" Visibility="Collapsed"
Background="#EEF5FF" BorderBrush="$($DevConfig.ColorPrimary)"
BorderThickness="1" CornerRadius="5" Padding="10,6">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Selected:" FontSize="11" Foreground="$($DevConfig.ColorTextMuted)"/>
<TextBlock x:Name="TxtUDGroupName" FontSize="12" FontWeight="SemiBold"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock x:Name="TxtUDGroupId" FontSize="10"
Foreground="$($DevConfig.ColorTextMuted)"/>
</StackPanel>
</Border>
</StackPanel>
<!-- User UPNs -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="USER UPNs - one per line (devices owned by these users will be evaluated)"
Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="TxtUDUpnList" Style="{StaticResource InputBox}"
Height="100" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto"
Tag="user1@domain.com&#10;user2@domain.com"/>
</StackPanel>
<!-- Filters -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="DEVICE PLATFORM FILTER - only devices matching a checked platform will be added"
Style="{StaticResource FieldLabel}"/>
<WrapPanel Margin="0,4,0,0">
<CheckBox x:Name="ChkUDWindows" Content="Windows" IsChecked="True" Margin="0,0,16,4" FontSize="12"/>
<CheckBox x:Name="ChkUDAndroid" Content="Android" IsChecked="True" Margin="0,0,16,4" FontSize="12"/>
<CheckBox x:Name="ChkUDiOS" Content="iOS" IsChecked="True" Margin="0,0,16,4" FontSize="12"/>
<CheckBox x:Name="ChkUDMacOS" Content="macOS" IsChecked="True" Margin="0,0,0,4" FontSize="12"/>
</WrapPanel>
</StackPanel>
<StackPanel Margin="0,0,0,12">
<TextBlock Text="DEVICE OWNERSHIP FILTER"
Style="{StaticResource FieldLabel}"/>
<ComboBox x:Name="CmbUDOwnership" Style="{StaticResource StyledCombo}"
Width="200" HorizontalAlignment="Left" Margin="0,4,0,0">
<ComboBoxItem Content="All ownership types" IsSelected="True"/>
<ComboBoxItem Content="Company only"/>
<ComboBoxItem Content="Personal only"/>
</ComboBox>
</StackPanel>
<StackPanel Margin="0,0,0,16">
<TextBlock Text="INTUNE ENROLLMENT FILTER"
Style="{StaticResource FieldLabel}"/>
<CheckBox x:Name="ChkUDIntuneOnly" Content="Intune enrolled devices only"
IsChecked="False" FontSize="12" Margin="0,4,0,0"
ToolTip="When checked, only devices managed by Intune (managementType = MDM) are included"/>
</StackPanel>
<Button Margin="4,4,0,0" x:Name="BtnUDValidate" Content="Validate &amp; Preview &#x2192;"
Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="180"/>
</StackPanel>
</Border>
<!-- === FIND COMMON GROUPS panel === -->
<Border x:Name="PanelFindCommon" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Find Common Groups" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock Text="Finds groups that every input object is a member of."
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14"
TextWrapping="Wrap"/>
<!-- Input type selector -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="INPUT TYPE" Style="{StaticResource FieldLabel}"/>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<RadioButton x:Name="RbFCUsers" Content="Users" GroupName="FCType"
IsChecked="True" FontSize="12" Margin="0,0,20,0" VerticalContentAlignment="Center"/>
<RadioButton x:Name="RbFCDevices" Content="Devices" GroupName="FCType"
FontSize="12" Margin="0,0,20,0" VerticalContentAlignment="Center"/>
<RadioButton x:Name="RbFCGroups" Content="Groups" GroupName="FCType"
FontSize="12" VerticalContentAlignment="Center"/>
</StackPanel>
</StackPanel>
<!-- Input list + inline Find button -->
<StackPanel Margin="0,0,0,12">
<TextBlock x:Name="TxtFCInputLabel"
Text="USER UPNs - one per line"
Style="{StaticResource FieldLabel}"/>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TxtFCInputList" Grid.Column="0" Style="{StaticResource InputBox}"
Height="130" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto"
Tag="Enter one identifier per line"/>
<Button Grid.Column="2" x:Name="BtnFCFind" Content="&#x1F50D; Find Common Groups"
Style="{StaticResource PrimaryBtn}" Width="160" VerticalAlignment="Top"/>
</Grid>
</StackPanel>
<!-- Progress overlay -->
<Border x:Name="PnlFCProgress" Visibility="Collapsed"
Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
<StackPanel>
<TextBlock x:Name="TxtFCProgressMsg" Text="Resolving objects..."
Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ProgressBar x:Name="PbFCProgress" Height="6" IsIndeterminate="True"
Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
<TextBlock x:Name="TxtFCProgressDetail" Text=""
Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
</StackPanel>
</Border>
<!-- Results area -->
<Border x:Name="PnlFCResults" Visibility="Collapsed">
<StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TxtFCCount" Grid.Column="0"
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button x:Name="BtnFCFilterClear" Content="&#x2715;"
Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
IsEnabled="False" Height="30" Width="30" FontSize="12"
Padding="0" Margin="0,0,4,0"/>
<TextBox x:Name="TxtFCFilter" Width="150" Height="30" FontSize="12"
VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
IsEnabled="False" Tag="keyword filter..."/>
<Button x:Name="BtnFCFilter" Content="&#x1F50D; Filter"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,12,0"/>
<Button x:Name="BtnFCCopyValue" Content="Copy Value"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnFCCopyRow" Content="Copy Row"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnFCCopyAll" Content="Copy All"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnFCExportXlsx" Content="Export XLSX"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4"/>
</StackPanel>
</Grid>
<DataGrid x:Name="DgFCResults"
AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
HeadersVisibility="Column"
GridLinesVisibility="All"
VerticalGridLinesBrush="#555555"
HorizontalGridLinesBrush="#D0D0D0"
RowBackground="White" AlternatingRowBackground="#F5FBF5"
BorderBrush="#B0D0B0" BorderThickness="1"
CanUserReorderColumns="False" CanUserResizeRows="False"
CanUserSortColumns="True" MaxHeight="420" FontSize="12">
<DataGrid.ColumnHeaderStyle>
<Style TargetType="DataGridColumnHeader">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Background" Value="#E8F5E9"/>
<Setter Property="Foreground" Value="#14532D"/>
<Setter Property="Padding" Value="8,5"/>
<Setter Property="BorderBrush" Value="#555555"/>
<Setter Property="BorderThickness" Value="0,0,1,1"/>
</Style>
</DataGrid.ColumnHeaderStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="Group Name" Binding="{Binding GroupName}" Width="2*" MinWidth="160"/>
<DataGridTextColumn Header="Group ID" Binding="{Binding GroupID}" Width="2*" MinWidth="120"/>
<DataGridTextColumn Header="Group Type" Binding="{Binding GroupType}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="Membership Type" Binding="{Binding MembershipType}" Width="*" MinWidth="120"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<TextBlock x:Name="TxtFCNoResults" Visibility="Collapsed"
Text="No common groups found for the specified objects."
FontSize="12" Foreground="#6B7280" Margin="0,10,0,0"/>
</StackPanel>
</Border>
<!-- === FIND DISTINCT GROUPS panel === -->
<Border x:Name="PanelFindDistinct" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Find Distinct Groups" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock Text="Finds groups that are NOT shared by all input objects (union minus intersection)."
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14"
TextWrapping="Wrap"/>
<!-- Input type selector -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="INPUT TYPE" Style="{StaticResource FieldLabel}"/>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<RadioButton x:Name="RbFDUsers" Content="Users" GroupName="FDType"
IsChecked="True" FontSize="12" Margin="0,0,20,0" VerticalContentAlignment="Center"/>
<RadioButton x:Name="RbFDDevices" Content="Devices" GroupName="FDType"
FontSize="12" Margin="0,0,20,0" VerticalContentAlignment="Center"/>
<RadioButton x:Name="RbFDGroups" Content="Groups" GroupName="FDType"
FontSize="12" VerticalContentAlignment="Center"/>
</StackPanel>
</StackPanel>
<!-- Input list + inline Find button -->
<StackPanel Margin="0,0,0,12">
<TextBlock x:Name="TxtFDInputLabel"
Text="USER UPNs - one per line"
Style="{StaticResource FieldLabel}"/>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TxtFDInputList" Grid.Column="0" Style="{StaticResource InputBox}"
Height="130" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto"
Tag="Enter one identifier per line"/>
<Button Grid.Column="2" x:Name="BtnFDFind" Content="&#x1F50D; Find Distinct Groups"
Style="{StaticResource PrimaryBtn}" Width="160" VerticalAlignment="Top"/>
</Grid>
</StackPanel>
<!-- Progress overlay -->
<Border x:Name="PnlFDProgress" Visibility="Collapsed"
Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
<StackPanel>
<TextBlock x:Name="TxtFDProgressMsg" Text="Resolving objects..."
Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ProgressBar x:Name="PbFDProgress" Height="6" IsIndeterminate="True"
Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
<TextBlock x:Name="TxtFDProgressDetail" Text=""
Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
</StackPanel>
</Border>
<!-- Results area -->
<Border x:Name="PnlFDResults" Visibility="Collapsed">
<StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TxtFDCount" Grid.Column="0"
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button x:Name="BtnFDFilterClear" Content="&#x2715;"
Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
IsEnabled="False" Height="30" Width="30" FontSize="12"
Padding="0" Margin="0,0,4,0"/>
<TextBox x:Name="TxtFDFilter" Width="150" Height="30" FontSize="12"
VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
IsEnabled="False" Tag="keyword filter..."/>
<Button x:Name="BtnFDFilter" Content="&#x1F50D; Filter"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,12,0"/>
<Button x:Name="BtnFDCopyValue" Content="Copy Value"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnFDCopyRow" Content="Copy Row"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnFDCopyAll" Content="Copy All"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnFDExportXlsx" Content="Export XLSX"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4"/>
</StackPanel>
</Grid>
<DataGrid x:Name="DgFDResults"
AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
HeadersVisibility="Column"
GridLinesVisibility="All"
VerticalGridLinesBrush="#555555"
HorizontalGridLinesBrush="#D0D0D0"
RowBackground="White" AlternatingRowBackground="#EDF5FF"
BorderBrush="#A0C0E0" BorderThickness="1"
CanUserReorderColumns="False" CanUserResizeRows="False"
CanUserSortColumns="True" MaxHeight="420" FontSize="12">
<DataGrid.ColumnHeaderStyle>
<Style TargetType="DataGridColumnHeader">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Background" Value="#DBEAFE"/>
<Setter Property="Foreground" Value="#1E3A5F"/>
<Setter Property="Padding" Value="8,5"/>
<Setter Property="BorderBrush" Value="#555555"/>
<Setter Property="BorderThickness" Value="0,0,1,1"/>
</Style>
</DataGrid.ColumnHeaderStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="Group Name" Binding="{Binding GroupName}" Width="2*" MinWidth="160"/>
<DataGridTextColumn Header="Group ID" Binding="{Binding GroupID}" Width="2*" MinWidth="120"/>
<DataGridTextColumn Header="Group Type" Binding="{Binding GroupType}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="Membership Type" Binding="{Binding MembershipType}" Width="*" MinWidth="120"/>
<DataGridTextColumn Header="Member" Binding="{Binding MemberOf}" Width="2*" MinWidth="160"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<TextBlock x:Name="TxtFDNoResults" Visibility="Collapsed"
Text="No distinct groups found for the specified objects."
FontSize="12" Foreground="#6B7280" Margin="0,10,0,0"/>
</StackPanel>
</Border>
<!-- === GET DEVICE INFO panel === -->
<Border x:Name="PanelGetDeviceInfo" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Get Device Info" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock Text="Fetches combined Entra and Intune device data and exports to XLSX."
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>
<!-- Input type -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="INPUT TYPE" Style="{StaticResource FieldLabel}"/>
<ComboBox x:Name="CmbGDIInputType" Style="{StaticResource StyledCombo}"
Width="260" HorizontalAlignment="Left" Margin="0,4,0,0">
<ComboBoxItem Content="User UPNs (resolve owned devices)" IsSelected="True"/>
<ComboBoxItem Content="Device names"/>
<ComboBoxItem Content="Device serial numbers"/>
<ComboBoxItem Content="Entra Device Object IDs"/>
<ComboBoxItem Content="Intune Device IDs"/>
<ComboBoxItem Content="Groups (Users and Devices)"/>
</ComboBox>
</StackPanel>
<!-- Input list -->
<StackPanel Margin="0,0,0,12">
<TextBlock x:Name="TxtGDIInputLabel" Text="USER UPNs - one per line"
Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="TxtGDIInputList" Style="{StaticResource InputBox}"
Height="110" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto" Tag="user@domain.com"/>
</StackPanel>
<!-- Platform filter -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="PLATFORM FILTER (leave all unchecked for all platforms)"
Style="{StaticResource FieldLabel}"/>
<WrapPanel Margin="0,4,0,0">
<CheckBox x:Name="ChkGDIWindows" Content="Windows" Margin="0,0,16,4" FontSize="12"/>
<CheckBox x:Name="ChkGDIAndroid" Content="Android" Margin="0,0,16,4" FontSize="12"/>
<CheckBox x:Name="ChkGDIiOS" Content="iOS" Margin="0,0,16,4" FontSize="12"/>
<CheckBox x:Name="ChkGDIMacOS" Content="macOS" Margin="0,0,0,4" FontSize="12"/>
</WrapPanel>
</StackPanel>
<!-- Ownership filter -->
<StackPanel Margin="0,0,0,8">
<TextBlock Text="OWNERSHIP FILTER" Style="{StaticResource FieldLabel}"/>
<ComboBox x:Name="CmbGDIOwnership" Style="{StaticResource StyledCombo}"
Width="200" HorizontalAlignment="Left" Margin="0,4,0,0">
<ComboBoxItem Content="All ownership types" IsSelected="True"/>
<ComboBoxItem Content="Company only"/>
<ComboBoxItem Content="Personal only"/>
</ComboBox>
</StackPanel>
<!-- Run buttons -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<Button x:Name="BtnGDIRunAll" Content="&#x25B6; Get All Device Info"
Style="{StaticResource PrimaryBtn}" Width="200" HorizontalAlignment="Left" Margin="0,4,0,0"/>
<Button x:Name="BtnGDIRun" Content="&#x25B6; Get Device Info"
Style="{StaticResource PrimaryBtn}" Width="180" HorizontalAlignment="Left" Margin="12,4,0,0"/>
</StackPanel>
<!-- Progress -->
<Border x:Name="PnlGDIProgress" Visibility="Collapsed"
Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
<StackPanel>
<TextBlock x:Name="TxtGDIProgressMsg" Text="Resolving devices..."
Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ProgressBar x:Name="PbGDIProgress" Height="6" IsIndeterminate="True"
Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
<TextBlock x:Name="TxtGDIProgressDetail" Text=""
Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
</StackPanel>
</Border>
<!-- Results -->
<Border x:Name="PnlGDIResults" Visibility="Collapsed">
<StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TxtGDICount" Grid.Column="0"
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button x:Name="BtnGDIFilterClear" Content="&#x2715;"
Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
IsEnabled="False" Height="30" Width="30" FontSize="12"
Padding="0" Margin="0,0,4,0"/>
<TextBox x:Name="TxtGDIFilter" Width="150" Height="30" FontSize="12"
VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
IsEnabled="False" Tag="keyword filter..."/>
<Button x:Name="BtnGDIFilter" Content="&#x1F50D; Filter"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,12,0"/>
<Button x:Name="BtnGDICopyValue" Content="Copy Value"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnGDICopyRow" Content="Copy Row"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnGDICopyAll" Content="Copy All"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnGDIExportXlsx" Content="Export XLSX"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4"/>
</StackPanel>
</Grid>
<DataGrid x:Name="DgGDIResults"
AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
HeadersVisibility="Column"
GridLinesVisibility="All"
VerticalGridLinesBrush="#555555"
HorizontalGridLinesBrush="#D0D0D0"
RowBackground="White" AlternatingRowBackground="#F5F5FF"
BorderBrush="#B0B0D0" BorderThickness="1"
CanUserReorderColumns="False" CanUserResizeRows="False"
CanUserSortColumns="True" MaxHeight="420" FontSize="12">
<DataGrid.ColumnHeaderStyle>
<Style TargetType="DataGridColumnHeader">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Background" Value="#E8EAF6"/>
<Setter Property="Foreground" Value="#1A237E"/>
<Setter Property="Padding" Value="8,5"/>
<Setter Property="BorderBrush" Value="#555555"/>
<Setter Property="BorderThickness" Value="0,0,1,1"/>
</Style>
</DataGrid.ColumnHeaderStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="Device Name" Binding="{Binding Entra_DeviceName}" Width="2*" MinWidth="140"/>
<DataGridTextColumn Header="Device Type" Binding="{Binding DeviceType}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="Join Type" Binding="{Binding Entra_JoinType}" Width="*" MinWidth="110"/>
<DataGridTextColumn Header="Entra Ownership" Binding="{Binding Entra_Ownership}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="Intune Ownership" Binding="{Binding Intune_Ownership}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="Entra OS" Binding="{Binding Entra_OSPlatform}" Width="*" MinWidth="80"/>
<DataGridTextColumn Header="Intune OS" Binding="{Binding Intune_OSPlatform}" Width="*" MinWidth="80"/>
<DataGridTextColumn Header="Entra OS Version" Binding="{Binding Entra_OSVersion}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="Intune OS Version" Binding="{Binding Intune_OSVersion}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="Windows Release" Binding="{Binding Windows_Release}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="Device Enabled" Binding="{Binding Entra_DeviceEnabled}" Width="*" MinWidth="90"/>
<DataGridTextColumn Header="Entra Last Sign-In" Binding="{Binding Entra_LastSignIn}" Width="*" MinWidth="110"/>
<DataGridTextColumn Header="Entra Activity" Binding="{Binding Entra_ActivityRange}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="Intune Check-In" Binding="{Binding Intune_LastCheckIn}" Width="*" MinWidth="110"/>
<DataGridTextColumn Header="Intune Activity" Binding="{Binding Intune_ActivityRange}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="OEM" Binding="{Binding Intune_OEM}" Width="*" MinWidth="80"/>
<DataGridTextColumn Header="Model" Binding="{Binding Intune_Model}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="Serial Number" Binding="{Binding Intune_SerialNumber}" Width="*" MinWidth="120"/>
<DataGridTextColumn Header="Enrolled Date" Binding="{Binding Intune_EnrolledDate}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="Compliance State" Binding="{Binding Intune_ComplianceState}" Width="*" MinWidth="110"/>
<DataGridTextColumn Header="Entra Device ID" Binding="{Binding Entra_DeviceID}" Width="2*" MinWidth="130"/>
<DataGridTextColumn Header="Entra Object ID" Binding="{Binding Entra_ObjectID}" Width="2*" MinWidth="130"/>
<DataGridTextColumn Header="Intune Device ID" Binding="{Binding Intune_DeviceID}" Width="2*" MinWidth="130"/>
<DataGridTextColumn Header="Device Owner UPN" Binding="{Binding Entra_DeviceOwner}" Width="2*" MinWidth="160"/>
<DataGridTextColumn Header="Primary User UPN" Binding="{Binding Entra_PrimaryUserUPN}" Width="2*" MinWidth="160"/>
<DataGridTextColumn Header="User Enabled" Binding="{Binding Entra_UserAccountEnabled}" Width="*" MinWidth="90"/>
<DataGridTextColumn Header="User Country" Binding="{Binding Entra_UserCountry}" Width="*" MinWidth="90"/>
<DataGridTextColumn Header="User City" Binding="{Binding Entra_UserCity}" Width="*" MinWidth="80"/>
<DataGridTextColumn Header="User Job Title" Binding="{Binding Entra_UserJobTitle}" Width="*" MinWidth="110"/>
<DataGridTextColumn Header="AP Group Tag" Binding="{Binding Autopilot_GroupTag}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="AP Profile Status" Binding="{Binding Autopilot_ProfileStatus}" Width="*" MinWidth="120"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<TextBlock x:Name="TxtGDINoResults" Visibility="Collapsed"
Text="No devices matched the input and filters."
FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<!-- === GET DISCOVERED APPS panel === -->
<Border x:Name="PanelGetDiscoveredApps" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Get Discovered Apps" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock Text="Fetches apps discovered on Intune managed devices. Supports multiple input types and filters."
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>
<!-- Input type -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="INPUT TYPE" Style="{StaticResource FieldLabel}"/>
<ComboBox x:Name="CmbDAInputType" Style="{StaticResource StyledCombo}"
Width="300" HorizontalAlignment="Left" Margin="0,4,0,0">
<ComboBoxItem Content="User UPNs (resolve owned devices)" IsSelected="True"/>
<ComboBoxItem Content="Device names"/>
<ComboBoxItem Content="Device IDs (Entra)"/>
<ComboBoxItem Content="Serial numbers"/>
<ComboBoxItem Content="Groups (names or IDs)"/>
</ComboBox>
</StackPanel>
<!-- Input list -->
<StackPanel Margin="0,0,0,12">
<TextBlock x:Name="TxtDAInputLabel" Text="USER UPNs - one per line"
Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="TxtDAInputList" Style="{StaticResource InputBox}"
Height="110" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto" Tag="user@domain.com"/>
</StackPanel>
<!-- Platform filter + Keyword filter (side by side) -->
<Grid Margin="0,0,0,2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="40"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="PLATFORM FILTER (leave all unchecked for all platforms)"
Style="{StaticResource FieldLabel}"/>
<WrapPanel Margin="0,4,0,0">
<CheckBox x:Name="ChkDAWindows" Content="Windows" Margin="0,0,16,4" FontSize="12"/>
<CheckBox x:Name="ChkDAAndroid" Content="Android" Margin="0,0,16,4" FontSize="12"/>
<CheckBox x:Name="ChkDAiOS" Content="iOS" Margin="0,0,16,4" FontSize="12"/>
<CheckBox x:Name="ChkDAMacOS" Content="macOS" Margin="0,0,0,4" FontSize="12"/>
</WrapPanel>
</StackPanel>
<StackPanel Grid.Column="2" VerticalAlignment="Top">
<TextBlock Text="APP KEYWORD FILTER (optional)" Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="TxtDAKeyword" Style="{StaticResource InputBox}"
Width="250" HorizontalAlignment="Left" Margin="0,4,0,0"
Tag="e.g. Chrome, Zoom, Office"/>
</StackPanel>
</Grid>
<!-- Ownership filter -->
<StackPanel Margin="0,0,0,4">
<TextBlock Text="OWNERSHIP FILTER" Style="{StaticResource FieldLabel}"/>
<ComboBox x:Name="CmbDAOwnership" Style="{StaticResource StyledCombo}"
Width="200" HorizontalAlignment="Left" Margin="0,4,0,0">
<ComboBoxItem Content="All ownership types" IsSelected="True"/>
<ComboBoxItem Content="Company only"/>
<ComboBoxItem Content="Personal only"/>
</ComboBox>
</StackPanel>
<!-- Run buttons -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<Button x:Name="BtnDARun" Content="&#x25B6; Get Discovered Apps"
Style="{StaticResource PrimaryBtn}" Width="220" HorizontalAlignment="Left" Margin="0,4,0,0"/>
<Button x:Name="BtnDARunAll" Content="&#x25B6; Get Discovered Apps from All Devices"
Style="{StaticResource PrimaryBtn}" Width="310" HorizontalAlignment="Left" Margin="12,4,0,0"/>
</StackPanel>
<!-- Progress -->
<Border x:Name="PnlDAProgress" Visibility="Collapsed"
Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
<StackPanel>
<TextBlock x:Name="TxtDAProgressMsg" Text="Resolving devices..."
Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ProgressBar x:Name="PbDAProgress" Height="6" IsIndeterminate="True"
Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
<TextBlock x:Name="TxtDAProgressDetail" Text=""
Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
</StackPanel>
</Border>
<!-- Results -->
<Border x:Name="PnlDAResults" Visibility="Collapsed">
<StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TxtDACount" Grid.Column="0"
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button x:Name="BtnDAFilterClear" Content="&#x2715;"
Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
IsEnabled="False" Height="30" Width="30" FontSize="12"
Padding="0" Margin="0,0,4,0"/>
<TextBox x:Name="TxtDAFilter" Width="150" Height="30" FontSize="12"
VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
IsEnabled="False" Tag="keyword filter..."/>
<Button x:Name="BtnDAFilter" Content="&#x1F50D; Filter"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,12,0"/>
<Button x:Name="BtnDACopyValue" Content="Copy Value"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnDACopyRow" Content="Copy Row"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnDACopyAll" Content="Copy All"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnDAExportXlsx" Content="Export XLSX"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4"/>
</StackPanel>
</Grid>
<DataGrid x:Name="DgDAResults"
AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
HeadersVisibility="Column"
GridLinesVisibility="All"
VerticalGridLinesBrush="#555555"
HorizontalGridLinesBrush="#D0D0D0"
RowBackground="White" AlternatingRowBackground="#F5F5FF"
BorderBrush="#B0B0D0" BorderThickness="1"
CanUserReorderColumns="False" CanUserResizeRows="False"
CanUserSortColumns="True" MaxHeight="420" FontSize="12">
<DataGrid.ColumnHeaderStyle>
<Style TargetType="DataGridColumnHeader">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Background" Value="#E8EAF6"/>
<Setter Property="Foreground" Value="#1A237E"/>
<Setter Property="Padding" Value="8,5"/>
<Setter Property="BorderBrush" Value="#555555"/>
<Setter Property="BorderThickness" Value="0,0,1,1"/>
</Style>
</DataGrid.ColumnHeaderStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="Device Name" Binding="{Binding DeviceName}" Width="2*" MinWidth="140"/>
<DataGridTextColumn Header="Entra Device ID" Binding="{Binding EntraDeviceId}" Width="2*" MinWidth="130"/>
<DataGridTextColumn Header="Primary User UPN" Binding="{Binding PrimaryUserUPN}" Width="2*" MinWidth="160"/>
<DataGridTextColumn Header="OS Version" Binding="{Binding OSVersion}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="Discovered App" Binding="{Binding DiscoveredApp}" Width="2*" MinWidth="160"/>
<DataGridTextColumn Header="App Version" Binding="{Binding AppVersion}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="App Publisher" Binding="{Binding AppPublisher}" Width="2*" MinWidth="130"/>
<DataGridTextColumn Header="Collected At" Binding="{Binding CollectedAt}" Width="*" MinWidth="120"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<TextBlock x:Name="TxtDANoResults" Visibility="Collapsed"
Text="No discovered apps matched the input and filters."
FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<!-- === GET POLICY ASSIGNMENTS panel === -->
<Border x:Name="PanelGetPolicyAssignments" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Get Policy Assignments" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock Text="Query Intune policy assignments across all policy types. Use filters to narrow results, or Get All Assignments to retrieve everything."
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>
<!-- Filters + action buttons -->
<Grid Margin="0,0,0,14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Filter stack (vertical) -->
<StackPanel Grid.Column="0">
<!-- Policy Name keyword -->
<StackPanel Margin="0,0,0,10">
<TextBlock Text="POLICY NAME (keyword &#x2014; blank for all)" Style="{StaticResource FieldLabel}"/>
<Border Background="#FFFFFF" BorderBrush="#B0D0B0" BorderThickness="1"
CornerRadius="8" Height="36" Margin="0,4,0,0">
<TextBox x:Name="TxtGPANameFilter"
Background="Transparent" Foreground="#374151" CaretBrush="#22C55E"
BorderThickness="0" Padding="10,0" FontSize="12"
VerticalContentAlignment="Center"/>
</Border>
</StackPanel>
<!-- Policy Type -->
<StackPanel Margin="0,0,0,10">
<TextBlock Text="POLICY TYPE" Style="{StaticResource FieldLabel}"/>
<ComboBox x:Name="CmbGPAType" Style="{StaticResource StyledCombo}" Margin="0,4,0,0">
<ComboBoxItem Content="(All types)" IsSelected="True"/>
<ComboBoxItem Content="Device Configuration"/>
<ComboBoxItem Content="Configuration Policy"/>
<ComboBoxItem Content="Autopilot Deployment Profile"/>
<ComboBoxItem Content="Platform Script (Windows PowerShell)"/>
<ComboBoxItem Content="Detection/Remediation Script"/>
<ComboBoxItem Content="Administrative Templates"/>
</ComboBox>
</StackPanel>
<!-- Policy Sub-Type -->
<StackPanel Margin="0,0,0,10">
<TextBlock Text="POLICY SUB-TYPE" Style="{StaticResource FieldLabel}"/>
<ComboBox x:Name="CmbGPASubType" Style="{StaticResource StyledCombo}" Margin="0,4,0,0">
<ComboBoxItem Content="(All sub-types)" IsSelected="True"/>
<ComboBoxItem Content="Administrative Templates"/>
<ComboBoxItem Content="App Control for Business"/>
<ComboBoxItem Content="Attack Surface Reduction Rules"/>
<ComboBoxItem Content="BitLocker"/>
<ComboBoxItem Content="Built-in"/>
<ComboBoxItem Content="Certificate"/>
<ComboBoxItem Content="Custom"/>
<ComboBoxItem Content="Defender Update Controls"/>
<ComboBoxItem Content="Device Features"/>
<ComboBoxItem Content="Device Preparation"/>
<ComboBoxItem Content="Edition Upgrade"/>
<ComboBoxItem Content="Endpoint Protection"/>
<ComboBoxItem Content="Exploit Protection"/>
<ComboBoxItem Content="Extensions"/>
<ComboBoxItem Content="General Device"/>
<ComboBoxItem Content="Health Monitoring"/>
<ComboBoxItem Content="Identity Protection"/>
<ComboBoxItem Content="Kiosk"/>
<ComboBoxItem Content="Local Admin Password Solution (Windows LAPS)"/>
<ComboBoxItem Content="Local User Group Membership"/>
<ComboBoxItem Content="MDE Onboarding Policy"/>
<ComboBoxItem Content="Microsoft Defender Antivirus"/>
<ComboBoxItem Content="Microsoft Defender Antivirus Exclusions"/>
<ComboBoxItem Content="PowerShell"/>
<ComboBoxItem Content="Remediation"/>
<ComboBoxItem Content="SCEP Certificate"/>
<ComboBoxItem Content="Settings Catalog"/>
<ComboBoxItem Content="Shared PC"/>
<ComboBoxItem Content="Software Update"/>
<ComboBoxItem Content="Trusted Certificate"/>
<ComboBoxItem Content="Update Rings"/>
<ComboBoxItem Content="VPN"/>
<ComboBoxItem Content="Wi-Fi"/>
<ComboBoxItem Content="Windows Firewall"/>
<ComboBoxItem Content="Windows Firewall Rules"/>
<ComboBoxItem Content="Windows OS Recovery"/>
<ComboBoxItem Content="Windows Security Experience"/>
</ComboBox>
</StackPanel>
<!-- OS Platform -->
<StackPanel>
<TextBlock Text="OS PLATFORM" Style="{StaticResource FieldLabel}"/>
<ComboBox x:Name="CmbGPAPlatform" Style="{StaticResource StyledCombo}" Margin="0,4,0,0">
<ComboBoxItem Content="(All platforms)" IsSelected="True"/>
<ComboBoxItem Content="Windows"/>
<ComboBoxItem Content="iOS/iPadOS"/>
<ComboBoxItem Content="Android"/>
<ComboBoxItem Content="AOSP"/>
<ComboBoxItem Content="macOS"/>
<ComboBoxItem Content="Linux"/>
</ComboBox>
</StackPanel>
</StackPanel>
<!-- Action buttons (right of filters) -->
<StackPanel Grid.Column="2" VerticalAlignment="Center">
<Button x:Name="BtnGPAGetSelected" Content="&#x25B6; Get Selected Assignments"
Style="{StaticResource PrimaryBtn}" Height="32" FontSize="12" Padding="14,4"/>
<Button x:Name="BtnGPAGetAll" Content="&#x25B6; Get All Assignments"
Style="{StaticResource PrimaryBtn}" Height="32" FontSize="12" Padding="14,4" Margin="0,8,0,0"/>
</StackPanel>
</Grid>
<!-- Progress -->
<Border x:Name="PnlGPAProgress" Visibility="Collapsed"
Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
<StackPanel>
<TextBlock x:Name="TxtGPAProgressMsg" Text="Loading policies..."
Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ProgressBar x:Name="PbGPAProgress" Height="6" IsIndeterminate="True"
Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
<TextBlock x:Name="TxtGPAProgressDetail" Text=""
Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
</StackPanel>
</Border>
<!-- No results message -->
<TextBlock x:Name="TxtGPANoResults" Visibility="Collapsed"
Text="No policy assignments matched the selected filters."
FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
<!-- Results panel -->
<Border x:Name="PnlGPAResults" Visibility="Collapsed">
<StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TxtGPACount" Grid.Column="0"
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button x:Name="BtnGPAFilterClear" Content="&#x2715;"
Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
IsEnabled="False" Height="30" Width="30" FontSize="12"
Padding="0" Margin="0,0,4,0"/>
<TextBox x:Name="TxtGPAFilter" Width="150" Height="30" FontSize="12"
VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
IsEnabled="False" Tag="keyword filter..."/>
<Button x:Name="BtnGPAFilter" Content="&#x1F50D; Filter"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,12,0"/>
<Button x:Name="BtnGPACopyValue" Content="Copy Value"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnGPACopyRow" Content="Copy Row"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnGPACopyAll" Content="Copy All"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnGPAExportXlsx" Content="Export XLSX"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4"/>
</StackPanel>
</Grid>
<DataGrid x:Name="DgGPAResults"
AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
HeadersVisibility="Column"
GridLinesVisibility="All"
VerticalGridLinesBrush="#555555"
HorizontalGridLinesBrush="#D0D0D0"
RowBackground="White" AlternatingRowBackground="#F5F5FF"
BorderBrush="#B0B0D0" BorderThickness="1"
CanUserReorderColumns="False" CanUserResizeRows="False" CanUserResizeColumns="True"
CanUserSortColumns="True" MaxHeight="420" FontSize="12">
<DataGrid.ColumnHeaderStyle>
<Style TargetType="DataGridColumnHeader">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Background" Value="#E8EAF6"/>
<Setter Property="Foreground" Value="#1A237E"/>
<Setter Property="Padding" Value="8,5"/>
<Setter Property="BorderBrush" Value="#555555"/>
<Setter Property="BorderThickness" Value="0,0,1,1"/>
</Style>
</DataGrid.ColumnHeaderStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="Policy Name" Binding="{Binding PolicyName}" Width="3*" MinWidth="200"/>
<DataGridTextColumn Header="Type" Binding="{Binding PolicyType}" Width="2*" MinWidth="150"/>
<DataGridTextColumn Header="Sub-Type" Binding="{Binding PolicySubType}" Width="2*" MinWidth="150"/>
<DataGridTextColumn Header="OS Platform" Binding="{Binding OSPlatform}" Width="*" MinWidth="100"/>
<DataGridTextColumn Header="Included Group" Binding="{Binding IncludedGroup}" Width="2*" MinWidth="160"/>
<DataGridTextColumn Header="Excluded Group" Binding="{Binding ExcludedGroup}" Width="2*" MinWidth="160"/>
<DataGridTextColumn Header="Include Filter" Binding="{Binding IncludeFilter}" Width="2*" MinWidth="150"/>
<DataGridTextColumn Header="Exclude Filter" Binding="{Binding ExcludeFilter}" Width="2*" MinWidth="150"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- === COMPARE GROUPS panel === -->
<Border x:Name="PanelCompareGroups" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Compare Groups" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock Text="Query and compare membership across multiple groups. Enter display names or Object IDs, one per line."
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,16" TextWrapping="Wrap"/>
<!-- Group input -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="GROUPS * (one per line &#x2014; display name or Object ID)" Style="{StaticResource FieldLabel}"/>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TxtCGGroupInput" Grid.Column="0" Style="{StaticResource InputBox}"
Height="80" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto"
Tag="SG-Marketing-All&#10;SG-Sales-All&#10;SG-IT-All"/>
<StackPanel Grid.Column="2" Orientation="Vertical" VerticalAlignment="Top">
<Button x:Name="BtnCGCommon" Content="Common Members"
Style="{StaticResource PrimaryBtn}" Width="140" Margin="0,0,0,6"/>
<Button x:Name="BtnCGDistinct" Content="Distinct Members"
Style="{StaticResource PrimaryBtn}" Width="140"/>
</StackPanel>
</Grid>
</StackPanel>
<!-- Progress overlay -->
<Border x:Name="PnlCGProgress" Visibility="Collapsed"
Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
<StackPanel>
<TextBlock x:Name="TxtCGProgressMsg" Text="Querying groups..."
Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ProgressBar x:Name="PbCGProgress" Height="6" IsIndeterminate="True"
Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
<TextBlock x:Name="TxtCGProgressDetail" Text=""
Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
</StackPanel>
</Border>
<!-- Results area -->
<Border x:Name="PnlCGResults" Visibility="Collapsed">
<StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TxtCGCount" Grid.Column="0"
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button x:Name="BtnCGFilterClear" Content="&#x2715;"
Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
IsEnabled="False" Height="30" Width="30" FontSize="12"
Padding="0" Margin="0,0,4,0"/>
<TextBox x:Name="TxtCGFilter" Width="150" Height="30" FontSize="12"
VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
IsEnabled="False" Tag="keyword filter..."/>
<Button x:Name="BtnCGFilter" Content="&#x1F50D; Filter"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,12,0"/>
<Button x:Name="BtnCGCopyValue" Content="Copy Value"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnCGCopyRow" Content="Copy Row"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnCGCopyAll" Content="Copy All"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnCGExportXlsx" Content="Export XLSX"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4"/>
</StackPanel>
</Grid>
<DataGrid x:Name="DgCGResults"
AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Extended" SelectionUnit="CellOrRowHeader"
HeadersVisibility="Column"
GridLinesVisibility="All"
VerticalGridLinesBrush="#555555"
HorizontalGridLinesBrush="#D0D0D0"
RowBackground="White" AlternatingRowBackground="#FFF8ED"
BorderBrush="#F5C96A" BorderThickness="1"
CanUserReorderColumns="False" CanUserResizeRows="False"
CanUserSortColumns="True" MaxHeight="420" FontSize="12">
<DataGrid.ColumnHeaderStyle>
<Style TargetType="DataGridColumnHeader">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Background" Value="#FEF3C7"/>
<Setter Property="Foreground" Value="#92400E"/>
<Setter Property="Padding" Value="8,5"/>
<Setter Property="BorderBrush" Value="#555555"/>
<Setter Property="BorderThickness" Value="0,0,1,1"/>
</Style>
</DataGrid.ColumnHeaderStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="Object" Binding="{Binding DisplayName}" Width="2*" IsReadOnly="True"/>
<DataGridTextColumn Header="Object ID" Binding="{Binding ObjectID}" Width="2*" IsReadOnly="True"/>
<DataGridTextColumn Header="Object Type" Binding="{Binding ObjectType}" Width="*" IsReadOnly="True"/>
<DataGridTextColumn Header="Source Group" Binding="{Binding SourceGroup}" Width="*" IsReadOnly="True"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<TextBlock x:Name="TxtCGNoResults" Visibility="Collapsed"
Text="No members found for the specified group(s)."
FontSize="12" Foreground="#6B7280" Margin="0,10,0,0"/>
</StackPanel>
</Border>
<!-- === REMOVE GROUPS panel === -->
<Border x:Name="PanelRemoveGroups" Style="{StaticResource Card}" Visibility="Collapsed">
<StackPanel>
<TextBlock Text="Remove Groups" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"
Text="&#x26A0; Remove one or more Groups. Enter one group display name or Object ID per line."/>
<!-- Input -->
<StackPanel Margin="0,0,0,12">
<TextBlock Text="GROUP NAMES / OBJECT IDs &#x2014; one per line" Style="{StaticResource FieldLabel}"/>
<TextBox x:Name="TxtRGGroupList" Style="{StaticResource InputBox}"
Height="100" TextWrapping="Wrap" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto"
Tag="GroupDisplayName&#10;00000000-0000-0000-0000-000000000001&#10;AnotherGroup"/>
</StackPanel>
<!-- Validate button -->
<Button x:Name="BtnRGValidate" Content="&#x2714; Validate Groups"
Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="160" Margin="4,4,0,12"/>
<!-- Validate progress -->
<Border x:Name="PnlRGProgress" Visibility="Collapsed"
Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
<StackPanel>
<TextBlock x:Name="TxtRGProgressMsg" Text="Validating groups..."
Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ProgressBar x:Name="PbRGProgress" Height="6" IsIndeterminate="True"
Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
<TextBlock x:Name="TxtRGProgressDetail" Text=""
Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
</StackPanel>
</Border>
<!-- Warning banner -->
<Border x:Name="PnlRGWarning" Visibility="Collapsed"
Background="#FFF8E1" CornerRadius="6" Padding="12,10" Margin="0,0,0,12"
BorderBrush="#F59E0B" BorderThickness="1">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x26A0;" FontSize="15" Foreground="#B45309" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock x:Name="TxtRGWarning" FontSize="12" Foreground="#92400E" TextWrapping="Wrap"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- Preview table + Execute button -->
<Border x:Name="PnlRGPreview" Visibility="Collapsed">
<StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TxtRGPreviewCount" Grid.Column="0"
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
<Button x:Name="BtnRGExecute" Grid.Column="1"
Content="&#x1F5D1; Remove Groups" IsEnabled="False"
Padding="14,8" FontSize="12" FontWeight="Bold" Cursor="Hand"
Foreground="White" BorderThickness="0">
<Button.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#DC2626" Offset="0"/>
<GradientStop Color="#B91C1C" Offset="1"/>
</LinearGradientBrush>
</Button.Background>
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="8"
Padding="{TemplateBinding Padding}" x:Name="RGExecBg">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="RGExecBg" Property="Opacity" Value="0.45"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="RGExecBg" Property="Opacity" Value="0.82"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
<DataGrid x:Name="DgRGPreview"
AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Single" SelectionUnit="FullRow"
HeadersVisibility="Column"
GridLinesVisibility="All"
VerticalGridLinesBrush="#555555"
HorizontalGridLinesBrush="#D0D0D0"
RowBackground="White" AlternatingRowBackground="#F5FBF5"
BorderBrush="#B0D0B0" BorderThickness="1"
CanUserReorderColumns="False" CanUserResizeRows="False"
CanUserSortColumns="True" MaxHeight="280" FontSize="12">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Style.Triggers>
<DataTrigger Binding="{Binding HasMembers}" Value="True">
<Setter Property="Background" Value="#FFF8E1"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</DataTrigger>
</Style.Triggers>
</Style>
</DataGrid.RowStyle>
<DataGrid.ColumnHeaderStyle>
<Style TargetType="DataGridColumnHeader">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Background" Value="#E8F5E9"/>
<Setter Property="Foreground" Value="#14532D"/>
<Setter Property="Padding" Value="8,5"/>
<Setter Property="BorderBrush" Value="#555555"/>
<Setter Property="BorderThickness" Value="0,0,1,1"/>
</Style>
</DataGrid.ColumnHeaderStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="Display Name" Binding="{Binding DisplayName}" Width="3*" MinWidth="160"/>
<DataGridTextColumn Header="Group ID" Binding="{Binding GroupID}" Width="3*" MinWidth="130"/>
<DataGridTextColumn Header="Group Type" Binding="{Binding GroupType}" Width="*" MinWidth="120"/>
<DataGridTextColumn Header="Members" Binding="{Binding MemberCount}" Width="*" MinWidth="80"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<!-- Execution progress -->
<Border x:Name="PnlRGExecProgress" Visibility="Collapsed"
Background="#2A1A1A" CornerRadius="6" Padding="16,12" Margin="0,12,0,12">
<StackPanel>
<TextBlock x:Name="TxtRGExecProgressMsg" Text="Removing groups..."
Foreground="#FCA5A5" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ProgressBar x:Name="PbRGExecProgress" Height="6" IsIndeterminate="True"
Background="#4A2A2A" Foreground="#EF4444" BorderThickness="0"/>
<TextBlock x:Name="TxtRGExecProgressDetail" Text=""
Foreground="#F87171" FontSize="11" Margin="0,5,0,0"/>
</StackPanel>
</Border>
<!-- Results table -->
<Border x:Name="PnlRGResults" Visibility="Collapsed">
<StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TxtRGResultCount" Grid.Column="0"
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button x:Name="BtnRGCopyAll" Content="Copy All"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4" Margin="0,0,6,0"/>
<Button x:Name="BtnRGExportXlsx" Content="Export XLSX"
Style="{StaticResource PrimaryBtn}"
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
Padding="12,4"/>
</StackPanel>
</Grid>
<DataGrid x:Name="DgRGResults"
AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Single" SelectionUnit="FullRow"
HeadersVisibility="Column"
GridLinesVisibility="All"
VerticalGridLinesBrush="#555555"
HorizontalGridLinesBrush="#D0D0D0"
RowBackground="White" AlternatingRowBackground="#F5F5F5"
BorderBrush="#B0D0B0" BorderThickness="1"
CanUserReorderColumns="False" CanUserResizeRows="False"
CanUserSortColumns="True" MaxHeight="280" FontSize="12">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Style.Triggers>
<DataTrigger Binding="{Binding IsSuccess}" Value="True">
<Setter Property="Background" Value="#F0FDF4"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsSuccess}" Value="False">
<Setter Property="Background" Value="#FEF2F2"/>
</DataTrigger>
</Style.Triggers>
</Style>
</DataGrid.RowStyle>
<DataGrid.ColumnHeaderStyle>
<Style TargetType="DataGridColumnHeader">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Background" Value="#E8F5E9"/>
<Setter Property="Foreground" Value="#14532D"/>
<Setter Property="Padding" Value="8,5"/>
<Setter Property="BorderBrush" Value="#555555"/>
<Setter Property="BorderThickness" Value="0,0,1,1"/>
</Style>
</DataGrid.ColumnHeaderStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="Display Name" Binding="{Binding DisplayName}" Width="3*" MinWidth="160"/>
<DataGridTextColumn Header="Group ID" Binding="{Binding GroupID}" Width="3*" MinWidth="130"/>
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="*" MinWidth="80"/>
<DataGridTextColumn Header="Message" Binding="{Binding Message}" Width="4*" MinWidth="200"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<TextBlock x:Name="TxtRGNoResults" Visibility="Collapsed"
Text="No groups could be resolved from the provided input. Check the names / IDs and try again."
FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</Grid>
</ScrollViewer>
</Grid>
</DockPanel>
<!-- ================================================================== -->
<!-- Session Notes floating overlay (in-memory only, not persisted) -->
<!-- ================================================================== -->
<Grid x:Name="OverlaySessionNotes" Visibility="Collapsed">
<Rectangle x:Name="SnDimmer" Fill="#80000000" IsHitTestVisible="True"/>
<Border Background="White" CornerRadius="12"
BorderBrush="#E5E7EB" BorderThickness="1"
Width="520" MaxHeight="480"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Border.Effect>
<DropShadowEffect BlurRadius="24" ShadowDepth="6" Direction="270"
Color="#000000" Opacity="0.28"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Card header -->
<Border Grid.Row="0" Background="#F9FAFB" CornerRadius="12,12,0,0" Padding="18,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#x1F5D2;" FontSize="17" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="Session Notes" FontSize="14" FontWeight="SemiBold"
Foreground="#111827" VerticalAlignment="Center"/>
<TextBlock Text=" not saved to disk" FontSize="11"
Foreground="#9CA3AF" VerticalAlignment="Center" Margin="4,0,0,0"/>
</StackPanel>
<Button x:Name="BtnSessionNotesClose" Grid.Column="1"
Content="&#x2715;" FontSize="13" FontWeight="Bold"
Background="Transparent" BorderThickness="0"
Foreground="#6B7280" Cursor="Hand"
Width="30" Height="30" Padding="0"
VerticalContentAlignment="Center" HorizontalContentAlignment="Center"/>
</Grid>
</Border>
<!-- Note text area -->
<TextBox x:Name="TxtSessionNotes" Grid.Row="1"
TextWrapping="Wrap" AcceptsReturn="True" AcceptsTab="True"
VerticalScrollBarVisibility="Auto"
BorderThickness="0,1,0,1" BorderBrush="#E5E7EB"
Padding="18,12" FontSize="12" FontFamily="Consolas"
Foreground="#1F2937" Background="White"
MinHeight="260" MaxHeight="360"
Tag="Type your session notes here&#x2026;"/>
<!-- Card footer -->
<Border Grid.Row="2" Background="#F9FAFB" CornerRadius="0,0,12,12" Padding="18,8">
<TextBlock Text="Notes are discarded when the tool is closed."
FontSize="11" Foreground="#9CA3AF" HorizontalAlignment="Center"/>
</Border>
</Grid>
</Border>
</Grid>
</Grid>
</Window>
"@
#endregion XAML
Write-ErrorLog "Checkpoint 2: XAML defined OK."
Write-EarlyLog "Checkpoint 2: XAML defined OK."
# ============================================================
#region UI HELPER FUNCTIONS
# ============================================================
function Get-Timestamp { (Get-Date).ToString("HH:mm:ss") }
function Write-VerboseLog {
param(
[string]$Message,
[ValidateSet('Info','Success','Warning','Error','Action')]
[string]$Level = 'Info'
)
$color = switch ($Level) {
'Info' { $DevConfig.LogColorInfo }
'Success' { $DevConfig.LogColorSuccess }
'Warning' { $DevConfig.LogColorWarning }
'Error' { $DevConfig.LogColorError }
'Action' { $DevConfig.LogColorAction }
}
$prefix = switch ($Level) {
'Info' { ' ' }
'Success' { "$([char]0x2713) " }
'Warning' { '[!] ' }
'Error' { "$([char]0x2717) " }
'Action' { '> ' }
}
$ts = Get-Timestamp
$line = "[$ts] $prefix$Message"
# Write to file
if ($script:LogFile) {
try { Add-Content -Path $script:LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue }
catch {}
}
# Write to RichTextBox on UI thread
$script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
try {
$para = [System.Windows.Documents.Paragraph]::new()
$run = [System.Windows.Documents.Run]::new($line)
$run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString($color)
)
$para.Inlines.Add($run)
$para.Margin = [System.Windows.Thickness]::new(0,0,0,0)
$script:RtbLog.Document.Blocks.Add($para)
$script:RtbLog.ScrollToEnd()
} catch {}
})
}
function Show-Panel {
param([string]$Name)
$panels = @('PanelSearch','PanelListMembers','PanelObjectMembership','PanelFindGroupsByOwners','PanelWelcome','PanelCreate','PanelMembership','PanelExport',
'PanelDynamic','PanelRename','PanelOwner','PanelUserDevices','PanelFindCommon','PanelFindDistinct','PanelGetDeviceInfo','PanelGetDiscoveredApps','PanelGetPolicyAssignments','PanelCompareGroups','PanelRemoveGroups')
foreach ($p in $panels) {
$el = $script:Window.FindName($p)
if ($el) { $el.Visibility = if ($p -eq $Name) { 'Visible' } else { 'Collapsed' } }
}
# Show Clear Inputs button on any panel except Welcome
$clearBtn = $script:Window.FindName('BtnClearInputs')
if ($clearBtn) {
$clearBtn.Visibility = if ($Name -ne 'PanelWelcome') { 'Visible' } else { 'Collapsed' }
}
}
function Show-Notification {
param([string]$Message, [string]$BgColor = '#FFF3CD', [string]$FgColor = '#7A4800')
$script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
$script:NotifStrip.Background = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString($BgColor))
$script:TxtNotif.Foreground = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString($FgColor))
$script:TxtNotif.Text = $Message
$script:NotifStrip.Visibility = 'Visible'
})
}
function Hide-Notification {
$script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
$script:NotifStrip.Visibility = 'Collapsed'
})
}
function Update-StatusBar {
param([string]$Text)
$script:Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
$script:TxtStatusBar.Text = $Text
})
}
function Show-ConfirmationDialog {
param(
[string]$Title,
[string]$OperationLabel,
[string]$TargetGroup,
[string]$TargetGroupId,
[object[]]$ValidObjects,
[object[]]$InvalidEntries,
[string]$ExtraInfo = ""
)
$validCount = $ValidObjects.Count
$invalidCount = $InvalidEntries.Count
# Build summary text
$sb = [System.Text.StringBuilder]::new()
$null = $sb.AppendLine("OPERATION : $OperationLabel")
$null = $sb.AppendLine("GROUP : $TargetGroup")
$null = $sb.AppendLine("GROUP ID : $TargetGroupId")
if ($ExtraInfo) { $null = $sb.AppendLine($ExtraInfo) }
$null = $sb.AppendLine("")
$null = $sb.AppendLine("Valid objects : $validCount")
if ($invalidCount -gt 0) {
$null = $sb.AppendLine("Invalid entries: $invalidCount (will be SKIPPED)")
$null = $sb.AppendLine("")
$null = $sb.AppendLine("-- INVALID (not found in Entra) --")
foreach ($inv in $InvalidEntries) { $null = $sb.AppendLine(" $([char]0x2717) $inv") }
}
$null = $sb.AppendLine("")
$null = $sb.AppendLine("-- VALID (will be processed) --")
foreach ($obj in $ValidObjects) {
$dispLabel = if ($obj.Type -eq 'User') { $obj.Original } else { $obj.DisplayName }
$null = $sb.AppendLine(" $([char]0x2713) $dispLabel [$($obj.Type)] ($($obj.Id))")
}
[xml]$dlgXaml = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Confirm Action" Width="620" Height="480"
WindowStartupLocation="CenterOwner"
Background="$($DevConfig.ColorBackground)"
FontFamily="$($DevConfig.FontFamily)" FontSize="13"
ResizeMode="CanResize">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Margin="0,0,0,10">
<TextBlock Text="$Title" FontSize="15" FontWeight="SemiBold"
Foreground="$($DevConfig.ColorPrimary)"/>
<TextBlock Text="Review the summary below before proceeding."
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,4,0,0"/>
</StackPanel>
<Border Grid.Row="1" BorderBrush="$($DevConfig.ColorBorder)" BorderThickness="1"
CornerRadius="6" Background="$($DevConfig.ColorSurface)">
<TextBox x:Name="SummaryBox" IsReadOnly="True" TextWrapping="Wrap"
Background="Transparent" BorderThickness="0"
FontFamily="Consolas" FontSize="11"
Foreground="$($DevConfig.ColorText)"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Padding="10,8"/>
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right"
Margin="0,12,0,0">
<Button x:Name="BtnDlgCancel" Content="Cancel" Width="90" Margin="0,0,10,0"
Background="Transparent"
Foreground="$($DevConfig.ColorText)"
BorderBrush="$($DevConfig.ColorBorder)" BorderThickness="1"
Padding="8,6" Cursor="Hand"/>
<Button x:Name="BtnDlgConfirm" Content="&#x2713; Confirm &amp; Execute" Width="180"
Background="$($DevConfig.ColorAccent)" Foreground="White"
BorderThickness="0" Padding="10,7" FontWeight="SemiBold" Cursor="Hand"/>
</StackPanel>
</Grid>
</Window>
"@
$dlgReader = [System.Xml.XmlNodeReader]::new($dlgXaml)
$dlg = [Windows.Markup.XamlReader]::Load($dlgReader)
$dlg.Owner = $script:Window
$dlg.FindName('SummaryBox').Text = $sb.ToString()
$script:DlgResult = $false
$dlg.FindName('BtnDlgCancel').Add_Click({ $script:DlgResult = $false; $dlg.Close() })
$dlg.FindName('BtnDlgConfirm').Add_Click({ $script:DlgResult = $true; $dlg.Close() })
$dlg.ShowDialog() | Out-Null
return $script:DlgResult
}
#endregion
Write-ErrorLog "Checkpoint 3: UI helpers defined OK."
# ============================================================
#region GRAPH HELPER FUNCTIONS
# ============================================================
function Invoke-GraphGet {
param([string]$Uri)
$results = [System.Collections.Generic.List[object]]::new()
$next = $Uri
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $next
if ($resp['value']) { $results.AddRange([object[]]$resp['value']) }
$next = if ($resp.ContainsKey('@odata.nextLink')) { $resp['@odata.nextLink'] } else { $null }
} while ($next)
return ,$results
}
function Resolve-GroupByNameOrId {
param([string]$GroupEntry)
$trimmed = $GroupEntry.Trim()
if ([string]::IsNullOrWhiteSpace($trimmed)) { return $null }
# GUID pattern → look up by ID
if ($trimmed -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
try {
$g = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$trimmed`?`$select=id,displayName,groupTypes,membershipRule,mailEnabled,securityEnabled"
return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; GroupTypes = $g['groupTypes']; MembershipRule = $g['membershipRule'] }
} catch { return $null }
}
# Name search
$enc = [Uri]::EscapeDataString($trimmed)
try {
$safe_trimmed = $trimmed -replace "'","''" # PS5-safe: extracted to avoid nested double-quotes in string
$resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe_trimmed'`&`$select=id,displayName,groupTypes,membershipRule,mailEnabled,securityEnabled`&`$top=10"
if ($resp['value'] -and $resp['value'].Count -gt 0) {
$g = $resp['value'][0]
return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; GroupTypes = $g['groupTypes']; MembershipRule = $g['membershipRule'] }
}
} catch {}
return $null
}
function Search-Groups {
param([string]$Query)
if ([string]::IsNullOrWhiteSpace($Query)) { return @() }
$enc = $Query.Trim() -replace "'","''"
try {
$resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=startswith(displayName,'$enc')`&`$select=id,displayName,groupTypes`&`$top=15"
return $resp['value']
} catch { return @() }
}
function Resolve-MemberEntry {
param([string]$Entry)
# Returns [PSCustomObject]@{ Id; DisplayName; Type; Found }
# Type = 'User' | 'Group' | 'Device' | 'Unknown'
$trimmed = $Entry.Trim()
if ([string]::IsNullOrWhiteSpace($trimmed)) { return $null }
$isGuid = $trimmed -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
$isUpn = $trimmed -match '^[^@\s]+@[^@\s]+\.[^@\s]+$'
# ---- User (UPN only) ----
if ($isUpn) {
try {
$u = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($trimmed))?`$select=id,displayName,userPrincipalName"
return [PSCustomObject]@{ Id = $u['id']; DisplayName = $u['displayName']; Type = 'User'; Found = $true; Original = $trimmed }
} catch {
return [PSCustomObject]@{ Id = $null; DisplayName = $trimmed; Type = 'User'; Found = $false; Original = $trimmed }
}
}
# ---- GUID - try group, then device ----
if ($isGuid) {
try {
$g = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$trimmed`?`$select=id,displayName"
return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; Type = 'Group'; Found = $true; Original = $trimmed }
} catch {}
try {
$d = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices/$trimmed`?`$select=id,displayName"
return [PSCustomObject]@{ Id = $d['id']; DisplayName = $d['displayName']; Type = 'Device'; Found = $true; Original = $trimmed }
} catch {}
return [PSCustomObject]@{ Id = $null; DisplayName = $trimmed; Type = 'Unknown'; Found = $false; Original = $trimmed }
}
# ---- Display name - try group, then device ----
$safeEntry = $trimmed -replace "'","''"
try {
$resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safeEntry'`&`$select=id,displayName`&`$top=1"
if ($resp['value'] -and $resp['value'].Count -gt 0) {
$g = $resp['value'][0]
return [PSCustomObject]@{ Id = $g['id']; DisplayName = $g['displayName']; Type = 'Group'; Found = $true; Original = $trimmed }
}
} catch {}
try {
$resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safeEntry'`&`$select=id,displayName`&`$top=1"
if ($resp['value'] -and $resp['value'].Count -gt 0) {
$d = $resp['value'][0]
return [PSCustomObject]@{ Id = $d['id']; DisplayName = $d['displayName']; Type = 'Device'; Found = $true; Original = $trimmed }
}
} catch {}
return [PSCustomObject]@{ Id = $null; DisplayName = $trimmed; Type = 'Unknown'; Found = $false; Original = $trimmed }
}
function Validate-InputList {
param([string[]]$Entries)
# Use plain arrays - Generic.List serialises to Object[] across runspace
# boundaries which causes AddRange type errors.
$valid = @()
$invalid = @()
foreach ($entry in $Entries) {
$e = $entry.Trim()
if ([string]::IsNullOrWhiteSpace($e)) { continue }
Write-VerboseLog "Resolving: $e" -Level Info
$result = Resolve-MemberEntry -Entry $e
if ($null -eq $result) { continue }
if ($result.Found) {
Write-VerboseLog " $([char]0x2713) $($result.DisplayName) [$($result.Type)] ($($result.Id))" -Level Success
$valid += $result
} else {
Write-VerboseLog " $([char]0x2717) Not found: $e" -Level Warning
$invalid += $e
}
}
return @{ Valid = $valid; Invalid = $invalid }
}
function Get-GroupMemberCount {
param([string]$GroupId)
# Requires ConsistencyLevel:eventual AND $count=true in URL
# Silently returns $null on any error (e.g. missing permission) - never blocks UI
try {
$resp = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/`$count?`$count=true" `
-Headers @{ ConsistencyLevel = 'eventual' }
return [int]$resp
} catch { return $null }
}
function Get-GroupMembers {
param([string]$GroupId)
# Note: @odata.type must NOT be in $select - Graph returns it automatically.
# Including it causes a BadRequest error.
return Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members?`$select=id,displayName,userPrincipalName`&`$top=999"
}
function Add-MembersToGroup {
param(
[string]$GroupId,
[object[]]$Members
)
$ok = 0; $fail = 0; $skipped = 0
foreach ($m in $Members) {
if ($null -ne $Shared -and $Shared['StopRequested']) {
Write-VerboseLog "Stop requested - halting after $ok added." -Level Warning
break
}
$body = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($m.Id)" }
Write-VerboseLog "Adding $($m.DisplayName) [$($m.Type)]..." -Level Action
try {
$null = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/`$ref" -Body ($body | ConvertTo-Json) -ContentType "application/json"
Write-VerboseLog "Added: $($m.DisplayName)" -Level Success
$ok++
} catch {
$errMsg = $_.Exception.Message
$errDetail = $_.ErrorDetails.Message
if ($errMsg -match 'already exist' -or $errDetail -match 'already exist') {
Write-VerboseLog "Skipping $($m.DisplayName) - already a member of this group" -Level Warning
$skipped++
} else {
Write-VerboseLog "Failed to add $($m.DisplayName): $errMsg" -Level Error
$fail++
}
}
}
return @{ Ok = $ok; Fail = $fail; Skipped = $skipped }
}
function Remove-MembersFromGroup {
param(
[string]$GroupId,
[object[]]$Members
)
$ok = 0; $fail = 0
foreach ($m in $Members) {
if ($null -ne $Shared -and $Shared['StopRequested']) {
Write-VerboseLog "Stop requested - halting after $ok removed." -Level Warning
break
}
Write-VerboseLog "Removing $($m.DisplayName) [$($m.Type)]..." -Level Action
try {
$null = Invoke-MgGraphRequest -Method DELETE -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/$($m.Id)/`$ref"
Write-VerboseLog "Removed: $($m.DisplayName)" -Level Success
$ok++
} catch {
Write-VerboseLog "Failed to remove $($m.DisplayName): $($_.Exception.Message)" -Level Error
$fail++
}
}
return @{ Ok = $ok; Fail = $fail }
}
function Export-GroupMembersToXlsx {
param([string]$GroupId, [string]$GroupName, [string]$OutputPath)
Write-VerboseLog "Fetching members for group: $GroupName" -Level Action
$members = Get-GroupMembers -GroupId $GroupId
Write-VerboseLog "Retrieved $($members.Count) members" -Level Info
# Build rows
$rows = foreach ($m in $members) {
$odataType = if ($m.ContainsKey('@odata.type')) { $m['@odata.type'] } else { '' }
$type = switch ($odataType) {
'#microsoft.graph.user' { 'User' }
'#microsoft.graph.group' { 'Group' }
'#microsoft.graph.device' { 'Device' }
default { if ($odataType) { $odataType } else { 'Unknown' } }
}
[PSCustomObject]@{
DisplayName = $m['displayName']
UserPrincipalName = if ($m['userPrincipalName']) { $m['userPrincipalName'] } else { '-' }
ObjectId = $m['id']
ObjectType = $type
GroupName = $GroupName
GroupId = $GroupId
ExportedAt = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
}
}
# Ensure folder exists
$folder = Split-Path $OutputPath -Parent
if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
# Try ImportExcel if available, else CSV fallback
if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
Import-Module ImportExcel -ErrorAction SilentlyContinue
$rows | Export-Excel -Path $OutputPath -WorksheetName "Members" -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium2
Write-VerboseLog "Exported $($rows.Count) members to XLSX: $OutputPath" -Level Success
} else {
# Fallback: save as CSV with .xlsx extension (user informed)
$rows | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
Write-VerboseLog "ImportExcel module not found - exported as CSV to: $OutputPath" -Level Warning
Write-VerboseLog "Install ImportExcel: Install-Module ImportExcel -Scope CurrentUser" -Level Info
}
return $rows.Count
}
#endregion
Write-ErrorLog "Checkpoint 4: Graph helpers defined OK."
# ============================================================
#region BUILD & WIRE UI
# ============================================================
Write-EarlyLog "Loading XAML..."
Write-ErrorLog "Loading XAML..."
try {
$reader = [System.Xml.XmlNodeReader]::new($Xaml)
$script:Window = [Windows.Markup.XamlReader]::Load($reader)
Write-ErrorLog "XAML loaded OK."
Write-EarlyLog "XAML loaded OK."
} catch {
Write-ErrorLog "XAML load failed: $($_.Exception.Message)"
Write-EarlyLog "XAML load FAILED: $($_.Exception.Message)"
try {
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.MessageBox]::Show("XAML load failed:`n`n$($_.Exception.Message)","Error") | Out-Null
} catch {}
exit 1
}
# Pre-initialise all script-scope state variables (required by Set-StrictMode -Version Latest)
$script:SelectedGroup = $null
$script:ExportSelectedGroup = $null
$script:DynSelectedGroup = $null
$script:RenSelectedGroup = $null
$script:OwnerGroupsTxt = ''
$script:OwnerGroupsValidated = $null
$script:CurrentMemberOp = 'Add'
$script:DlgResult = $false
$script:SensitivityLabels = @() # sensitivity label fetch removed (API permission not available)
$script:DynGroupState = $null # populated by BtnDynValidate Work block; checked in Done block
$script:SearchKeyword = '' # keyword for BtnEntraSearch
$script:SearchTypes = @{} # type filter hashtable
$script:SearchResults = $null # List[PSCustomObject] from BtnEntraSearch
$script:SearchExactMatch = $false # exact-match-only filter for BtnEntraSearch
$script:LMAllResults = $null # List[PSCustomObject] from BtnLMQuery (all, pre-filter)
$script:LMGroupInput = '' # raw text from TxtLMGroupInput
$script:LMProgressTimer = $null # DispatcherTimer for progress bar updates
$script:CGAllResults = $null # List[PSCustomObject] from BtnCGCommon / BtnCGDistinct
$script:CGGroupInput = '' # raw text from TxtCGGroupInput
$script:CGProgressTimer = $null # DispatcherTimer for CG progress bar
$script:CreateSensLabelId = ''
$script:CreateSensLabelName = ''
$script:PickerCallbacks = @{}
$script:LogPaneVisible = $true
$script:AuthTimer = $null
$script:AuthPs = $null
$script:AuthRs = $null
$script:AuthHandle = $null
$script:AuthDlgResult = $false
$script:AuthDlgTenant = ''
$script:AuthDlgClient = ''
$script:BgPs = $null
$script:BgRs = $null
$script:BgHandle = $null
$script:BgTimer = $null
$script:BgDone = $null
$script:BgBtn = $null
$script:SharedBg = $null
$script:BgStopped = $false
$script:PimRoles = @()
$script:PimQueue = $null
$script:PimRs = $null
$script:PimPs = $null
$script:PimHandle = $null
$script:PimTimer = $null
$script:PimAutoTimer = $null
$script:BladeExpanded = $false
$script:CreateParams = $null
$script:CreateValidated = $null
$script:CreateNewGroupId = $null
$script:MemberParams = $null
$script:MemberValidated = $null
$script:MemberExecResult = $null
$script:ExportOutPath = ''
$script:ExportCount = 0
$script:DynRule = ''
$script:RenNewName = ''
$script:RenExistingId = $null
$script:OwnerUpnsTxt = ''
$script:OwnerValidated = $null
$script:OwnerExecResult = $null
$script:UDSelectedGroup = $null
$script:UDParams = $null
$script:UDValidated = $null
$script:UDExecResult = $null
$script:FCParams = $null
$script:FCResult = $null
$script:FDParams = $null
$script:FDResult = $null
$script:GPAParams = $null # GPA: query params passed to background work block
$script:GPAResult = $null # GPA: result rows from background query
$script:RGGroupList = $null # RG: raw group input text
$script:RGValidated = $null # RG: validated group objects for preview
$script:RGExecResult = $null # RG: per-group deletion results
$script:AuthCancelled = $false
$script:AuthStartTime = [datetime]::UtcNow
$script:AuthTimeoutSec = 120
$script:BladeDevExpanded = $false
$script:GDIParams = $null
$script:GdiQueue = $null
$script:GdiStop = $null
$script:OmStop = $null
$script:DaStop = $null
$script:GdiRs = $null
$script:OmQueue = $null
$script:OmRs = $null
$script:OmPs = $null
$script:OmHandle = $null
$script:OmTimer = $null
$script:OMParams = $null
$script:DaQueue = $null
$script:DaRs = $null
$script:DaPs = $null
$script:DaHandle = $null
$script:DaTimer = $null
$script:SearchAllData = $null
$script:LMAllData = $null
$script:OMAllData = $null
$script:FCAllData = $null
$script:FDAllData = $null
$script:GDIAllData = $null
$script:DAAllData = $null
$script:GPAAllData = $null
$script:CGAllData = $null
$script:DAParams = $null
$script:GdiPs = $null
$script:GdiHandle = $null
$script:GdiTimer = $null
# Cache named elements
$script:TxtStatusBar = $script:Window.FindName('TxtStatusBar')
$script:NotifStrip = $script:Window.FindName('NotifStrip')
$script:TxtNotif = $script:Window.FindName('TxtNotif')
$script:PimStrip = $script:Window.FindName('PimStrip')
$script:SpPimRoles = $script:Window.FindName('SpPimRoles')
$script:BgImage = $script:Window.FindName('BgImage')
$script:LogoImg = $script:Window.FindName('LogoImg')
$script:RtbLog = $script:Window.FindName('RtbLog')
$script:LogPane = $script:Window.FindName('LogPane')
# ── Settings panel toggle ─────────────────────────────────────────────────────
# ── Log pane min/max height enforcement via GridSplitter drag ─────────────────
$script:LogPaneMinH = 44
$script:LogPaneMaxH = 260
$script:LogPane.Add_SizeChanged({
$h = $script:LogPane.ActualHeight
if ($h -lt $script:LogPaneMinH -and $script:LogPaneVisible) {
$script:LogPane.Height = $script:LogPaneMinH
} elseif ($h -gt $script:LogPaneMaxH) {
$script:LogPane.Height = $script:LogPaneMaxH
}
})
# ── Helper: load a bitmap from a local path or web URL ──
# BitmapFrame.Create(Uri) handles both file:// and http(s):// without extra modules.
# Local path takes priority over URL.
# ÄÄ SVG -> RasterBitmap (no external libraries required) ÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄ
# Renders SVG via a hidden WPF WebBrowser (IE/Trident engine), captures with
# RenderTargetBitmap, then closes the window. Handles simple/flat logo SVGs.
function Convert-SvgToBitmap {
param([string]$SvgContent, [int]$Width = 120, [int]$Height = 40)
try {
# Add Win32 interop helpers (once per session)
if (-not ([System.Management.Automation.PSTypeName]'SvgCapture').Type) {
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class SvgCapture {
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hwnd, IntPtr hdc, int flags);
[DllImport("user32.dll")] public static extern IntPtr GetDC(IntPtr hwnd);
[DllImport("user32.dll")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
[DllImport("gdi32.dll")] public static extern IntPtr CreateCompatibleDC(IntPtr hdc);
[DllImport("gdi32.dll")] public static extern IntPtr CreateCompatibleBitmap(IntPtr hdc, int w, int h);
[DllImport("gdi32.dll")] public static extern IntPtr SelectObject(IntPtr hdc, IntPtr obj);
[DllImport("gdi32.dll")] public static extern bool DeleteDC(IntPtr hdc);
[DllImport("gdi32.dll")] public static extern bool DeleteObject(IntPtr obj);
}
"@
}
$svgB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($SvgContent))
$html = "<!DOCTYPE html><html><body style='margin:0;padding:0;overflow:hidden;" +
"background:transparent;'><img src='data:image/svg+xml;base64,$svgB64' " +
"width='$Width' height='$Height'/></body></html>"
$wb = [System.Windows.Controls.WebBrowser]::new()
$wb.Width = $Width
$wb.Height = $Height
$null = $wb.Add_LoadCompleted({ $script:_SvgLoadDone = $true })
$hostWin = [System.Windows.Window]::new()
$hostWin.WindowStyle = 'None'
$hostWin.ShowInTaskbar = $false
$hostWin.AllowsTransparency = $false
$hostWin.Left = -32000; $hostWin.Top = -32000
$hostWin.Width = $Width; $hostWin.Height = $Height
$hostWin.Content = $wb
$script:_SvgLoadDone = $false
$null = $hostWin.Show()
$null = $wb.NavigateToString($html)
# Wait up to 4 s for LoadCompleted, pumping dispatcher each cycle
$deadline = (Get-Date).AddSeconds(4)
while (-not $script:_SvgLoadDone -and (Get-Date) -lt $deadline) {
$null = [System.Windows.Application]::Current.Dispatcher.Invoke(
[Action]{}, [System.Windows.Threading.DispatcherPriority]::ApplicationIdle)
Start-Sleep -Milliseconds 80
}
# Extra render pump
$null = [System.Windows.Application]::Current.Dispatcher.Invoke(
[Action]{}, [System.Windows.Threading.DispatcherPriority]::Render)
Start-Sleep -Milliseconds 100
# Get the HWND of the host window (not the WebBrowser — window HWND captures its children too)
$helper = [System.Windows.Interop.WindowInteropHelper]::new($hostWin)
$hwnd = $helper.Handle
# Capture via PrintWindow (works on HWND-hosted controls; RenderTargetBitmap does NOT)
$screenDC = [SvgCapture]::GetDC([IntPtr]::Zero)
$memDC = [SvgCapture]::CreateCompatibleDC($screenDC)
$hBmp = [SvgCapture]::CreateCompatibleBitmap($screenDC, $Width, $Height)
$oldObj = [SvgCapture]::SelectObject($memDC, $hBmp)
# PW_RENDERFULLCONTENT = 2 (forces re-render, needed for HwndHost controls)
$null = [SvgCapture]::PrintWindow($hwnd, $memDC, 2)
# Convert GDI HBITMAP -> WPF BitmapSource
$bmpSrc = [System.Windows.Interop.Imaging]::CreateBitmapSourceFromHBitmap(
$hBmp, [IntPtr]::Zero,
[System.Windows.Int32Rect]::Empty,
[System.Windows.Media.Imaging.BitmapSizeOptions]::FromEmptyOptions())
$null = $bmpSrc.Freeze()
# GDI cleanup
$null = [SvgCapture]::SelectObject($memDC, $oldObj)
$null = [SvgCapture]::DeleteDC($memDC)
$null = [SvgCapture]::DeleteObject($hBmp)
$null = [SvgCapture]::ReleaseDC([IntPtr]::Zero, $screenDC)
$null = $hostWin.Close()
$null = Write-VerboseLog "SVG logo captured at ${Width}x${Height}." -Level Success
return $bmpSrc
} catch {
$null = Write-VerboseLog "SVG render failed: $($_.Exception.Message)" -Level Warning
return $null
}
}
function Load-BitmapSource {
param([string]$LocalPath, [string]$Url, [string]$Base64,
[int]$SvgW = 120, [int]$SvgH = 40)
# Robust SVG detector: handles BOM, <?xml?> preamble, leading whitespace
function Test-IsSvg { param([string]$s)
if (-not $s) { return $false }
$sample = $s.Substring(0, [Math]::Min(600, $s.Length))
return $sample -match '(?is)<svg[\s>]'
}
$result = $null
# Priority 1: Base64-encoded image embedded in DevConfig
if (-not [string]::IsNullOrWhiteSpace($Base64)) {
try {
$bytes = [Convert]::FromBase64String($Base64.Trim())
$decoded = [System.Text.Encoding]::UTF8.GetString($bytes, 0, [Math]::Min(600, $bytes.Length))
if (Test-IsSvg $decoded) {
$svgText = [System.Text.Encoding]::UTF8.GetString($bytes)
$result = Convert-SvgToBitmap -SvgContent $svgText -Width $SvgW -Height $SvgH
} else {
# Raster bytes (PNG / JPG / ICO / BMP)
$stream = [System.IO.MemoryStream]::new($bytes)
$bmp = [System.Windows.Media.Imaging.BitmapImage]::new()
$bmp.BeginInit()
$bmp.StreamSource = $stream
$bmp.CacheOption = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
$bmp.EndInit()
$null = $bmp.Freeze()
$result = $bmp
}
} catch { Write-VerboseLog "Base64 logo decode failed: $($_.Exception.Message)" -Level Warning }
# Ensure we return only a BitmapSource (discard any leaked pipeline objects)
if ($result -isnot [System.Windows.Media.Imaging.BitmapSource]) { $result = $null }
return $result
}
# Priority 2: Local file path
if (-not [string]::IsNullOrWhiteSpace($LocalPath) -and (Test-Path $LocalPath)) {
if ($LocalPath -match '\.svg$') {
$result = Convert-SvgToBitmap -SvgContent ([IO.File]::ReadAllText($LocalPath)) -Width $SvgW -Height $SvgH
} else {
$result = [System.Windows.Media.Imaging.BitmapFrame]::Create([Uri]::new((Resolve-Path $LocalPath).Path))
}
if ($result -isnot [System.Windows.Media.Imaging.BitmapSource]) { $result = $null }
return $result
}
# Priority 3: Web URL
if (-not [string]::IsNullOrWhiteSpace($Url)) {
if ($Url -match '\.svg(\?|$)') {
try {
$resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -ErrorAction Stop
$body = if ($resp.Content -is [byte[]]) {
[System.Text.Encoding]::UTF8.GetString($resp.Content)
} else { [string]$resp.Content }
$result = Convert-SvgToBitmap -SvgContent $body -Width $SvgW -Height $SvgH
} catch { Write-VerboseLog "SVG URL download failed: $($_.Exception.Message)" -Level Warning }
if ($result -isnot [System.Windows.Media.Imaging.BitmapSource]) { $result = $null }
return $result
}
try {
$bmp = [System.Windows.Media.Imaging.BitmapImage]::new()
$bmp.BeginInit()
$bmp.UriSource = [Uri]::new($Url)
$bmp.CacheOption = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
$bmp.CreateOptions = [System.Windows.Media.Imaging.BitmapCreateOptions]::None
$bmp.EndInit()
$result = $bmp
} catch { Write-VerboseLog "URL logo load failed: $($_.Exception.Message)" -Level Warning }
}
return $result
}
# Apply background image (local path or URL)
try {
$bgSrc = Load-BitmapSource -LocalPath $DevConfig.BackgroundImagePath -Url $DevConfig.BackgroundImageUrl
if ($bgSrc) { $script:BgImage.Source = $bgSrc }
} catch { Write-VerboseLog "Could not load background image: $($_.Exception.Message)" -Level Warning }
# Apply logo (local path or URL)
try {
$logoSrc = Load-BitmapSource -LocalPath $DevConfig.LogoImagePath -Url $DevConfig.LogoImageUrl `
-Base64 $DevConfig.LogoBase64 -SvgW $DevConfig.LogoWidth -SvgH $DevConfig.LogoHeight
if ($logoSrc) {
$script:LogoImg.Source = $logoSrc
$script:LogoImg.Visibility = 'Visible'
}
} catch { Write-VerboseLog "Could not load logo: $($_.Exception.Message)" -Level Warning }
# Log pane toggle
$script:Window.FindName('BtnLogToggle').Add_Click({
if ($script:LogPaneVisible) {
$script:LogPane.Height = 0
$script:LogPane.Visibility = 'Collapsed'
$script:Window.FindName('BtnLogToggle').Content = '^ Show'
$script:LogPaneVisible = $false
} else {
$script:LogPane.Visibility = 'Visible'
$script:LogPane.Height = $DevConfig.VerbosePaneDefaultHeight
$script:Window.FindName('BtnLogToggle').Content = 'v Hide'
$script:LogPaneVisible = $true
}
})
$script:Window.FindName('BtnLogClear').Add_Click({
$script:RtbLog.Document.Blocks.Clear()
})
# ÄÄ Collapse Group Mgmt blade on startup ÄÄ
$script:Window.FindName('BladeGroupMgmtContent').Visibility = 'Collapsed'
$script:Window.FindName('BladeDivider').Visibility = 'Collapsed'
$script:Window.FindName('TxtBladeArrow').Text = '+'
# ── Group Management blade toggle ──
$script:BladeExpanded = $false
$script:Window.FindName('BtnBladeGroupMgmt').Add_Click({
$script:BladeExpanded = -not $script:BladeExpanded
$content = $script:Window.FindName('BladeGroupMgmtContent')
$divider = $script:Window.FindName('BladeDivider')
$arrow = $script:Window.FindName('TxtBladeArrow')
if ($script:BladeExpanded) {
$content.Visibility = 'Visible'
$divider.Visibility = 'Visible'
$arrow.Text = [char]0x2212
} else {
$content.Visibility = 'Collapsed'
$divider.Visibility = 'Collapsed'
$arrow.Text = '+'
}
})
# ── Clear Inputs button ──
# Show when any panel other than Welcome is active; clears all input fields in the current panel
$script:Window.FindName('BtnClearInputs').Add_Click({
$panels = @{
'PanelCreate' = @('TxtCreateName','TxtCreateMailNick','TxtCreateDesc','TxtCreateOwner','TxtCreateMembers','TxtCreateDynamic')
'PanelMembership' = @('TxtGroupSearch','TxtMemberList')
'PanelExport' = @('TxtExportGroupSearch','TxtExportPath')
'PanelDynamic' = @('TxtDynGroupSearch','TxtDynRule')
'PanelRename' = @('TxtRenGroupSearch','TxtNewGroupName')
'PanelOwner' = @('TxtOwnerGroupList','TxtOwnerList')
'PanelUserDevices'= @('TxtUDGroupSearch','TxtUDUpnList')
'PanelFindCommon' = @('TxtFCInputList')
'PanelFindDistinct' = @('TxtFDInputList')
'PanelGetDeviceInfo' = @('TxtGDIInputList')
'PanelGetDiscoveredApps' = @('TxtDAInputList','TxtDAKeyword')
'PanelSearch' = @('TxtSearchKeyword')
'PanelListMembers' = @('TxtLMGroupInput')
'PanelObjectMembership' = @('TxtOMInputList')
'PanelFindGroupsByOwners' = @('TxtFGOInputList')
'PanelCompareGroups' = @('TxtCGGroupInput')
'PanelGetPolicyAssignments' = @('TxtGPANameFilter')
'PanelRemoveGroups' = @('TxtRGGroupList')
}
$badgesToHide = @(
'SelectedGroupBadge','GroupSearchResults',
'ExportSelectedGroupBadge','ExportGroupSearchResults',
'DynSelectedGroupBadge','DynGroupSearchResults',
'RenSelectedGroupBadge','RenGroupSearchResults',
'UDSelectedGroupBadge','UDGroupSearchResults'
)
foreach ($panelName in $panels.Keys) {
$panel = $script:Window.FindName($panelName)
if ($panel -and $panel.Visibility -eq 'Visible') {
foreach ($fieldName in $panels[$panelName]) {
$field = $script:Window.FindName($fieldName)
if ($field) { $field.Text = '' }
}
# Reset combos (Create panel)
$cmb = $script:Window.FindName('CmbCreateType')
if ($cmb) { $cmb.SelectedIndex = 0 }
$cmbLbl = $script:Window.FindName('CmbSensitivityLabel')
if ($cmbLbl) { $cmbLbl.SelectedIndex = 0 }
# Hide group badges and result lists
foreach ($b in $badgesToHide) {
$el = $script:Window.FindName($b)
if ($el) { $el.Visibility = 'Collapsed' }
}
# Reset selected group state for this panel
$script:SelectedGroup = $null
$script:ExportSelectedGroup = $null
$script:DynSelectedGroup = $null
$script:RenSelectedGroup = $null
$script:OwnerGroupsTxt = ''
$script:OwnerGroupsValidated = $null
$script:UDSelectedGroup = $null
# Special reset for Search panel (clears results + buttons + checkboxes)
if ($panelName -eq 'PanelSearch') {
$dg = $script:Window.FindName('DgSearchResults')
if ($dg) { $dg.ItemsSource = $null }
$script:Window.FindName('PnlSearchResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtSearchNoResults').Visibility = 'Collapsed'
$script:Window.FindName('BtnSearchCopyValue').IsEnabled = $false
$script:Window.FindName('BtnSearchCopyRow').IsEnabled = $false
$script:Window.FindName('BtnSearchCopyAll').IsEnabled = $false
$script:Window.FindName('TxtSearchFilter').IsEnabled = $false
$script:Window.FindName('BtnSearchFilter').IsEnabled = $false
$script:Window.FindName('BtnSearchFilterClear').IsEnabled = $false
$script:Window.FindName('TxtSearchFilter').Text = ''
$script:SearchAllData = $null
$script:Window.FindName('BtnSearchExportXlsx').IsEnabled = $false
foreach ($cb in @('ChkSrchUsers','ChkSrchDevices','ChkSrchSG','ChkSrchM365')) {
$cbx = $script:Window.FindName($cb)
if ($cbx) { $cbx.IsChecked = $true }
}
$script:SearchResults = $null
}
# Special reset for List Group Members panel
if ($panelName -eq 'PanelListMembers') {
$dg = $script:Window.FindName('DgLMResults')
if ($dg) { $dg.ItemsSource = $null; $dg.Columns.Clear() }
$script:Window.FindName('PnlLMResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtLMNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlLMProgress').Visibility = 'Collapsed'
$script:Window.FindName('BtnLMCopyValue').IsEnabled = $false
$script:Window.FindName('BtnLMCopyRow').IsEnabled = $false
$script:Window.FindName('BtnLMCopyAll').IsEnabled = $false
$script:Window.FindName('TxtLMFilter').IsEnabled = $false
$script:Window.FindName('BtnLMFilter').IsEnabled = $false
$script:Window.FindName('BtnLMFilterClear').IsEnabled = $false
$script:Window.FindName('TxtLMFilter').Text = ''
$script:LMAllData = $null
$script:Window.FindName('BtnLMExportXlsx').IsEnabled = $false
foreach ($cb in @('ChkLMUsers','ChkLMDevices','ChkLMGroups')) {
$cbx = $script:Window.FindName($cb)
if ($cbx) { $cbx.IsChecked = $true }
}
$script:LMAllResults = $null
if ($script:LMProgressTimer) { $script:LMProgressTimer.Stop() }
}
# Special reset for Object Membership panel
if ($panelName -eq 'PanelObjectMembership') {
$dg = $script:Window.FindName('DgOMResults')
if ($dg) { $dg.ItemsSource = $null }
$script:Window.FindName('PnlOMResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtOMNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlOMProgress').Visibility = 'Collapsed'
$script:Window.FindName('BtnOMCopyValue').IsEnabled = $false
$script:Window.FindName('BtnOMCopyRow').IsEnabled = $false
$script:Window.FindName('BtnOMCopyAll').IsEnabled = $false
$script:Window.FindName('BtnOMFilter').IsEnabled = $false
$script:Window.FindName('BtnOMFilterClear').IsEnabled = $false
$script:OMAllData = $null
$script:Window.FindName('TxtOMFilter').Text = ''
$script:Window.FindName('TxtOMFilter').IsEnabled = $false
$script:Window.FindName('BtnOMExportXlsx').IsEnabled = $false
$script:OMAllResults = $null
if ($script:OmTimer) { $script:OmTimer.Stop() }
}
# Special reset for Find Groups by Owners panel
if ($panelName -eq 'PanelFindGroupsByOwners') {
$dg = $script:Window.FindName('DgFGOResults')
if ($dg) { $dg.ItemsSource = $null }
$script:Window.FindName('PnlFGOResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtFGONoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlFGOProgress').Visibility = 'Collapsed'
$script:Window.FindName('BtnFGOCopyValue').IsEnabled = $false
$script:Window.FindName('BtnFGOCopyRow').IsEnabled = $false
$script:Window.FindName('BtnFGOCopyAll').IsEnabled = $false
$script:Window.FindName('BtnFGOFilter').IsEnabled = $false
$script:Window.FindName('BtnFGOFilterClear').IsEnabled = $false
$script:FGOAllData = $null
$script:Window.FindName('TxtFGOFilter').Text = ''
$script:Window.FindName('TxtFGOFilter').IsEnabled = $false
$script:Window.FindName('BtnFGOExportXlsx').IsEnabled = $false
if ($script:FGOTimer) { $script:FGOTimer.Stop() }
}
# Special reset for Get Discovered Apps panel
if ($panelName -eq 'PanelGetDiscoveredApps') {
$dg = $script:Window.FindName('DgDAResults')
if ($dg) { $dg.ItemsSource = $null }
$script:Window.FindName('PnlDAResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtDANoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlDAProgress').Visibility = 'Collapsed'
$script:Window.FindName('BtnDACopyValue').IsEnabled = $false
$script:Window.FindName('BtnDACopyRow').IsEnabled = $false
$script:Window.FindName('BtnDACopyAll').IsEnabled = $false
$script:Window.FindName('TxtDAFilter').IsEnabled = $false
$script:Window.FindName('BtnDAFilter').IsEnabled = $false
$script:Window.FindName('BtnDAFilterClear').IsEnabled = $false
$script:Window.FindName('TxtDAFilter').Text = ''
$script:DAAllData = $null
$script:Window.FindName('BtnDAExportXlsx').IsEnabled = $false
if ($script:DaTimer) { $script:DaTimer.Stop() }
foreach ($chk in @('ChkDAWindows','ChkDAAndroid','ChkDAiOS','ChkDAMacOS')) {
$cbx = $script:Window.FindName($chk)
if ($cbx) { $cbx.IsChecked = $false }
}
}
# Special reset for Compare Groups panel
if ($panelName -eq 'PanelCompareGroups') {
$dg = $script:Window.FindName('DgCGResults')
if ($dg) { $dg.ItemsSource = $null }
$script:Window.FindName('PnlCGResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtCGNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlCGProgress').Visibility = 'Collapsed'
$script:Window.FindName('BtnCGCopyValue').IsEnabled = $false
$script:Window.FindName('BtnCGCopyRow').IsEnabled = $false
$script:Window.FindName('BtnCGCopyAll').IsEnabled = $false
$script:Window.FindName('TxtCGFilter').IsEnabled = $false
$script:Window.FindName('BtnCGFilter').IsEnabled = $false
$script:Window.FindName('BtnCGFilterClear').IsEnabled = $false
$script:Window.FindName('TxtCGFilter').Text = ''
$script:CGAllData = $null
$script:Window.FindName('BtnCGExportXlsx').IsEnabled = $false
$script:CGAllResults = $null
if ($script:CGProgressTimer) { $script:CGProgressTimer.Stop() }
$script:Window.FindName('BtnCGCommon').IsEnabled = $true
$script:Window.FindName('BtnCGDistinct').IsEnabled = $true
}
# Special reset for Find Common Groups panel
if ($panelName -eq 'PanelFindCommon') {
$dg = $script:Window.FindName('DgFCResults')
if ($dg) { $dg.ItemsSource = $null }
$script:Window.FindName('PnlFCResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtFCNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlFCProgress').Visibility = 'Collapsed'
$script:Window.FindName('BtnFCCopyValue').IsEnabled = $false
$script:Window.FindName('BtnFCCopyRow').IsEnabled = $false
$script:Window.FindName('BtnFCCopyAll').IsEnabled = $false
$script:Window.FindName('TxtFCFilter').Text = ''
$script:FCAllData = $null
$script:Window.FindName('BtnFCFilter').IsEnabled = $false
$script:Window.FindName('BtnFCFilterClear').IsEnabled = $false
$script:Window.FindName('TxtFCFilter').IsEnabled = $false
$script:Window.FindName('BtnFCExportXlsx').IsEnabled = $false
$script:FCResult = $null
}
# Special reset for Find Distinct Groups panel
if ($panelName -eq 'PanelFindDistinct') {
$dg = $script:Window.FindName('DgFDResults')
if ($dg) { $dg.ItemsSource = $null }
$script:Window.FindName('PnlFDResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtFDNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlFDProgress').Visibility = 'Collapsed'
$script:Window.FindName('BtnFDCopyValue').IsEnabled = $false
$script:Window.FindName('BtnFDCopyRow').IsEnabled = $false
$script:Window.FindName('BtnFDCopyAll').IsEnabled = $false
$script:Window.FindName('TxtFDFilter').IsEnabled = $false
$script:Window.FindName('BtnFDFilter').IsEnabled = $false
$script:Window.FindName('BtnFDFilterClear').IsEnabled = $false
$script:Window.FindName('TxtFDFilter').Text = ''
$script:FDAllData = $null
$script:Window.FindName('BtnFDExportXlsx').IsEnabled = $false
$script:FDResult = $null
}
# Special reset for Get Policy Assignments panel
if ($panelName -eq 'PanelGetPolicyAssignments') {
$script:Window.FindName('CmbGPAType').SelectedIndex = 0
$script:Window.FindName('CmbGPAType').IsEnabled = $true
$script:Window.FindName('CmbGPASubType').SelectedIndex = 0
$script:Window.FindName('CmbGPAPlatform').SelectedIndex = 0
$script:Window.FindName('PnlGPAProgress').Visibility = 'Collapsed'
$script:Window.FindName('PnlGPAResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtGPANoResults').Visibility = 'Collapsed'
$dg = $script:Window.FindName('DgGPAResults')
if ($dg) { $dg.ItemsSource = $null }
foreach ($b in @('BtnGPACopyValue','BtnGPACopyRow','BtnGPACopyAll','BtnGPAExportXlsx')) {
$script:Window.FindName($b).IsEnabled = $false
}
foreach ($b in @('BtnGPAGetSelected','BtnGPAGetAll')) {
$script:Window.FindName($b).IsEnabled = $true
}
$script:GPAResult = $null
}
# Special reset for Remove Groups panel
if ($panelName -eq 'PanelRemoveGroups') {
$dg = $script:Window.FindName('DgRGPreview')
if ($dg) { $dg.ItemsSource = $null }
$dg2 = $script:Window.FindName('DgRGResults')
if ($dg2) { $dg2.ItemsSource = $null }
$script:Window.FindName('PnlRGProgress').Visibility = 'Collapsed'
$script:Window.FindName('PnlRGWarning').Visibility = 'Collapsed'
$script:Window.FindName('PnlRGPreview').Visibility = 'Collapsed'
$script:Window.FindName('PnlRGExecProgress').Visibility = 'Collapsed'
$script:Window.FindName('PnlRGResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtRGNoResults').Visibility = 'Collapsed'
$script:Window.FindName('BtnRGExecute').IsEnabled = $false
$script:Window.FindName('BtnRGCopyAll').IsEnabled = $false
$script:Window.FindName('BtnRGExportXlsx').IsEnabled = $false
$script:RGGroupList = $null
$script:RGValidated = $null
$script:RGExecResult = $null
}
Hide-Notification
Write-VerboseLog "Inputs cleared." -Level Info
break
}
}
})
# ── Reset-AuthUI - defined at script scope so the DispatcherTimer tick can call it ──
function Reset-AuthUI {
param([string]$StatusText = 'Not connected')
try { $script:AuthTimer.Stop() } catch {}
try { $script:AuthPs.Stop(1000, $null) | Out-Null } catch {}
try { $script:AuthPs.EndInvoke($script:AuthHandle) } catch {}
try { $script:AuthRs.Close() } catch {}
try { $script:AuthPs.Dispose() } catch {}
$script:Window.Tag = $null
try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {}
$script:Window.FindName('BtnConnect').IsEnabled = $true
$script:Window.FindName('BtnAuthCancel').Visibility = 'Collapsed'
Update-StatusBar -Text $StatusText
}
# ── CONNECT button ──
$script:Window.FindName('BtnConnect').Add_Click({
Hide-Notification
# ── Auth input dialog - pre-populated from DevConfig, user can override Client ID ──
[xml]$authDlgXaml = @"
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Connect to Microsoft Graph" Width="460" SizeToContent="Height"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
Background="$($DevConfig.ColorBackground)"
FontFamily="$($DevConfig.FontFamily)" FontSize="13">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="10"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="10"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="16"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0">
<TextBlock Text="TENANT ID" FontSize="11" FontWeight="SemiBold"
Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,3"/>
<TextBox x:Name="TxtAuthTenantId" FontSize="12" Padding="8,6"
Background="$($DevConfig.ColorSurface)" Foreground="$($DevConfig.ColorText)"
BorderBrush="$($DevConfig.ColorBorder)" BorderThickness="1"/>
</StackPanel>
<StackPanel Grid.Row="2">
<TextBlock Text="CLIENT ID (override or leave as configured)" FontSize="11" FontWeight="SemiBold"
Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,3"/>
<TextBox x:Name="TxtAuthClientId" FontSize="12" Padding="8,6"
Background="$($DevConfig.ColorSurface)" Foreground="$($DevConfig.ColorText)"
BorderBrush="$($DevConfig.ColorBorder)" BorderThickness="1"/>
</StackPanel>
<StackPanel Grid.Row="4">
<TextBlock x:Name="TxtAuthError" FontSize="11" Foreground="$($DevConfig.ColorError)"
Visibility="Collapsed" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Grid.Row="6" Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="BtnAuthCancel" Content="Cancel" Width="80" Margin="0,0,10,0"
Background="Transparent" Foreground="$($DevConfig.ColorText)"
BorderBrush="$($DevConfig.ColorBorder)" BorderThickness="1"
Padding="8,6" Cursor="Hand"/>
<Button x:Name="BtnAuthConnect" Content="Connect" Width="100"
Background="$($DevConfig.ColorPrimary)" Foreground="White"
BorderThickness="0" Padding="8,6" FontWeight="SemiBold" Cursor="Hand"/>
</StackPanel>
</Grid>
</Window>
"@
$authReader = [System.Xml.XmlNodeReader]::new($authDlgXaml)
$authDlg = [Windows.Markup.XamlReader]::Load($authReader)
$authDlg.Owner = $script:Window
# Pre-populate fields
$authDlg.FindName('TxtAuthTenantId').Text = $DevConfig.TenantId
$authDlg.FindName('TxtAuthClientId').Text = $DevConfig.ClientId
$script:AuthDlgResult = $false
$script:AuthDlgTenant = ''
$script:AuthDlgClient = ''
$authDlg.FindName('BtnAuthCancel').Add_Click({
$script:AuthDlgResult = $false
$authDlg.Close()
})
$authDlg.FindName('BtnAuthConnect').Add_Click({
$tid = $authDlg.FindName('TxtAuthTenantId').Text.Trim()
$cid = $authDlg.FindName('TxtAuthClientId').Text.Trim()
$errBlk = $authDlg.FindName('TxtAuthError')
if ([string]::IsNullOrWhiteSpace($tid)) {
$errBlk.Text = "Tenant ID is required."
$errBlk.Visibility = 'Visible'
return
}
if ([string]::IsNullOrWhiteSpace($cid)) {
$errBlk.Text = "Client ID is required."
$errBlk.Visibility = 'Visible'
return
}
$script:AuthDlgResult = $true
$script:AuthDlgTenant = $tid
$script:AuthDlgClient = $cid
$authDlg.Close()
})
$authDlg.ShowDialog() | Out-Null
if (-not $script:AuthDlgResult) {
Write-VerboseLog "Connection cancelled by user." -Level Warning
return
}
$effectiveTenantId = $script:AuthDlgTenant
$effectiveClientId = $script:AuthDlgClient
# Auth timeout - seconds to wait for browser authentication before aborting
$script:AuthTimeoutSec = 120
$script:Window.FindName('BtnConnect').IsEnabled = $false
$script:Window.FindName('BtnAuthCancel').Visibility = 'Visible'
$script:Window.FindName('BtnAuthCancel').IsEnabled = $true
$script:Window.FindName('BtnAuthCancel').Content = (New-StopBtnContent 'Cancel Auth')
$script:AuthCancelled = $false
$script:AuthStartTime = [datetime]::UtcNow
Write-VerboseLog 'Initiating Microsoft Graph connection...' -Level Action
Write-VerboseLog "Tenant ID : $effectiveTenantId" -Level Info
Write-VerboseLog "Client ID : $effectiveClientId" -Level Info
Write-VerboseLog "Browser authentication window should open. Sign in within $script:AuthTimeoutSec seconds, or click [Stop] Cancel Auth to abort." -Level Info
Update-StatusBar -Text 'Waiting for browser authentication... (Cancel Auth to abort)'
$rs = [runspacefactory]::CreateRunspace()
$rs.ApartmentState = 'STA'
$rs.ThreadOptions = 'ReuseThread'
$rs.Open()
$rs.SessionStateProxy.SetVariable('TenantId', $effectiveTenantId)
$rs.SessionStateProxy.SetVariable('ClientId', $effectiveClientId)
$rs.SessionStateProxy.SetVariable('Window', $script:Window)
$ps = [powershell]::Create()
$ps.Runspace = $rs
$null = $ps.AddScript({
param()
# ── Helper: clear MSAL token cache to remove stale/corrupt entries ──
function Clear-MsalCache {
try {
$cachePath = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenCache'
if (Test-Path $cachePath) {
Get-ChildItem $cachePath -File -ErrorAction SilentlyContinue |
Remove-Item -Force -ErrorAction SilentlyContinue
}
# Also clear the Graph SDK's own cache
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
} catch {}
}
# ── Helper: try browser auth, return error message or $null on success ──
function Try-BrowserAuth {
try {
try {
Connect-MgGraph -TenantId $TenantId -ClientId $ClientId -NoWelcome `
-AuthType Browser -ErrorAction Stop
} catch [System.Management.Automation.ParameterBindingException] {
# -AuthType not supported in this SDK version
Connect-MgGraph -TenantId $TenantId -ClientId $ClientId -NoWelcome -ErrorAction Stop
}
return $null # success
} catch {
return $_.Exception.Message
}
}
try {
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop
# ── Attempt 1: browser auth ────────────────────────────────────
# Force browser re-authentication - clear any cached MSAL tokens first
Clear-MsalCache
$err1 = Try-BrowserAuth
if ($err1) {
# Signal UI to log the first failure
$Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
$Window.Tag = "warn:Browser auth failed: $err1 - clearing cache and retrying..."
})
Start-Sleep -Milliseconds 500
# ── Attempt 2: clear MSAL cache + retry browser ────────────
Clear-MsalCache
$err2 = Try-BrowserAuth
if ($err2) {
# Signal UI to show device code instructions
$Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
$Window.Tag = "warn:Browser auth failed again: $err2 - switching to device code flow..."
})
Start-Sleep -Milliseconds 500
# ── Attempt 3: TLS callback fix + retry browser ────────
# Zscaler and other SSL-inspection proxies re-sign TLS
# connections. The runspace may not inherit the Windows
# certificate store correctly, causing MSAL to reject the
# Zscaler root CA and produce an invalid JWT error.
# Setting ServerCertificateValidationCallback to null forces
# .NET to use the OS certificate store (which includes the
# Zscaler root CA pushed by IT policy). Scoped to this
# runspace only - disposed immediately after auth.
[Net.ServicePointManager]::SecurityProtocol = `
[Net.SecurityProtocolType]::Tls12
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = $null
Clear-MsalCache
$err3 = Try-BrowserAuth
if ($err3) { throw $err3 }
}
}
$ctx = Get-MgContext
$Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
$Window.Tag = "connected:$($ctx.Account)|$($ctx.TenantId)|$(($ctx.Scopes -join ','))"
})
} catch {
$err = $_.Exception.Message
$Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action]{
$Window.Tag = "error:$err"
})
}
})
$handle = $ps.BeginInvoke()
$timer = [System.Windows.Threading.DispatcherTimer]::new()
$timer.Interval = [TimeSpan]::FromMilliseconds(500)
$script:AuthTimer = $timer
$script:AuthPs = $ps
$script:AuthRs = $rs
$script:AuthHandle = $handle
$timer.Add_Tick({
# ── User clicked Cancel Auth ────────────────────────────────
if ($script:AuthCancelled) {
Write-VerboseLog 'Authentication cancelled by user.' -Level Warning
Reset-AuthUI -StatusText 'Not connected'
Show-Notification 'Authentication cancelled.' -BgColor '#FFF3CD' -FgColor '#7A4800'
return
}
# ── Timeout ─────────────────────────────────────────────────
$elapsed = ([datetime]::UtcNow - $script:AuthStartTime).TotalSeconds
if ($elapsed -gt $script:AuthTimeoutSec) {
Write-VerboseLog "Authentication timed out after $script:AuthTimeoutSec seconds." -Level Warning
Reset-AuthUI -StatusText 'Not connected - timed out'
Show-Notification "Authentication timed out after $script:AuthTimeoutSec seconds. Please try again." `
-BgColor '#FFF3CD' -FgColor '#7A4800'
return
}
$tag = $script:Window.Tag -as [string]
if (-not $tag) { return }
# ── Intermediate warning from runspace (cache clear / fallback notice) ──
if ($tag.StartsWith('warn:')) {
$warnMsg = $tag.Substring(5)
$script:Window.Tag = $null
Write-VerboseLog $warnMsg -Level Warning
# If switching to device code, update status bar to guide the user
if ($warnMsg -like '*TLS*' -or $warnMsg -like '*switching*') {
Update-StatusBar -Text 'Retrying with TLS fix - please complete browser authentication again'
}
return
}
if ($tag.StartsWith('connected:')) {
$script:AuthTimer.Stop()
$info = $tag.Substring(10) -split '\|'
$acct = $info[0]
$tenant = $info[1]
try { $script:AuthPs.EndInvoke($script:AuthHandle) } catch {}
$script:AuthRs.Close()
$script:AuthPs.Dispose()
$script:Window.Tag = $null
$script:Window.FindName('BtnAuthCancel').Visibility = 'Collapsed'
Write-VerboseLog 'Connected successfully' -Level Success
Write-VerboseLog "Account : $acct" -Level Info
Write-VerboseLog "Tenant : $tenant" -Level Info
Update-StatusBar -Text "Connected as: $acct | Tenant: $tenant"
$script:Window.FindName('BtnConnect').IsEnabled = $false
$script:Window.FindName('BtnDisconnect').IsEnabled = $true
Show-Notification -Message "Connected as $acct" -BgColor '#D4EDDA' -FgColor '#155724'
foreach ($b in @('BtnOpSearch','BtnOpListMembers','BtnOpObjectMembership','BtnOpFindGroupsByOwners','BtnOpCreate','BtnOpAdd','BtnOpRemove','BtnOpExport','BtnOpDynamic','BtnOpRename','BtnOpOwner','BtnOpGetPolicyAssignments','BtnOpUserDevices','BtnOpFindCommon','BtnOpFindDistinct','BtnOpGetDeviceInfo','BtnOpGetDiscoveredApps','BtnOpCompareGroups','BtnOpRemoveGroups')) {
$script:Window.FindName($b).IsEnabled = $true
}
Invoke-PimCheck
Start-PimAutoRefresh
} elseif ($tag.StartsWith('error:')) {
$err = $tag.Substring(6)
Write-VerboseLog "Connection failed: $err" -Level Error
Reset-AuthUI -StatusText 'Not connected'
Show-Notification "Connection failed: $err" -BgColor '#F8D7DA' -FgColor '#721C24'
}
})
$timer.Start()
})
# ── AUTH CANCEL button ──
$script:Window.FindName('BtnAuthCancel').Add_Click({
$script:AuthCancelled = $true
$script:Window.FindName('BtnAuthCancel').IsEnabled = $false
$script:Window.FindName('BtnAuthCancel').Content = 'Cancelling...'
Write-VerboseLog 'Cancelling authentication...' -Level Warning
})
# ── DISCONNECT button ──
$script:Window.FindName('BtnDisconnect').Add_Click({
try {
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
Write-VerboseLog "Disconnected from Microsoft Graph." -Level Info
} catch {}
Update-StatusBar -Text "Not connected"
$script:Window.FindName('BtnConnect').IsEnabled = $true
$script:Window.FindName('BtnDisconnect').IsEnabled = $false
$script:PimStrip.Visibility = 'Collapsed'
$script:SpPimRoles.Children.Clear()
Stop-PimAutoRefresh
Show-Panel 'PanelWelcome'
Hide-Notification
foreach ($b in @('BtnOpSearch','BtnOpListMembers','BtnOpObjectMembership','BtnOpFindGroupsByOwners','BtnOpCreate','BtnOpAdd','BtnOpRemove','BtnOpExport','BtnOpDynamic','BtnOpRename','BtnOpOwner','BtnOpGetPolicyAssignments','BtnOpUserDevices','BtnOpFindCommon','BtnOpFindDistinct','BtnOpGetDeviceInfo','BtnOpGetDiscoveredApps','BtnOpCompareGroups','BtnOpRemoveGroups')) {
$btn = $script:Window.FindName($b)
if ($btn) { $btn.IsEnabled = $false }
}
})
# ÄÄ FEEDBACK button ÄÄ
# Hide the button if no FeedbackUrl is configured
$btnFeedback = $script:Window.FindName('BtnFeedback')
if ($btnFeedback) {
# Always show the button; click handler opens URL or logs a hint if not configured
$btnFeedback.Visibility = 'Visible'
$btnFeedback.Add_Click({
$url = $DevConfig.FeedbackUrl
if (-not [string]::IsNullOrWhiteSpace($url)) {
try { Start-Process $url } catch {
Write-VerboseLog "Could not open feedback URL: $url" -Level Warning
}
} else {
Write-VerboseLog "Feedback URL not configured. Set FeedbackUrl in DevConfig." -Level Info
}
})
}
# ── AUTHOR attribution link (blog version) ──
$authorLink = $script:Window.FindName('TxtAuthorLink')
if ($authorLink) {
$authorLink.Add_MouseLeftButtonDown({
try { Start-Process "https://www.linkedin.com/in/satish-singhi-791163167/" } catch {}
})
}
# ── PIM Role Status Check ────────────────────────────────────────────────────
function Invoke-PimCheck {
# Queries the current user's active PIM role assignments via Graph.
# Runs on a background runspace, renders results as coloured badges
# in the PIM strip. Colour: green = >60min, amber = <=60min, red = <=15min.
Write-VerboseLog "Checking active PIM roles..." -Level Info
$script:PimStrip.Visibility = 'Visible'
$script:SpPimRoles.Children.Clear()
# Placeholder while loading
$loadTb = [System.Windows.Controls.TextBlock]::new()
$loadTb.Text = "Checking..."; $loadTb.FontSize = 11; $loadTb.VerticalAlignment = 'Center'
$loadTb.Foreground = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString('#A0C0E8'))
$loadTb.Margin = [System.Windows.Thickness]::new(0,0,0,0)
$script:SpPimRoles.Children.Add($loadTb) | Out-Null
$script:PimQueue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new()
$script:PimRs = [runspacefactory]::CreateRunspace()
$script:PimRs.ApartmentState = 'MTA'
$script:PimRs.ThreadOptions = 'ReuseThread'
$script:PimRs.Open()
$script:PimRs.SessionStateProxy.SetVariable('PimQueue', $script:PimQueue)
$script:PimPs = [powershell]::Create()
$script:PimPs.Runspace = $script:PimRs
$null = $script:PimPs.AddScript({
function PQ { param([hashtable]$M) $PimQueue.Enqueue($M) }
function Send-RoleResult {
param([string]$RoleName, [string]$AssignType)
# Expiry detection removed - show active role name only.
PQ @{ Type='role'; RoleName=$RoleName; AssignType=$AssignType }
}
Import-Module Microsoft.Graph.Authentication -ErrorAction SilentlyContinue
$ctx = Get-MgContext
if (-not $ctx) { PQ @{ Type='error'; Msg='No active Graph context' }; PQ @{ Type='done' }; return }
$me = Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/me?$select=id,userPrincipalName'
$userId = $me['id']
PQ @{ Type='log'; Msg="Checking PIM roles for: $($me['userPrincipalName'])" }
# ── transitiveMemberOf: active directory roles for the current user ──
# Uses only Directory.Read.All (already required). Returns currently active roles.
try {
$uri2 = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.directoryRole" +
"?`$select=id,displayName,roleTemplateId"
$resp2 = Invoke-MgGraphRequest -Method GET -Uri $uri2
$roles = $resp2['value']
PQ @{ Type='log'; Msg=('PIM: transitiveMemberOf (' + $roles.Count + ' active role(s))') }
if (-not $roles -or $roles.Count -eq 0) {
PQ @{ Type='none' }
} else {
foreach ($role in $roles) {
Send-RoleResult -RoleName $role['displayName'] -AssignType 'Active (PIM)'
}
}
} catch {
$pimErr = $_.Exception.Message
if ($pimErr -match '403|Forbidden|401|Unauthorized') {
PQ @{ Type='nopim' }
} else {
PQ @{ Type='error'; Msg=$pimErr }
}
}
PQ @{ Type='done' }
})
$script:PimHandle = $script:PimPs.BeginInvoke()
$script:PimTimer = [System.Windows.Threading.DispatcherTimer]::new()
$script:PimTimer.Interval = [TimeSpan]::FromMilliseconds(200)
$script:PimTimer.Add_Tick({
$m = $null
$gotResults = $false
while ($script:PimQueue.TryDequeue([ref]$m)) {
switch ($m['Type']) {
'log' {
# Verbose log message from runspace
Write-VerboseLog "PIM: $($m['Msg'])" -Level Info
}
'role' {
if (-not $gotResults) {
$script:SpPimRoles.Children.Clear()
$gotResults = $true
}
# Build badge - role name only, no expiry
$badge = [System.Windows.Controls.Border]::new()
$badge.CornerRadius = [System.Windows.CornerRadius]::new(4)
$badge.Padding = [System.Windows.Thickness]::new(8,3,8,3)
$badge.Margin = [System.Windows.Thickness]::new(0,3,6,3)
$badge.Background = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString('#1A5E3A'))
$badge.ToolTip = "$($m['RoleName']) ($($m['AssignType']))"
$badgeTb = [System.Windows.Controls.TextBlock]::new()
$badgeTb.FontSize = 10.5
$badgeTb.FontWeight = 'SemiBold'
$badgeTb.Foreground = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString('#90EEC0'))
$badgeTb.Text = "$([char]0x2713) $($m['RoleName'])"
$badge.Child = $badgeTb
$script:SpPimRoles.Children.Add($badge) | Out-Null
Write-VerboseLog "PIM: $([char]0x2713) $($m['RoleName']) ($($m['AssignType']))" -Level Info
}
'none' {
# API returned 200 but zero instances - no active roles right now
$script:SpPimRoles.Children.Clear()
$tb = [System.Windows.Controls.TextBlock]::new()
$tb.Text = "No active PIM roles detected"
$tb.FontSize = 11
$tb.VerticalAlignment = 'Center'
$tb.ToolTip = "Navigate to Entra ID > Privileged Identity Management > My roles to activate"
$tb.Foreground = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString('#92400E'))
$script:SpPimRoles.Children.Add($tb) | Out-Null
Write-VerboseLog "PIM: No active PIM roles detected" -Level Warning
}
'nopim' {
# 403 response - no active PIM roles or RoleManagement.Read.Directory not consented
# Treat the same as 'none': roles are not active
$script:SpPimRoles.Children.Clear()
$tb = [System.Windows.Controls.TextBlock]::new()
$tb.Text = "No active PIM roles detected"
$tb.FontSize = 11
$tb.VerticalAlignment = 'Center'
$tb.ToolTip = "403 response from Graph - either no PIM roles are active, or RoleManagement.Read.Directory permission is not granted on the App Registration.`nNavigate to Entra ID > Privileged Identity Management > My roles to activate."
$tb.Foreground = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString('#92400E'))
$script:SpPimRoles.Children.Add($tb) | Out-Null
Write-VerboseLog "PIM: No active PIM roles detected (transitiveMemberOf returned 403)" -Level Warning
Write-VerboseLog "PIM: To activate: Entra ID > Privileged Identity Management > My roles" -Level Info
}
'error' {
# Unexpected error - not a permission issue
$script:SpPimRoles.Children.Clear()
$tb = [System.Windows.Controls.TextBlock]::new()
$tb.Text = "PIM check error - see verbose log"
$tb.FontSize = 11
$tb.VerticalAlignment = 'Center'
$tb.ToolTip = $m['Msg']
$tb.Foreground = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString('#F06C6C'))
$script:SpPimRoles.Children.Add($tb) | Out-Null
Write-VerboseLog "PIM check error: $($m['Msg'])" -Level Error
}
'done' {
$script:PimTimer.Stop()
$script:PimPs.EndInvoke($script:PimHandle)
$script:PimRs.Close(); $script:PimPs.Dispose()
$script:PimPs = $null; $script:PimRs = $null
}
}
}
})
$script:PimTimer.Start()
}
# ── PIM refresh button ──
$script:Window.FindName('BtnPimRefresh').Add_Click({
Write-VerboseLog "PIM roles - manual refresh." -Level Info
Invoke-PimCheck
})
# ── PIM auto-refresh timer ────────────────────────────────────────────────────
# Fires every PimRefreshIntervalMinutes. Skipped silently if a background
# operation is running ($script:BgPs is not null = job in progress).
function Start-PimAutoRefresh {
if ($DevConfig.PimRefreshIntervalMinutes -le 0) { return }
if ($null -ne $script:PimAutoTimer) {
$script:PimAutoTimer.Stop()
}
$script:PimAutoTimer = [System.Windows.Threading.DispatcherTimer]::new()
$script:PimAutoTimer.Interval = [TimeSpan]::FromMinutes($DevConfig.PimRefreshIntervalMinutes)
$script:PimAutoTimer.Add_Tick({
# Skip if a background Graph operation is currently running
if ($null -ne $script:BgPs) {
Write-VerboseLog "PIM auto-refresh skipped - background operation in progress." -Level Info
return
}
# Skip if PIM check itself is already running
if ($null -ne $script:PimPs) {
return
}
Write-VerboseLog "PIM auto-refresh (every $($DevConfig.PimRefreshIntervalMinutes) min)." -Level Info
Invoke-PimCheck
})
$script:PimAutoTimer.Start()
Write-VerboseLog "PIM auto-refresh enabled - interval: $($DevConfig.PimRefreshIntervalMinutes) min." -Level Info
}
function Stop-PimAutoRefresh {
if ($null -ne $script:PimAutoTimer) {
$script:PimAutoTimer.Stop()
$script:PimAutoTimer = $null
}
}
# Helper: build StackPanel content for Stop-style buttons (icon + label)
function New-StopBtnContent {
param([string]$Text)
$sp = [System.Windows.Controls.StackPanel]::new()
$sp.Orientation = [System.Windows.Controls.Orientation]::Horizontal
$ico = [System.Windows.Controls.TextBlock]::new()
$ico.Text = [char]0xE71A
$ico.FontFamily = [System.Windows.Media.FontFamily]::new("Segoe MDL2 Assets")
$ico.FontSize = 12
$ico.VerticalAlignment = [System.Windows.VerticalAlignment]::Center
$ico.Margin = [System.Windows.Thickness]::new(0,0,5,0)
$ico.Foreground = [System.Windows.Media.Brushes]::White
$lbl = [System.Windows.Controls.TextBlock]::new()
$lbl.Text = $Text
$lbl.VerticalAlignment = [System.Windows.VerticalAlignment]::Center
$null = $sp.Children.Add($ico)
$null = $sp.Children.Add($lbl)
return $sp
}
# ── STOP button ──
$script:Window.FindName('BtnStop').Add_Click({
Write-VerboseLog "Stop requested by user - stopping after current object..." -Level Warning
$script:BgStopped = $true
if ($null -ne $script:SharedBg) { $script:SharedBg['StopRequested'] = $true }
if ($null -ne $script:GdiStop) { $script:GdiStop['Stop'] = $true }
if ($null -ne $script:OmStop) { $script:OmStop['Stop'] = $true }
if ($null -ne $script:DaStop) { $script:DaStop['Stop'] = $true }
if ($null -ne $script:FGOStop) { $script:FGOStop['Stop'] = $true }
$script:Window.FindName('BtnStop').IsEnabled = $false
$script:Window.FindName('BtnStop').Content = (New-StopBtnContent "Stopping...")
})
# Disable operation buttons until connected
foreach ($b in @('BtnOpSearch','BtnOpListMembers','BtnOpObjectMembership','BtnOpFindGroupsByOwners','BtnOpCreate','BtnOpAdd','BtnOpRemove','BtnOpExport','BtnOpDynamic','BtnOpRename','BtnOpOwner','BtnOpGetPolicyAssignments','BtnOpUserDevices','BtnOpFindCommon','BtnOpFindDistinct','BtnOpGetDeviceInfo','BtnOpGetDiscoveredApps','BtnOpCompareGroups','BtnOpRemoveGroups')) {
$btn = $script:Window.FindName($b)
if ($btn) { $btn.IsEnabled = $false }
}
# ── Op tile buttons ──
$script:Window.FindName('BtnOpCreate').Add_Click({
Show-Panel 'PanelCreate'
Hide-Notification
Write-VerboseLog "Panel: Create Group" -Level Info
})
$script:Window.FindName('BtnOpAdd').Add_Click({
$script:CurrentMemberOp = 'Add'
$script:Window.FindName('TxtMembershipTitle').Text = "Add Members"
$script:Window.FindName('TxtMemberInputLabel').Text = "MEMBERS TO ADD - one per line (User UPNs / Group names or IDs / Device names or IDs)"
$script:Window.FindName('BtnMemberValidate').Content = "Validate & Preview ->"
# Reset state
$script:Window.FindName('TxtGroupSearch').Text = ""
$script:Window.FindName('TxtMemberList').Text = ""
$script:Window.FindName('SelectedGroupBadge').Visibility = 'Collapsed'
$script:Window.FindName('GroupSearchResults').Visibility = 'Collapsed'
$script:SelectedGroup = $null
Show-Panel 'PanelMembership'
Hide-Notification
Write-VerboseLog "Panel: Add Members" -Level Info
})
$script:Window.FindName('BtnOpRemove').Add_Click({
$script:CurrentMemberOp = 'Remove'
$script:Window.FindName('TxtMembershipTitle').Text = "Remove Members"
$script:Window.FindName('TxtMemberInputLabel').Text = "MEMBERS TO REMOVE - one per line (User UPNs / Group names or IDs / Device names or IDs)"
$script:Window.FindName('BtnMemberValidate').Content = "Validate & Preview ->"
$script:Window.FindName('TxtGroupSearch').Text = ""
$script:Window.FindName('TxtMemberList').Text = ""
$script:Window.FindName('SelectedGroupBadge').Visibility = 'Collapsed'
$script:Window.FindName('GroupSearchResults').Visibility = 'Collapsed'
$script:SelectedGroup = $null
Show-Panel 'PanelMembership'
Hide-Notification
Write-VerboseLog "Panel: Remove Members" -Level Info
})
$script:Window.FindName('BtnOpExport').Add_Click({
$script:Window.FindName('TxtExportGroupSearch').Text = ""
$script:Window.FindName('TxtExportPath').Text = ""
$script:Window.FindName('ExportSelectedGroupBadge').Visibility = 'Collapsed'
$script:Window.FindName('ExportGroupSearchResults').Visibility = 'Collapsed'
$script:ExportSelectedGroup = $null
Show-Panel 'PanelExport'
Hide-Notification
Write-VerboseLog "Panel: Export Members" -Level Info
})
$script:Window.FindName('BtnOpDynamic').Add_Click({
$script:Window.FindName('TxtDynGroupSearch').Text = ""
$script:Window.FindName('TxtDynRule').Text = ""
$script:Window.FindName('DynSelectedGroupBadge').Visibility = 'Collapsed'
$script:Window.FindName('DynGroupSearchResults').Visibility = 'Collapsed'
$script:DynSelectedGroup = $null
Show-Panel 'PanelDynamic'
Hide-Notification
Write-VerboseLog "Panel: Set Dynamic Query" -Level Info
})
$script:Window.FindName('BtnOpRename').Add_Click({
$script:Window.FindName('TxtRenGroupSearch').Text = ""
$script:Window.FindName('TxtNewGroupName').Text = ""
$script:Window.FindName('RenSelectedGroupBadge').Visibility = 'Collapsed'
$script:Window.FindName('RenGroupSearchResults').Visibility = 'Collapsed'
$script:RenSelectedGroup = $null
Show-Panel 'PanelRename'
Hide-Notification
Write-VerboseLog "Panel: Rename Group" -Level Info
})
$script:Window.FindName('BtnOpOwner').Add_Click({
$script:Window.FindName('TxtOwnerGroupList').Text = ""
$script:Window.FindName('TxtOwnerList').Text = ""
$script:OwnerGroupsTxt = ''
$script:OwnerGroupsValidated = $null
Show-Panel 'PanelOwner'
Hide-Notification
Write-VerboseLog "Panel: Set Group Owner" -Level Info
})
$script:Window.FindName('BtnOpSearch').Add_Click({
$script:Window.FindName('TxtSearchKeyword').Text = ""
$script:Window.FindName('PnlSearchResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtSearchNoResults').Visibility = 'Collapsed'
$script:SearchResults = $null
Show-Panel 'PanelSearch'
Hide-Notification
Write-VerboseLog "Panel: Search Entra Objects" -Level Info
})
$script:Window.FindName('BtnOpListMembers').Add_Click({
$script:Window.FindName('PnlLMResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtLMNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlLMProgress').Visibility = 'Collapsed'
Show-Panel 'PanelListMembers'
Hide-Notification
Write-VerboseLog "Panel: List Group Members" -Level Info
})
$script:Window.FindName('BtnOpObjectMembership').Add_Click({
$script:Window.FindName('PnlOMResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtOMNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlOMProgress').Visibility = 'Collapsed'
$script:Window.FindName('TxtOMInputList').Text = ''
$script:Window.FindName('CmbOMObjectType').SelectedIndex = 0
$script:Window.FindName('TxtOMInputLabel').Text = 'USER UPNs - one per line'
$dg = $script:Window.FindName('DgOMResults')
if ($dg) { $dg.ItemsSource = $null }
$script:Window.FindName('BtnOMCopyValue').IsEnabled = $false
$script:Window.FindName('BtnOMCopyRow').IsEnabled = $false
$script:Window.FindName('BtnOMCopyAll').IsEnabled = $false
$script:Window.FindName('TxtOMFilter').IsEnabled = $false
$script:Window.FindName('BtnOMFilter').IsEnabled = $false
$script:Window.FindName('BtnOMFilterClear').IsEnabled = $false
$script:Window.FindName('TxtOMFilter').Text = ''
$script:OMAllData = $null
$script:Window.FindName('BtnOMExportXlsx').IsEnabled = $false
Show-Panel 'PanelObjectMembership'
Hide-Notification
Write-VerboseLog "Panel: Object Membership" -Level Info
})
$script:Window.FindName('CmbOMObjectType').Add_SelectionChanged({
$sel = $script:Window.FindName('CmbOMObjectType').SelectedItem.Content
$label = switch ($sel) {
'Users (UPN)' { 'USER UPNs - one per line' }
'Devices (Name)' { 'DEVICE NAMES - one per line' }
'Devices (ID)' { 'DEVICE IDs - one per line' }
'Security Groups' { 'SECURITY GROUP names or IDs - one per line' }
'M365 Groups' { 'M365 GROUP names or IDs - one per line' }
default { 'OBJECTS - one per line' }
}
$script:Window.FindName('TxtOMInputLabel').Text = $label
})
# ── OBJECT MEMBERSHIP - Run ─────────────────────────────────────────
$script:Window.FindName('BtnOMRun').Add_Click({
Hide-Notification
$inputTxt = $script:Window.FindName('TxtOMInputList').Text
$objTypeSel = $script:Window.FindName('CmbOMObjectType').SelectedItem.Content
if ([string]::IsNullOrWhiteSpace($inputTxt)) {
Show-Notification 'Please enter at least one identifier.' -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$script:OMParams = @{
InputTxt = $inputTxt
ObjType = $objTypeSel
}
# Reset UI
$script:Window.FindName('PnlOMResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtOMNoResults').Visibility = 'Collapsed'
$script:Window.FindName('BtnOMCopyValue').IsEnabled = $false
$script:Window.FindName('BtnOMCopyRow').IsEnabled = $false
$script:Window.FindName('BtnOMCopyAll').IsEnabled = $false
$script:Window.FindName('BtnOMFilter').IsEnabled = $false
$script:Window.FindName('BtnOMFilterClear').IsEnabled = $false
$script:Window.FindName('TxtOMFilter').Text = ''
$script:OMAllData = $null
$script:Window.FindName('TxtOMFilter').IsEnabled = $false
$script:Window.FindName('BtnOMExportXlsx').IsEnabled = $false
$dg = $script:Window.FindName('DgOMResults')
if ($dg) { $dg.ItemsSource = $null }
$script:Window.FindName('PnlOMProgress').Visibility = 'Visible'
$script:Window.FindName('TxtOMProgressMsg').Text = 'Resolving objects...'
$script:Window.FindName('TxtOMProgressDetail').Text = ''
$script:Window.FindName('BtnOMRun').IsEnabled = $false
$script:Window.FindName('BtnStop').Visibility = 'Visible'
$script:Window.FindName('BtnStop').IsEnabled = $true
$script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
$script:BgStopped = $false
Write-VerboseLog '--- Object Membership: starting ---' -Level Action
$script:OmQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
$script:OmStop = [hashtable]::Synchronized(@{ Stop = $false })
$script:OmRs = [runspacefactory]::CreateRunspace()
$script:OmRs.ApartmentState = 'STA'
$script:OmRs.ThreadOptions = 'ReuseThread'
$script:OmRs.Open()
$script:OmRs.SessionStateProxy.SetVariable('OmQueue', $script:OmQueue)
$script:OmRs.SessionStateProxy.SetVariable('OmStop', $script:OmStop)
$script:OmRs.SessionStateProxy.SetVariable('OmParams', $script:OMParams)
$script:OmRs.SessionStateProxy.SetVariable('LogFile', $script:LogFile)
$script:OmPs = [powershell]::Create()
$script:OmPs.Runspace = $script:OmRs
$null = $script:OmPs.AddScript({
function OLog {
param([string]$Msg, [string]$Level = 'Info')
$prefix = switch ($Level) {
"Success" { "$([char]0x2713) " } "Warning" { "[!] " } "Error" { "$([char]0x2717) " }
'Action' { '> ' } default { ' ' }
}
$line = "[$(Get-Date -Format 'HH:mm:ss')] $prefix$Msg"
$OmQueue.Enqueue(@{ Type='log'; Line=$line; Level=$Level })
if ($LogFile) {
try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } catch {}
}
}
function Get-GraphAll([string]$Uri) {
$items = [System.Collections.Generic.List[object]]::new()
$next = $Uri
do {
$r = Invoke-MgGraphRequest -Method GET -Uri $next
if ($r['value']) { foreach ($i in @($r['value'])) { $items.Add($i) } }
$next = if ($r.ContainsKey('@odata.nextLink')) { $r['@odata.nextLink'] } else { $null }
} while ($next)
return ,$items
}
$isGuidRx = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$'
function Resolve-ObjectId {
param([string]$Entry, [string]$ObjType)
$Entry = $Entry.Trim()
if ([string]::IsNullOrWhiteSpace($Entry)) { return $null }
switch ($ObjType) {
'Users (UPN)' {
try {
$enc = [Uri]::EscapeDataString($Entry)
$u = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users/$enc`?`$select=id,displayName"
return @{ Id=[string]$u['id']; DisplayName=[string]$u['displayName']; ResourceType='users' }
} catch { return $null }
}
'Devices (Name)' {
try {
$safe = $Entry -replace "'","''"
$r = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safe'`&`$select=id,displayName`&`$top=1"
if ($r['value'] -and $r['value'].Count -gt 0) {
$d = $r['value'][0]
return @{ Id=[string]$d['id']; DisplayName=[string]$d['displayName']; ResourceType='devices' }
}
return $null
} catch { return $null }
}
'Devices (ID)' {
try {
$enc = [Uri]::EscapeDataString($Entry)
$d = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices/$enc`?`$select=id,displayName"
return @{ Id=[string]$d['id']; DisplayName=[string]$d['displayName']; ResourceType='devices' }
} catch { return $null }
}
'Security Groups' {
try {
if ($Entry -match $isGuidRx) {
$g = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$Entry`?`$select=id,displayName"
return @{ Id=[string]$g['id']; DisplayName=[string]$g['displayName']; ResourceType='groups' }
} else {
$safe = $Entry -replace "'","''"
$r = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe' and securityEnabled eq true and mailEnabled eq false`&`$select=id,displayName`&`$top=1"
if ($r['value'] -and $r['value'].Count -gt 0) {
$d = $r['value'][0]
return @{ Id=[string]$d['id']; DisplayName=[string]$d['displayName']; ResourceType='groups' }
}
return $null
}
} catch { return $null }
}
'M365 Groups' {
try {
if ($Entry -match $isGuidRx) {
$g = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$Entry`?`$select=id,displayName"
return @{ Id=[string]$g['id']; DisplayName=[string]$g['displayName']; ResourceType='groups' }
} else {
$safe = $Entry -replace "'","''"
$r = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe' and groupTypes/any(c:c eq 'Unified')`&`$select=id,displayName`&`$top=1"
if ($r['value'] -and $r['value'].Count -gt 0) {
$d = $r['value'][0]
return @{ Id=[string]$d['id']; DisplayName=[string]$d['displayName']; ResourceType='groups' }
}
return $null
}
} catch { return $null }
}
}
return $null
}
try {
$p = $OmParams
$entries = @($p['InputTxt'] -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
OLog "Processing $($entries.Count) input(s) - Type: $($p['ObjType'])" 'Action'
$allRows = [System.Collections.Generic.List[PSCustomObject]]::new()
$idx = 0
foreach ($entry in $entries) {
if ($OmStop['Stop']) { OLog 'Stop requested - halting.' 'Warning'; break }
$idx++
$OmQueue.Enqueue(@{ Type='progress'; Msg="Resolving object $idx of $($entries.Count)..."; Detail=$entry })
OLog "Resolving: $entry" 'Info'
$resolved = Resolve-ObjectId -Entry $entry -ObjType $p['ObjType']
if (-not $resolved) {
OLog " Could not resolve: $entry" 'Warning'
continue
}
$resType = $resolved['ResourceType']
$resId = $resolved['Id']
$resName = $resolved['DisplayName']
OLog " Resolved: $resName ($resType/$resId)" 'Success'
OLog " Fetching transitive memberships..." 'Info'
try {
$memberships = Get-GraphAll "https://graph.microsoft.com/v1.0/$resType/$resId/transitiveMemberOf?`$select=id,displayName,description,groupTypes,securityEnabled,mailEnabled`&`$top=999"
} catch {
OLog " transitiveMemberOf failed: $($_.Exception.Message)" 'Warning'
continue
}
OLog " $($memberships.Count) group membership(s) found" 'Info'
# Fetch direct memberships to distinguish Direct vs Transitive
$directIds = [System.Collections.Generic.HashSet[string]]::new()
try {
$directMembers = Get-GraphAll "https://graph.microsoft.com/v1.0/$resType/$resId/memberOf?`$select=id`&`$top=999"
foreach ($dm in $directMembers) {
$null = $directIds.Add([string]$dm['id'])
}
} catch {}
foreach ($grp in $memberships) {
if ($OmStop['Stop']) { break }
$odataType = [string]$grp['@odata.type']
if ($odataType -ne '#microsoft.graph.group') { continue }
$gId = [string]$grp['id']
$gName = [string]$grp['displayName']
$gDesc = [string]$grp['description']
$gTypes = @($grp['groupTypes'])
$secEn = $grp['securityEnabled']
$mailEn = $grp['mailEnabled']
$groupType = if ($gTypes -contains 'Unified') { 'M365' }
elseif ($secEn -eq $true -and $mailEn -eq $false) { 'Security' }
elseif ($secEn -eq $true -and $mailEn -eq $true) { 'Mail-Enabled Security' }
elseif ($secEn -eq $false -and $mailEn -eq $true) { 'Distribution' }
else { 'Other' }
$memType = if ($directIds.Contains($gId)) { 'Direct' } else { 'Transitive' }
$objTypeDisplay = switch ($resType) {
'users' { 'User' }
'devices' { 'Device' }
'groups' { 'Group' }
}
$allRows.Add([PSCustomObject]@{
InputObject = $entry
ObjectType = $objTypeDisplay
GroupName = $gName
GroupId = $gId
GroupType = $groupType
Description = $gDesc
MembershipType = $memType
})
}
}
OLog "Completed: $($allRows.Count) membership record(s) found." 'Success'
$OmQueue.Enqueue(@{ Type='done'; Rows=$allRows; Error=$null })
} catch {
$OmQueue.Enqueue(@{ Type='done'; Rows=@(); Error=$_.Exception.Message })
}
})
$script:OmHandle = $script:OmPs.BeginInvoke()
# Timer to drain OmQueue on the UI thread
$script:OmTimer = [System.Windows.Threading.DispatcherTimer]::new()
$script:OmTimer.Interval = [TimeSpan]::FromMilliseconds(300)
$script:OmTimer.Add_Tick({
$entry = $null
while ($script:OmQueue.TryDequeue([ref]$entry)) {
switch ($entry['Type']) {
'log' {
$lvl = $entry['Level']
$col = switch ($lvl) {
'Success' { $DevConfig.LogColorSuccess }
'Warning' { $DevConfig.LogColorWarning }
'Error' { $DevConfig.LogColorError }
'Action' { $DevConfig.LogColorAction }
default { $DevConfig.LogColorInfo }
}
try {
$para = [System.Windows.Documents.Paragraph]::new()
$run = [System.Windows.Documents.Run]::new($entry['Line'])
$run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString($col))
$para.Margin = [System.Windows.Thickness]::new(0)
$para.Inlines.Add($run)
$script:RtbLog.Document.Blocks.Add($para)
$script:RtbLog.ScrollToEnd()
} catch {}
}
'progress' {
$script:Window.FindName('PnlOMProgress').Visibility = 'Visible'
$script:Window.FindName('TxtOMProgressMsg').Text = $entry['Msg']
$script:Window.FindName('TxtOMProgressDetail').Text = $entry['Detail']
Update-StatusBar -Text "Object Membership: $($entry['Msg'])"
}
'done' {
$script:OmTimer.Stop()
try { $script:OmPs.EndInvoke($script:OmHandle) } catch {}
try { $script:OmRs.Close() } catch {}
try { $script:OmPs.Dispose() } catch {}
$script:OmPs = $null; $script:OmRs = $null
$script:Window.FindName('BtnStop').Visibility = 'Collapsed'
$script:Window.FindName('BtnStop').IsEnabled = $true
$script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
$script:Window.FindName('BtnOMRun').IsEnabled = $true
$script:Window.FindName('PnlOMProgress').Visibility = 'Collapsed'
$errMsg = $entry['Error']
if ($errMsg) {
Write-VerboseLog "Object Membership error: $errMsg" -Level Error
Show-Notification "Error: $errMsg" -BgColor '#F8D7DA' -FgColor '#721C24'
Update-StatusBar -Text 'Connected'
return
}
$rows = @($entry['Rows'])
if ($rows.Count -eq 0) {
Write-VerboseLog 'Object Membership: no results.' -Level Warning
$script:Window.FindName('TxtOMNoResults').Visibility = 'Visible'
Show-Notification 'No group memberships found.' -BgColor '#FFF3CD' -FgColor '#7A4800'
Update-StatusBar -Text 'Connected'
return
}
$dg = $script:Window.FindName('DgOMResults')
$dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($rows)
$script:Window.FindName('TxtOMCount').Text = "$($rows.Count) membership(s) found"
$script:Window.FindName('PnlOMResults').Visibility = 'Visible'
$script:Window.FindName('BtnOMCopyAll').IsEnabled = $true
$script:Window.FindName('TxtOMFilter').IsEnabled = $true
$script:Window.FindName('BtnOMFilter').IsEnabled = $true
$script:Window.FindName('BtnOMFilterClear').IsEnabled = $true
$script:Window.FindName('BtnOMExportXlsx').IsEnabled = $true
Write-VerboseLog "Object Membership: $($rows.Count) record(s) loaded." -Level Success
Show-Notification "$($rows.Count) membership(s) ready." -BgColor '#D4EDDA' -FgColor '#155724'
Update-StatusBar -Text 'Connected'
}
}
}
})
$script:OmTimer.Start()
})
# ── DgOMResults: cell selection ──
$script:Window.FindName('DgOMResults').Add_SelectedCellsChanged({
$dg = $script:Window.FindName('DgOMResults')
$selCells = $dg.SelectedCells.Count
$script:Window.FindName('BtnOMCopyValue').IsEnabled = ($selCells -ge 1)
$script:Window.FindName('BtnOMCopyRow').IsEnabled = ($selCells -gt 0)
})
# ── BtnOMCopyValue ──
# ── BtnOMFilter ──
$script:Window.FindName('BtnOMFilter').Add_Click({
$dg = $script:Window.FindName('DgOMResults')
$keyword = $script:Window.FindName('TxtOMFilter').Text.Trim()
if ($null -eq $script:OMAllData) {
$script:OMAllData = @($dg.ItemsSource)
}
if ($keyword -eq '') {
$dg.ItemsSource = $script:OMAllData
Show-Notification "Filter cleared - $($script:OMAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$filtered = @($script:OMAllData | Where-Object {
$row = $_
$match = $false
foreach ($prop in $row.PSObject.Properties) {
if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
}
$match
})
# ── BtnOMFilterClear ──
$script:Window.FindName('BtnOMFilterClear').Add_Click({
$script:Window.FindName('TxtOMFilter').Text = ''
$dg = $script:Window.FindName('DgOMResults')
if ($null -ne $script:OMAllData) {
$dg.ItemsSource = $script:OMAllData
Show-Notification "Filter cleared - $($script:OMAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
}
})
$dg.ItemsSource = $filtered
Show-Notification "$($filtered.Count) of $($script:OMAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
}
Write-VerboseLog "OM: Filter applied - keyword='$keyword'" -Level Info
})
$script:Window.FindName('BtnOMCopyValue').Add_Click({
$dg = $script:Window.FindName('DgOMResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$vals = [System.Collections.Generic.List[string]]::new()
foreach ($cellInfo in @($dg.SelectedCells)) {
$col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
}
[System.Windows.Clipboard]::SetText($vals -join "`r`n")
Show-Notification '$($vals.Count) value(s) copied to clipboard.' -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog 'OM: Copied cell value to clipboard.' -Level Success
})
# ── BtnOMCopyRow ──
$script:Window.FindName('BtnOMCopyRow').Add_Click({
$dg = $script:Window.FindName('DgOMResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$seen = [System.Collections.Generic.HashSet[object]]::new()
$rowObjs = [System.Collections.Generic.List[object]]::new()
foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rowObjs.Add($cell.Item) } }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$txtLines = [System.Collections.Generic.List[string]]::new()
$txtLines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
foreach ($row in $rowObjs) {
$vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
$txtLines.Add($vals -join "`t")
}
[System.Windows.Clipboard]::SetText($txtLines -join "`n")
Show-Notification "$($rowObjs.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "OM: Copied $($rowObjs.Count) row(s) to clipboard." -Level Success
# Highlight full rows
$dg.UnselectAllCells()
foreach ($r in $rowObjs) { $null = $dg.SelectedItems.Add($r) }
})
# ── BtnOMCopyAll ──
$script:Window.FindName('BtnOMCopyAll').Add_Click({
$dg = $script:Window.FindName('DgOMResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) { return }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$txtLines = [System.Collections.Generic.List[string]]::new()
$txtLines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
foreach ($row in $allItems) {
$vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
$txtLines.Add($vals -join "`t")
}
[System.Windows.Clipboard]::SetText($txtLines -join "`n")
Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "OM: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})
# ── BtnOMExportXlsx ──
$script:Window.FindName('BtnOMExportXlsx').Add_Click({
$dg = $script:Window.FindName('DgOMResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) {
Show-Notification 'No results to export.' -BgColor '#FFF3CD' -FgColor '#856404'
return
}
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$dlg = [Microsoft.Win32.SaveFileDialog]::new()
$dlg.Title = 'Export Object Membership Results'
$dlg.Filter = 'Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*'
$dlg.FileName = "ObjectMembership_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
$dlg.InitialDirectory = [Environment]::GetFolderPath('Desktop')
if ($dlg.ShowDialog() -ne $true) { return }
$outPath = $dlg.FileName
try {
$folder = Split-Path $outPath -Parent
if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
$exportRows = $allItems | ForEach-Object {
$rowObj = $_; $ht = [ordered]@{}
foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$rowObj.($cd['P']) }
[PSCustomObject]$ht
}
if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
Import-Module ImportExcel -ErrorAction SilentlyContinue
$exportRows | Export-Excel -Path $outPath -WorksheetName 'ObjectMembership' -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium6
Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
Show-Notification "ImportExcel not found - saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
}
Write-VerboseLog "OM: Exported $($allItems.Count) row(s) to: $outPath" -Level Success
} catch {
Write-VerboseLog "OM Export failed: $($_.Exception.Message)" -Level Error
Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
}
})
# ============================================================
# BACKGROUND WORKER HELPER
# Uses a [hashtable]::Synchronized() shared between the UI thread
# and the background runspace - no serialisation, no write-back needed.
# $script:SharedBg is the live object both sides read/write directly.
# ============================================================
function Invoke-OnBackground {
param(
[scriptblock]$Work,
[scriptblock]$Done,
[string]$BusyMessage = "Working...",
[System.Windows.Controls.Button]$DisableButton = $null
)
if ($DisableButton) { $DisableButton.IsEnabled = $false }
$script:BgStopped = $false
$script:Window.FindName('BtnStop').Visibility = 'Visible'
Write-VerboseLog $BusyMessage -Level Action
# Initialise / reset the shared state bag
$script:SharedBg = [hashtable]::Synchronized(@{
# Thread-safe log queue - runspace appends, timer drains to RichTextBox
LogQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
# Inputs - written by UI thread before runspace starts
CreateParams = $script:CreateParams
CreateValidated = $script:CreateValidated
MemberParams = $script:MemberParams
MemberValidated = $script:MemberValidated
ExportOutPath = $script:ExportOutPath
ExportCount = $script:ExportCount
DynRule = $script:DynRule
RenNewName = $script:RenNewName
RenExistingId = $script:RenExistingId
OwnerUpnsTxt = $script:OwnerUpnsTxt
OwnerValidated = $script:OwnerValidated
OwnerExecResult = $script:OwnerExecResult
MemberExecResult = $script:MemberExecResult
CreateNewGroupId = $script:CreateNewGroupId
SelectedGroup = $script:SelectedGroup
ExportSelectedGroup = $script:ExportSelectedGroup
DynSelectedGroup = $script:DynSelectedGroup
RenSelectedGroup = $script:RenSelectedGroup
OwnerGroupsTxt = $script:OwnerGroupsTxt
OwnerGroupsValidated = $script:OwnerGroupsValidated
UDSelectedGroup = $script:UDSelectedGroup
UDParams = $script:UDParams
UDValidated = $script:UDValidated
UDExecResult = $script:UDExecResult
FCParams = $script:FCParams
FCResult = $script:FCResult
FDParams = $script:FDParams
FDResult = $script:FDResult
SensitivityLabels = $script:SensitivityLabels
LogFile = $script:LogFile
SearchKeyword = $script:SearchKeyword
SearchTypes = $script:SearchTypes
LMGroupInput = $script:LMGroupInput
LMAllResults = $script:LMAllResults
LMProgress = $null
CGGroupInput = $script:CGGroupInput
CGAllResults = $script:CGAllResults
CGProgress = $null
GPAParams = $script:GPAParams
GPAResult = $null
RGGroupList = $script:RGGroupList
RGValidated = $script:RGValidated
RGExecResult = $null
# Signalling
Status = 'running' # 'ok' | 'error' | 'stopped'
ErrorMessage = ''
StopRequested = $false
})
# Build function definitions to inject
$fnNames = @(
'Get-Timestamp','Write-VerboseLog','Invoke-GraphGet','Resolve-GroupByNameOrId',
'Search-Groups','Resolve-MemberEntry','Validate-InputList',
'Get-GroupMemberCount','Get-GroupMembers','Add-MembersToGroup',
'Remove-MembersFromGroup','Export-GroupMembersToXlsx',
'Get-GPASubType','Get-GPAPlatform'
)
$fnScript = [System.Text.StringBuilder]::new()
foreach ($name in $fnNames) {
$sb = (Get-Command $name -ErrorAction SilentlyContinue).ScriptBlock
if ($sb) { $null = $fnScript.AppendLine("function $name {`n$sb`n}") }
}
$rs = [runspacefactory]::CreateRunspace()
$rs.ApartmentState = 'STA'
$rs.ThreadOptions = 'ReuseThread'
$rs.Open()
# Pass the SAME synchronized hashtable object - no serialisation occurs
$rs.SessionStateProxy.SetVariable('Shared', $script:SharedBg)
$rs.SessionStateProxy.SetVariable('WorkStr', $Work.ToString())
$rs.SessionStateProxy.SetVariable('DevConfig', $DevConfig)
$runScript = $fnScript.ToString() + @'
Import-Module Microsoft.Graph.Authentication -ErrorAction SilentlyContinue
# Runspace-safe Write-VerboseLog - no WPF/Dispatcher access.
# Logs to file and enqueues for the UI timer to drain into the RichTextBox.
function Write-VerboseLog {
param([string]$Message,
[ValidateSet("Info","Success","Warning","Error","Action")][string]$Level = "Info")
$prefix = switch ($Level) {
"Info" { " " } "Success" { "✓ " } "Warning" { "⚠ " }
"Error" { "✗ " } "Action" { "▶ " }
}
$ts = Get-Timestamp
$line = "[$ts] $prefix$Message"
if ($script:LogFile) {
try { Add-Content -Path $script:LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } catch {}
}
$Shared["LogQueue"].Enqueue(@{ Line = $line; Level = $Level })
}
# Map Shared keys into $script:* so the Work block can use $script:X syntax
$script:CreateParams = $Shared['CreateParams']
$script:CreateValidated = $Shared['CreateValidated']
$script:MemberParams = $Shared['MemberParams']
$script:MemberValidated = $Shared['MemberValidated']
$script:ExportOutPath = $Shared['ExportOutPath']
$script:ExportCount = $Shared['ExportCount']
$script:DynRule = $Shared['DynRule']
$script:RenNewName = $Shared['RenNewName']
$script:RenExistingId = $Shared['RenExistingId']
$script:OwnerUpnsTxt = $Shared['OwnerUpnsTxt']
$script:OwnerValidated = $Shared['OwnerValidated']
$script:OwnerExecResult = $Shared['OwnerExecResult']
$script:MemberExecResult = $Shared['MemberExecResult']
$script:CreateNewGroupId = $Shared['CreateNewGroupId']
$script:SelectedGroup = $Shared['SelectedGroup']
$script:ExportSelectedGroup = $Shared['ExportSelectedGroup']
$script:DynSelectedGroup = $Shared['DynSelectedGroup']
$script:RenSelectedGroup = $Shared['RenSelectedGroup']
$script:OwnerGroupsTxt = $Shared['OwnerGroupsTxt']
$script:OwnerGroupsValidated = $Shared['OwnerGroupsValidated']
$script:UDSelectedGroup = $Shared['UDSelectedGroup']
$script:UDParams = $Shared['UDParams']
$script:UDValidated = $Shared['UDValidated']
$script:UDExecResult = $Shared['UDExecResult']
$script:FCParams = $Shared['FCParams']
$script:FCResult = $Shared['FCResult']
$script:FDParams = $Shared['FDParams']
$script:FDResult = $Shared['FDResult']
$script:SensitivityLabels = $Shared['SensitivityLabels']
$script:LogFile = $Shared['LogFile']
$script:SearchKeyword = $Shared['SearchKeyword']
$script:SearchTypes = $Shared['SearchTypes']
$script:LMGroupInput = $Shared['LMGroupInput']
$script:LMAllResults = $Shared['LMAllResults']
$script:CGGroupInput = $Shared['CGGroupInput']
$script:CGAllResults = $Shared['CGAllResults']
$script:GPAParams = $Shared['GPAParams']
$script:RGGroupList = $Shared['RGGroupList']
$script:RGValidated = $Shared['RGValidated']
$script:RGExecResult = $Shared['RGExecResult']
try {
$workBlock = [scriptblock]::Create($WorkStr)
& $workBlock
# Write results back into the shared bag - UI thread reads these in Done
$Shared['CreateValidated'] = $script:CreateValidated
$Shared['MemberValidated'] = $script:MemberValidated
$Shared['MemberExecResult'] = $script:MemberExecResult
$Shared['ExportCount'] = $script:ExportCount
$Shared['CreateNewGroupId'] = $script:CreateNewGroupId
$Shared['RenExistingId'] = $script:RenExistingId
$Shared['OwnerValidated'] = $script:OwnerValidated
$Shared['OwnerExecResult'] = $script:OwnerExecResult
$Shared['OwnerGroupsValidated'] = $script:OwnerGroupsValidated
$Shared['UDValidated'] = $script:UDValidated
$Shared['UDExecResult'] = $script:UDExecResult
$Shared['FCResult'] = $script:FCResult
$Shared['FDResult'] = $script:FDResult
$Shared['LMAllResults'] = $script:LMAllResults
$Shared['CGAllResults'] = $script:CGAllResults
$Shared['GPAResult'] = $script:GPAResult
$Shared['RGValidated'] = $script:RGValidated
$Shared['RGExecResult'] = $script:RGExecResult
$Shared['SensitivityLabels'] = $script:SensitivityLabels
$Shared['Status'] = 'ok'
} catch {
$Shared['ErrorMessage'] = $_.Exception.Message
$Shared['Status'] = 'error'
}
'@
$ps = [powershell]::Create()
$ps.Runspace = $rs
$null = $ps.AddScript($runScript)
$script:BgPs = $ps
$script:BgRs = $rs
$script:BgHandle = $ps.BeginInvoke()
$script:BgDone = $Done
$script:BgBtn = $DisableButton
$timer = [System.Windows.Threading.DispatcherTimer]::new()
$timer.Interval = [TimeSpan]::FromMilliseconds(300)
$script:BgTimer = $timer
$timer.Add_Tick({
# Drain log queue regardless of completion status
$shared = $script:SharedBg
if ($null -ne $shared -and $null -ne $shared['LogQueue']) {
$entry = $null
while ($shared['LogQueue'].TryDequeue([ref]$entry)) {
$line = $entry['Line']
$level = $entry['Level']
$col = switch ($level) {
'Info' { $DevConfig.LogColorInfo }
'Success' { $DevConfig.LogColorSuccess }
'Warning' { $DevConfig.LogColorWarning }
'Error' { $DevConfig.LogColorError }
'Action' { $DevConfig.LogColorAction }
default { $DevConfig.LogColorInfo }
}
try {
$para = [System.Windows.Documents.Paragraph]::new()
$run = [System.Windows.Documents.Run]::new($line)
$run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString($col))
$para.Margin = [System.Windows.Thickness]::new(0,0,0,0)
$para.Inlines.Add($run)
$script:RtbLog.Document.Blocks.Add($para)
$script:RtbLog.ScrollToEnd()
} catch {}
}
}
if ($null -eq $shared -or $shared['Status'] -eq 'running') { return }
$script:BgTimer.Stop()
$script:Window.FindName('BtnStop').Visibility = 'Collapsed'
try { $script:BgPs.EndInvoke($script:BgHandle) } catch {}
try { $script:BgRs.Close() } catch {}
try { $script:BgPs.Dispose() } catch {}
if ($script:BgBtn) { $script:BgBtn.IsEnabled = $true }
$script:Window.FindName('BtnStop').IsEnabled = $true
$script:Window.FindName('BtnStop').Content = (New-StopBtnContent "Stop")
if ($script:BgStopped) {
Write-VerboseLog "Operation stopped by user." -Level Warning
Show-Notification "Operation stopped by user." -BgColor '#FFF3CD' -FgColor '#7A4800'
return
}
if ($shared['Status'] -eq 'error') {
$errMsg = $shared['ErrorMessage']
Write-VerboseLog "Background error: $errMsg" -Level Error
Show-Notification "Error: $errMsg" -BgColor '#F8D7DA' -FgColor '#721C24'
} else {
# Promote results from shared bag into $script:* for Done block
$script:CreateValidated = $shared['CreateValidated']
$script:MemberValidated = $shared['MemberValidated']
$script:MemberExecResult = $shared['MemberExecResult']
$script:ExportCount = $shared['ExportCount']
$script:CreateNewGroupId = $shared['CreateNewGroupId']
$script:RenExistingId = $shared['RenExistingId']
$script:OwnerValidated = $shared['OwnerValidated']
$script:OwnerExecResult = $shared['OwnerExecResult']
$script:OwnerGroupsValidated = $shared['OwnerGroupsValidated']
$script:UDValidated = $shared['UDValidated']
$script:UDExecResult = $shared['UDExecResult']
$script:FCResult = $shared['FCResult']
$script:FDResult = $shared['FDResult']
$script:SensitivityLabels = $shared['SensitivityLabels']
$script:DynGroupState = $shared['DynGroupState']
$script:SearchResults = $shared['SearchResults']
$script:LMAllResults = $shared['LMAllResults']
$script:CGAllResults = $shared['CGAllResults']
$script:GPAResult = $shared['GPAResult']
$script:RGValidated = $shared['RGValidated']
$script:RGExecResult = $shared['RGExecResult']
if ($script:BgDone) { & $script:BgDone }
}
})
$timer.Start()
}
# ── GROUP SEARCH helper (reused across panels) ──
# All element references are resolved by name INSIDE each handler to avoid
# closure capture failure (local variables are out of scope when events fire).
# OnSelected callbacks are stored in a script-scope hashtable keyed by picker name.
function Wire-GroupPicker {
param(
[string]$SearchBoxName,
[string]$SearchBtnName,
[string]$ByIdBtnName,
[string]$ResultsListName,
[string]$ResultsBorderName,
[string]$BadgeName,
[string]$BadgeNameTxt,
[string]$BadgeIdTxt,
[string]$ScriptVarName,
[scriptblock]$OnSelected
)
$script:PickerCallbacks[$SearchBoxName] = $OnSelected
# ---- Search button & Enter key ----
$searchBtn = $script:Window.FindName($SearchBtnName)
$searchBtn.Tag = "$SearchBoxName|$ResultsListName|$ResultsBorderName"
$searchBtn.Add_Click({
param($sender, $e)
try {
$parts = $sender.Tag -split '\|'
$sBoxName = $parts[0]
$lstName = $parts[1]
$borderName = $parts[2]
$q = $script:Window.FindName($sBoxName).Text.Trim()
if ([string]::IsNullOrWhiteSpace($q)) { return }
Write-VerboseLog "Searching groups: $q" -Level Info
$lst = $script:Window.FindName($lstName)
$border = $script:Window.FindName($borderName)
$lst.Items.Clear()
$groups = Search-Groups -Query $q
if ($groups.Count -eq 0) {
Write-VerboseLog "No groups found matching '$q'" -Level Warning
$border.Visibility = 'Collapsed'
return
}
foreach ($g in $groups) {
$typeLabel = if ($g['groupTypes'] -contains 'Unified') { '[M365]' } else { '[Security]' }
$lst.Items.Add([PSCustomObject]@{
Display = "$($g['displayName']) $typeLabel | $($g['id'])"
Id = $g['id']; Name = $g['displayName']; Raw = $g
})
}
$border.Visibility = 'Visible'
} catch { Write-VerboseLog "Group search error: $($_.Exception.Message)" -Level Error }
})
$searchBox = $script:Window.FindName($SearchBoxName)
$searchBox.Tag = "$SearchBtnName"
$searchBox.Add_KeyDown({
param($sender, $e)
if ($e.Key -eq 'Return') {
$script:Window.FindName($sender.Tag).RaiseEvent(
[System.Windows.RoutedEventArgs]::new([System.Windows.Controls.Button]::ClickEvent)
)
}
})
# ---- Use as ID button ----
$byIdBtn = $script:Window.FindName($ByIdBtnName)
$byIdBtn.Tag = "$SearchBoxName|$BadgeNameTxt|$BadgeIdTxt|$BadgeName|$ResultsBorderName|$ScriptVarName"
$byIdBtn.Add_Click({
param($sender, $e)
$parts = $sender.Tag -split '\|'
$sBoxName = $parts[0]
$badgeNameTxt = $parts[1]
$badgeIdTxt = $parts[2]
$badgeName = $parts[3]
$borderName = $parts[4]
$varName = $parts[5]
$idVal = $script:Window.FindName($sBoxName).Text.Trim()
if ([string]::IsNullOrWhiteSpace($idVal)) {
Show-Notification "Enter a Group Object ID or display name in the text box first."
return
}
Write-VerboseLog "Resolving group: $idVal" -Level Action
$g = Resolve-GroupByNameOrId -GroupEntry $idVal
if ($null -eq $g) {
Write-VerboseLog "Group not found: $idVal" -Level Error
Show-Notification "Group not found: $idVal" -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
Set-Variable -Name $varName -Value $g -Scope Script
$script:Window.FindName($badgeNameTxt).Text = $g.DisplayName
$script:Window.FindName($badgeIdTxt).Text = " ($($g.Id))"
$script:Window.FindName($badgeName).Visibility = 'Visible'
$script:Window.FindName($borderName).Visibility = 'Collapsed'
Write-VerboseLog "Group selected: $($g.DisplayName) ($($g.Id))" -Level Success
$cb = $script:PickerCallbacks[$sBoxName]
if ($cb) { & $cb $g }
})
# ---- Results list selection ----
$lstResults = $script:Window.FindName($ResultsListName)
$lstResults.Tag = "$ResultsListName|$BadgeNameTxt|$BadgeIdTxt|$BadgeName|$ResultsBorderName|$ScriptVarName|$SearchBoxName"
$lstResults.Add_SelectionChanged({
param($sender, $e)
try {
$parts = $sender.Tag -split '\|'
$lstName = $parts[0]
$badgeNameTxt = $parts[1]
$badgeIdTxt = $parts[2]
$badgeName = $parts[3]
$borderName = $parts[4]
$varName = $parts[5]
$sBoxName = $parts[6]
$sel = $script:Window.FindName($lstName).SelectedItem
if ($null -eq $sel) { return }
$g = $sel.Raw
$gObj = [PSCustomObject]@{
Id = $g['id']; DisplayName = $g['displayName']
GroupTypes = $g['groupTypes']; MembershipRule = $g['membershipRule']
}
Set-Variable -Name $varName -Value $gObj -Scope Script
$script:Window.FindName($badgeNameTxt).Text = $gObj.DisplayName
$script:Window.FindName($badgeIdTxt).Text = " ($($gObj.Id))"
$script:Window.FindName($badgeName).Visibility = 'Visible'
$script:Window.FindName($borderName).Visibility = 'Collapsed'
Write-VerboseLog "Group selected: $($gObj.DisplayName) ($($gObj.Id))" -Level Success
$cb = $script:PickerCallbacks[$sBoxName]
if ($cb) { & $cb $gObj }
} catch { Write-VerboseLog "Group selection error: $($_.Exception.Message)" -Level Error }
})
}
# Wire group pickers for each panel
Wire-GroupPicker `
-SearchBoxName 'TxtGroupSearch' -SearchBtnName 'BtnGroupSearch' -ByIdBtnName 'BtnGroupById' `
-ResultsListName 'LstGroupResults' -ResultsBorderName 'GroupSearchResults' `
-BadgeName 'SelectedGroupBadge' -BadgeNameTxt 'TxtSelectedGroupName' -BadgeIdTxt 'TxtSelectedGroupId' `
-ScriptVarName 'SelectedGroup' `
-OnSelected {
param($g)
$cnt = Get-GroupMemberCount -GroupId $g.Id
if ($null -ne $cnt) {
$script:Window.FindName('TxtSelectedGroupMemCount').Text = " Members: $cnt"
}
}
Wire-GroupPicker `
-SearchBoxName 'TxtExportGroupSearch' -SearchBtnName 'BtnExportGroupSearch' -ByIdBtnName 'BtnExportGroupById' `
-ResultsListName 'LstExportGroupResults' -ResultsBorderName 'ExportGroupSearchResults' `
-BadgeName 'ExportSelectedGroupBadge' -BadgeNameTxt 'TxtExportGroupName' -BadgeIdTxt 'TxtExportGroupId' `
-ScriptVarName 'ExportSelectedGroup'
Wire-GroupPicker `
-SearchBoxName 'TxtDynGroupSearch' -SearchBtnName 'BtnDynGroupSearch' -ByIdBtnName 'BtnDynGroupById' `
-ResultsListName 'LstDynGroupResults' -ResultsBorderName 'DynGroupSearchResults' `
-BadgeName 'DynSelectedGroupBadge' -BadgeNameTxt 'TxtDynGroupName' -BadgeIdTxt 'TxtDynGroupId' `
-ScriptVarName 'DynSelectedGroup' `
-OnSelected {
param($g)
if ($g.MembershipRule) {
$script:Window.FindName('TxtDynCurrentRule').Text = " Current rule: $($g.MembershipRule)"
$script:Window.FindName('TxtDynRule').Text = $g.MembershipRule
}
}
Wire-GroupPicker `
-SearchBoxName 'TxtRenGroupSearch' -SearchBtnName 'BtnRenGroupSearch' -ByIdBtnName 'BtnRenGroupById' `
-ResultsListName 'LstRenGroupResults' -ResultsBorderName 'RenGroupSearchResults' `
-BadgeName 'RenSelectedGroupBadge' -BadgeNameTxt 'TxtRenGroupCurrentName' -BadgeIdTxt 'TxtRenGroupId' `
-ScriptVarName 'RenSelectedGroup'
# ── Export browse ──
$script:Window.FindName('BtnExportBrowse').Add_Click({
$dlg = [Microsoft.Win32.SaveFileDialog]::new()
$dlg.Title = "Save Group Members XLSX"
$dlg.Filter = "Excel files (*.xlsx)|*.xlsx"
$dlg.DefaultExt = ".xlsx"
$dlg.FileName = "GroupMembers_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
if ($dlg.ShowDialog()) {
$script:Window.FindName('TxtExportPath').Text = $dlg.FileName
}
})
# ── FIND GROUPS BY OWNERS - Tile Click ──────────────────────────────────────
$script:Window.FindName('BtnOpFindGroupsByOwners').Add_Click({
$script:Window.FindName('PnlFGOResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtFGONoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlFGOProgress').Visibility = 'Collapsed'
$script:Window.FindName('TxtFGOInputList').Text = ''
$dg = $script:Window.FindName('DgFGOResults')
if ($dg) { $dg.ItemsSource = $null }
$script:Window.FindName('BtnFGOCopyValue').IsEnabled = $false
$script:Window.FindName('BtnFGOCopyRow').IsEnabled = $false
$script:Window.FindName('BtnFGOCopyAll').IsEnabled = $false
$script:Window.FindName('TxtFGOFilter').IsEnabled = $false
$script:Window.FindName('BtnFGOFilter').IsEnabled = $false
$script:Window.FindName('BtnFGOFilterClear').IsEnabled = $false
$script:Window.FindName('TxtFGOFilter').Text = ''
$script:FGOAllData = $null
$script:Window.FindName('BtnFGOExportXlsx').IsEnabled = $false
Show-Panel 'PanelFindGroupsByOwners'
Hide-Notification
Write-VerboseLog "Panel: Find Groups by Owners" -Level Info
})
# ── FIND GROUPS BY OWNERS - Run ─────────────────────────────────────────────
$script:Window.FindName('BtnFGORun').Add_Click({
Hide-Notification
$inputTxt = $script:Window.FindName('TxtFGOInputList').Text
$entries = @($inputTxt -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
if ($entries.Count -eq 0) {
Show-Notification 'Enter at least one owner UPN.' -BgColor '#FFF3CD' -FgColor '#856404'
return
}
# Reset UI
$script:Window.FindName('PnlFGOResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtFGONoResults').Visibility = 'Collapsed'
$script:Window.FindName('BtnFGOCopyValue').IsEnabled = $false
$script:Window.FindName('BtnFGOCopyRow').IsEnabled = $false
$script:Window.FindName('BtnFGOCopyAll').IsEnabled = $false
$script:Window.FindName('BtnFGOFilter').IsEnabled = $false
$script:Window.FindName('BtnFGOFilterClear').IsEnabled = $false
$script:Window.FindName('TxtFGOFilter').Text = ''
$script:Window.FindName('TxtFGOFilter').IsEnabled = $false
$script:Window.FindName('BtnFGOExportXlsx').IsEnabled = $false
$script:FGOAllData = $null
$dg = $script:Window.FindName('DgFGOResults')
if ($dg) { $dg.ItemsSource = $null }
$script:Window.FindName('PnlFGOProgress').Visibility = 'Visible'
$script:Window.FindName('TxtFGOProgressMsg').Text = 'Resolving owners...'
$script:Window.FindName('TxtFGOProgressDetail').Text = ''
$script:Window.FindName('BtnFGORun').IsEnabled = $false
$script:FGOQueue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new()
$script:FGOStop = [hashtable]::Synchronized(@{ Stop = $false })
$script:FGORs = [runspacefactory]::CreateRunspace()
$script:FGORs.ApartmentState = 'STA'
$script:FGORs.Open()
$script:FGORs.SessionStateProxy.SetVariable('entries', $entries)
$script:FGORs.SessionStateProxy.SetVariable('queue', $script:FGOQueue)
$script:FGORs.SessionStateProxy.SetVariable('stopFlag', $script:FGOStop)
# VerboseLog not passed to runspace - uses queue pattern instead
$script:FGOPs = [powershell]::Create()
$script:FGOPs.Runspace = $script:FGORs
$script:FGOPs.AddScript({
param($entries, $queue, $stopFlag)
$resultList = [System.Collections.Generic.List[PSObject]]::new()
$resolved = 0
$failed = 0
foreach ($upn in $entries) {
if ($stopFlag['Stop']) {
$queue.Enqueue(@{ type = 'progress'; msg = 'Stopped by user.'; detail = '' })
break
}
$queue.Enqueue(@{ type = 'progress'; msg = "Resolving owner: $upn ..."; detail = "$($resolved + $failed + 1) of $($entries.Count)" })
try {
# Resolve user
$u = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($upn))?`$select=id,userPrincipalName"
$userId = $u['id']
$ownerUpn = $u['userPrincipalName']
# Get owned objects (groups only)
$ownedGroups = [System.Collections.Generic.List[hashtable]]::new()
$uri = "https://graph.microsoft.com/v1.0/users/$userId/ownedObjects/microsoft.graph.group?`$select=id,displayName,groupTypes,mailEnabled,securityEnabled,membershipRule&`$top=999"
while ($uri) {
if ($stopFlag['Stop']) { break }
$resp = Invoke-MgGraphRequest -Method GET -Uri $uri
if ($resp['value']) {
foreach ($g in $resp['value']) { $ownedGroups.Add($g) }
}
$uri = $resp['@odata.nextLink']
}
if ($stopFlag['Stop']) { break }
foreach ($g in $ownedGroups) {
if ($stopFlag['Stop']) { break }
# Determine group type
$gTypes = $g['groupTypes']
$mailEnabled = $g['mailEnabled']
$secEnabled = $g['securityEnabled']
$gType = 'Other'
if ($gTypes -and $gTypes -contains 'Unified') { $gType = 'M365' }
elseif ($secEnabled -and -not $mailEnabled) { $gType = 'Security' }
elseif ($secEnabled -and $mailEnabled) { $gType = 'Mail-Enabled Security' }
elseif (-not $secEnabled -and $mailEnabled) { $gType = 'Distribution' }
# Membership type
$mType = 'Assigned'
if ($gTypes -and $gTypes -contains 'DynamicMembership') { $mType = 'Dynamic' }
# Member count
$memberCount = '...'
try {
$cntResp = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/groups/$($g['id'])/members/`$count" `
-Headers @{ 'ConsistencyLevel' = 'eventual' }
$memberCount = [string]$cntResp
} catch {
$memberCount = 'N/A'
}
$resultList.Add([PSCustomObject]@{
Owner = $ownerUpn
GroupName = [string]$g['displayName']
GroupId = [string]$g['id']
GroupType = $gType
MembershipType = $mType
MemberCount = $memberCount
})
}
$resolved++
$queue.Enqueue(@{ type = 'progress'; msg = "Resolved: $ownerUpn ($($ownedGroups.Count) group(s))"; detail = "$($resolved + $failed) of $($entries.Count)" })
} catch {
$failed++
$queue.Enqueue(@{ type = 'progress'; msg = "Failed: $upn - $($_.Exception.Message)"; detail = "$($resolved + $failed) of $($entries.Count)" })
}
}
$queue.Enqueue(@{ type = 'done'; results = $resultList; resolved = $resolved; failed = $failed })
}).AddArgument($entries).AddArgument($script:FGOQueue).AddArgument($script:FGOStop)
$script:FGOHandle = $script:FGOPs.BeginInvoke()
# Timer to poll queue
$script:FGOTimer = [System.Windows.Threading.DispatcherTimer]::new()
$script:FGOTimer.Interval = [TimeSpan]::FromMilliseconds(200)
$script:FGOTimer.Add_Tick({
try {
$msg = $null
while ($script:FGOQueue.TryDequeue([ref]$msg)) {
switch ($msg.type) {
'progress' {
$script:Window.FindName('TxtFGOProgressMsg').Text = $msg.msg
$script:Window.FindName('TxtFGOProgressDetail').Text = $msg.detail
Write-VerboseLog "FGO: $($msg.msg)" -Level Info
}
'done' {
$script:FGOTimer.Stop()
$script:Window.FindName('PnlFGOProgress').Visibility = 'Collapsed'
$script:Window.FindName('BtnFGORun').IsEnabled = $true
$results = $msg.results
if ($results -and $results.Count -gt 0) {
$dg = $script:Window.FindName('DgFGOResults')
$dg.ItemsSource = @($results)
$script:Window.FindName('TxtFGOCount').Text = "$($results.Count) owned group(s) found | Resolved: $($msg.resolved) Failed: $($msg.failed)"
$script:Window.FindName('PnlFGOResults').Visibility = 'Visible'
$script:Window.FindName('BtnFGOCopyAll').IsEnabled = $true
$script:Window.FindName('BtnFGOExportXlsx').IsEnabled = $true
$script:Window.FindName('BtnFGOFilter').IsEnabled = $true
$script:Window.FindName('BtnFGOFilterClear').IsEnabled = $true
$script:Window.FindName('TxtFGOFilter').IsEnabled = $true
Show-Notification "$($results.Count) owned group(s) found." -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$script:Window.FindName('TxtFGONoResults').Visibility = 'Visible'
Show-Notification "No owned groups found for the provided UPNs." -BgColor '#FFF3CD' -FgColor '#856404'
}
Write-VerboseLog "FGO: Complete. $($results.Count) result(s)." -Level Info
}
}
}
} catch {
Write-VerboseLog "FGO timer error: $($_.Exception.Message)" -Level Warning
}
})
$script:FGOTimer.Start()
})
# ── FIND GROUPS BY OWNERS - DataGrid SelectedCellsChanged ───────────────────
$script:Window.FindName('DgFGOResults').Add_SelectedCellsChanged({
$dg = $script:Window.FindName('DgFGOResults')
$selCells = $dg.SelectedCells.Count
$script:Window.FindName('BtnFGOCopyValue').IsEnabled = ($selCells -ge 1)
$script:Window.FindName('BtnFGOCopyRow').IsEnabled = ($selCells -gt 0)
})
# ── FIND GROUPS BY OWNERS - Filter ──────────────────────────────────────────
$script:Window.FindName('BtnFGOFilter').Add_Click({
$dg = $script:Window.FindName('DgFGOResults')
$keyword = $script:Window.FindName('TxtFGOFilter').Text.Trim()
if ($null -eq $script:FGOAllData) {
$script:FGOAllData = @($dg.ItemsSource)
}
if ($keyword -eq '') {
$dg.ItemsSource = $script:FGOAllData
Show-Notification "Filter cleared - $($script:FGOAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$filtered = @($script:FGOAllData | Where-Object {
$row = $_
$match = $false
foreach ($prop in $row.PSObject.Properties) {
if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
}
$match
})
$dg.ItemsSource = $filtered
Show-Notification "$($filtered.Count) of $($script:FGOAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
}
Write-VerboseLog "FGO: Filter applied - keyword='$keyword'" -Level Info
})
# ── FIND GROUPS BY OWNERS - Filter Clear ────────────────────────────────────
$script:Window.FindName('BtnFGOFilterClear').Add_Click({
$script:Window.FindName('TxtFGOFilter').Text = ''
$dg = $script:Window.FindName('DgFGOResults')
if ($null -ne $script:FGOAllData) {
$dg.ItemsSource = $script:FGOAllData
Show-Notification "Filter cleared - $($script:FGOAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
}
})
# ── FIND GROUPS BY OWNERS - Copy Value ──────────────────────────────────────
$script:Window.FindName('BtnFGOCopyValue').Add_Click({
$dg = $script:Window.FindName('DgFGOResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$vals = [System.Collections.Generic.List[string]]::new()
foreach ($cellInfo in @($dg.SelectedCells)) {
$col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
}
[System.Windows.Clipboard]::SetText($vals -join "`r`n")
Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
})
# ── FIND GROUPS BY OWNERS - Copy Row ────────────────────────────────────────
$script:Window.FindName('BtnFGOCopyRow').Add_Click({
$dg = $script:Window.FindName('DgFGOResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$seen = [System.Collections.Generic.HashSet[object]]::new()
$rows = [System.Collections.Generic.List[object]]::new()
foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rows.Add($cell.Item) } }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$header = ($colDefs | ForEach-Object { $_.H }) -join "`t"
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add($header)
foreach ($r in $rows) {
$vals = [System.Collections.Generic.List[string]]::new()
foreach ($cd in $colDefs) {
$v = ''
if ($cd.P -ne '') { $v = [string]$r.($cd.P) }
$vals.Add($v)
}
$lines.Add($vals -join "`t")
}
[System.Windows.Clipboard]::SetText($lines -join "`r`n")
Show-Notification "$($rows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
$dg.UnselectAllCells()
foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
})
# ── FIND GROUPS BY OWNERS - Copy All ────────────────────────────────────────
$script:Window.FindName('BtnFGOCopyAll').Add_Click({
$dg = $script:Window.FindName('DgFGOResults')
if ($null -eq $dg.ItemsSource) { return }
$allRows = @($dg.ItemsSource)
if ($allRows.Count -eq 0) { return }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$header = ($colDefs | ForEach-Object { $_.H }) -join "`t"
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add($header)
foreach ($r in $allRows) {
$vals = [System.Collections.Generic.List[string]]::new()
foreach ($cd in $colDefs) {
$v = ''
if ($cd.P -ne '') { $v = [string]$r.($cd.P) }
$vals.Add($v)
}
$lines.Add($vals -join "`t")
}
[System.Windows.Clipboard]::SetText($lines -join "`r`n")
Show-Notification "$($allRows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
})
# ── FIND GROUPS BY OWNERS - Export XLSX ─────────────────────────────────────
$script:Window.FindName('BtnFGOExportXlsx').Add_Click({
$dg = $script:Window.FindName('DgFGOResults')
if ($null -eq $dg.ItemsSource) { return }
$allRows = @($dg.ItemsSource)
if ($allRows.Count -eq 0) { return }
$dlg = [Microsoft.Win32.SaveFileDialog]::new()
$dlg.Title = 'Save Groups by Owners XLSX'
$dlg.Filter = 'Excel files (*.xlsx)|*.xlsx'
$dlg.DefaultExt = '.xlsx'
$dlg.FileName = "GroupsByOwners_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
if (-not $dlg.ShowDialog()) { return }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
try {
$exportRows = [System.Collections.Generic.List[PSObject]]::new()
foreach ($r in $allRows) {
$obj = [ordered]@{}
foreach ($cd in $colDefs) {
$v = ''
if ($cd.P -ne '') { $v = [string]$r.($cd.P) }
$obj[$cd.H] = $v
}
$exportRows.Add([PSCustomObject]$obj)
}
$exportRows | Export-Excel -Path $dlg.FileName -AutoSize -FreezeTopRow -BoldTopRow
Show-Notification "Exported $($allRows.Count) row(s) to XLSX." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "FGO: Exported to $($dlg.FileName)" -Level Info
} catch {
try {
$csvPath = [System.IO.Path]::ChangeExtension($dlg.FileName, '.csv')
$exportRows | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8
Show-Notification "ImportExcel not available - exported as CSV." -BgColor '#FFF3CD' -FgColor '#856404'
} catch {
Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
}
}
})
# ── Set domain suffix label from DevConfig (or just "@" if not configured) ──
$domainSuffix = if ($DevConfig.M365MailDomain) { "@$($DevConfig.M365MailDomain)" } else { "@" }
$script:Window.FindName('TxtMailDomainSuffix').Text = $domainSuffix
# ── M365 panel visibility + lazy sensitivity label fetch ──
$script:Window.FindName('CmbCreateType').Add_SelectionChanged({
$sel = $script:Window.FindName('CmbCreateType').SelectedItem.Content
$isM365 = $sel -like '*365*'
$script:Window.FindName('PnlMailNick').Visibility = if ($isM365) { 'Visible' } else { 'Collapsed' }
})
$script:Window.FindName('PnlMailNick').Visibility = 'Collapsed' # default hidden (Security selected)
# ── CREATE GROUP - Members ↔ Dynamic Rule mutual exclusion ──
# If the user populates the Members list, grey out the Dynamic Rule field (contradictory inputs).
# Conversely, if a Dynamic Rule is entered, grey out the Members field.
$script:Window.FindName('TxtCreateMembers').Add_TextChanged({
$hasMembersText = -not [string]::IsNullOrWhiteSpace($script:Window.FindName('TxtCreateMembers').Text)
$dynBox = $script:Window.FindName('TxtCreateDynamic')
$dynBox.IsEnabled = -not $hasMembersText
$dynBox.Opacity = if ($hasMembersText) { 0.4 } else { 1.0 }
$dynBox.ToolTip = if ($hasMembersText) {
"Clear the Members field before entering a Dynamic Membership Rule - these settings are mutually exclusive."
} else { $null }
})
$script:Window.FindName('TxtCreateDynamic').Add_TextChanged({
$hasDynText = -not [string]::IsNullOrWhiteSpace($script:Window.FindName('TxtCreateDynamic').Text)
$membersBox = $script:Window.FindName('TxtCreateMembers')
$membersBox.IsEnabled = -not $hasDynText
$membersBox.Opacity = if ($hasDynText) { 0.4 } else { 1.0 }
$membersBox.ToolTip = if ($hasDynText) {
"Clear the Dynamic Membership Rule field before adding initial members - these settings are mutually exclusive."
} else { $null }
})
# ── CREATE GROUP - Validate & Preview ──
$script:Window.FindName('BtnCreateValidate').Add_Click({
Hide-Notification
$groupType = $script:Window.FindName('CmbCreateType').SelectedItem.Content
$dispName = $script:Window.FindName('TxtCreateName').Text.Trim()
$mailNick = $script:Window.FindName('TxtCreateMailNick').Text.Trim()
$desc = $script:Window.FindName('TxtCreateDesc').Text.Trim()
$ownerUpnsTxt = $script:Window.FindName('TxtCreateOwner').Text
$membersTxt = $script:Window.FindName('TxtCreateMembers').Text
$dynRule = $script:Window.FindName('TxtCreateDynamic').Text.Trim()
$isM365 = $groupType -like '*365*'
# Read sensitivity label selection (M365 only)
$selLabelItem = $script:Window.FindName('CmbSensitivityLabel').SelectedItem
$script:CreateSensLabelId = if ($selLabelItem -and $selLabelItem.Tag) { $selLabelItem.Tag } else { '' }
$script:CreateSensLabelName = if ($selLabelItem -and $selLabelItem.Tag) { $selLabelItem.Content } else { '' }
if ([string]::IsNullOrWhiteSpace($dispName)) {
Show-Notification "Display name is required." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
if ($isM365 -and [string]::IsNullOrWhiteSpace($mailNick)) {
Show-Notification "Group email address (mail nickname) is required for M365 groups." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
# Capture all input into script-scope so the background worker and Done callback can access them
$script:CreateParams = @{
GroupType = $groupType; DispName = $dispName; MailNick = $mailNick
Desc = $desc; OwnerUpnsTxt = $ownerUpnsTxt; MembersTxt = $membersTxt
DynRule = $dynRule; IsM365 = $isM365
SensLabelId = $script:CreateSensLabelId
SensLabelName = $script:CreateSensLabelName
MailDomain = $DevConfig.M365MailDomain
}
$btn = $script:Window.FindName('BtnCreateValidate')
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Create Group: Validating ---" -Work {
$p = $script:CreateParams
$dispName = $p['DispName']; $ownerUpnsTxt = $p['OwnerUpnsTxt']
$membersTxt = $p['MembersTxt']; $dynRule = $p['DynRule']
$groupType = $p['GroupType']; $isM365 = $p['IsM365']
$mailNick = $p['MailNick']; $mailDomain = $p['MailDomain']
Write-VerboseLog "Type: $groupType | Name: $dispName" -Level Info
# Resolve owners (supports multiple UPNs, one per line)
$ownerObjs = [System.Collections.Generic.List[PSCustomObject]]::new()
$invalidOwners = [System.Collections.Generic.List[string]]::new()
$ownerEntries = $ownerUpnsTxt -split "`n" | Where-Object { $_.Trim() -ne '' }
foreach ($upn in $ownerEntries) {
$upn = $upn.Trim()
Write-VerboseLog "Resolving owner: $upn" -Level Info
try {
$u = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($upn))?`$select=id,displayName"
$ownerObjs.Add([PSCustomObject]@{ Id = $u['id']; DisplayName = $u['displayName']; Type = 'User'; Found = $true; Original = $upn })
Write-VerboseLog "Owner resolved: $($u['displayName']) ($($u['id']))" -Level Success
} catch {
$invalidOwners.Add($upn)
Write-VerboseLog "Owner UPN not found: $upn" -Level Warning
}
}
# Resolve members
$validMembers = [System.Collections.Generic.List[PSCustomObject]]::new()
$invalidMembers = [System.Collections.Generic.List[string]]::new()
if (-not [string]::IsNullOrWhiteSpace($membersTxt)) {
$entries = $membersTxt -split "`n" | Where-Object { $_.Trim() -ne '' }
$valResult = Validate-InputList -Entries $entries
foreach ($item in $valResult.Valid) { $validMembers.Add([PSCustomObject]$item) }
foreach ($item in $valResult.Invalid) { $invalidMembers.Add([string]$item) }
}
# M365: remove device members (not supported) and warn
if ($isM365) {
$deviceEntries = @($validMembers | Where-Object { $_.Type -eq 'Device' })
foreach ($dev in $deviceEntries) {
$validMembers.Remove($dev) | Out-Null
$invalidMembers.Add("$($dev.DisplayName) [skipped - devices are not supported as M365 group members]")
Write-VerboseLog "Device skipped (M365): $($dev.DisplayName)" -Level Warning
}
}
# Check display name uniqueness (warn only - duplicates are allowed)
Write-VerboseLog "Checking for duplicate display name..." -Level Info
$existingNameId = $null
try {
$safe_dispName = $dispName -replace "'","''" # PS5-safe: extracted to avoid nested double-quotes in string
$resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe_dispName'`&`$select=id`&`$top=1"
if ($resp['value'] -and $resp['value'].Count -gt 0) { $existingNameId = $resp['value'][0]['id'] }
} catch {}
if ($existingNameId) {
Write-VerboseLog "WARNING: A group named '$dispName' already exists (ID: $existingNameId)" -Level Warning
}
# M365: check mail nickname uniqueness (hard block - email addresses must be unique)
$emailConflictMsg = $null
if ($isM365) {
Write-VerboseLog "Checking mail nickname uniqueness..." -Level Info
try {
$nickResp = Invoke-MgGraphRequest -Method GET `
$safe_mailNick = $mailNick -replace "'","''" # PS5-safe: extracted to avoid nested double-quotes in string
-Uri "https://graph.microsoft.com/v1.0/groups?`$filter=mailNickname eq '$safe_mailNick'`&`$select=id,displayName`&`$top=1"
if ($nickResp['value'] -and $nickResp['value'].Count -gt 0) {
$conflict = $nickResp['value'][0]
$emailAddr = if ($mailDomain) { "$mailNick@$mailDomain" } else { $mailNick }
$emailConflictMsg = "Email address '$emailAddr' is already in use by group '$($conflict['displayName'])' (ID: $($conflict['id'])). Please choose a different mail nickname."
Write-VerboseLog "Email conflict: $emailConflictMsg" -Level Error
} else {
Write-VerboseLog "Mail nickname is available." -Level Success
}
} catch {
Write-VerboseLog "Could not verify mail nickname uniqueness: $($_.Exception.Message)" -Level Warning
}
}
$script:CreateValidated = @{
OwnerObjs = $ownerObjs
InvalidOwners = $invalidOwners
ValidMembers = $validMembers
InvalidMembers = $invalidMembers
ExistingNameId = $existingNameId
EmailConflict = $emailConflictMsg
}
} -Done {
$p = $script:CreateParams
$v = $script:CreateValidated
$ownerObjs = @($v['OwnerObjs'])
$invalidOwners = @($v['InvalidOwners'])
$validMembers = $v['ValidMembers']
$invalidMembers = $v['InvalidMembers']
$existingNameId = $v['ExistingNameId']
$emailConflict = $v['EmailConflict']
$dispName = $p['DispName']; $groupType = $p['GroupType']
$mailNick = $p['MailNick']; $mailDomain = $p['MailDomain']
$desc = $p['Desc']; $dynRule = $p['DynRule']
$isM365 = $p['IsM365']; $sensLabelName = $p['SensLabelName']
# Hard block on email conflict
if ($emailConflict) {
Show-Notification $emailConflict -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
if ($existingNameId) {
Show-Notification "Warning: A group named '$dispName' already exists (ID: $existingNameId). Proceeding will create a duplicate display name." -BgColor '#FFF3CD' -FgColor '#7A4800'
}
$emailDisplay = if ($isM365 -and $mailDomain) { "$mailNick@$mailDomain" } elseif ($isM365) { $mailNick } else { '' }
$extra = "Group type : $groupType"
if ($existingNameId) { $extra = "$([char]0x26A0) Duplicate name: A group named '$dispName' already exists (ID: $existingNameId).`n" + $extra }
if ($emailDisplay) { $extra += "`nEmail : $emailDisplay" }
if ($sensLabelName) { $extra += "`nSens. label : $sensLabelName" }
if ($dynRule) { $extra += "`nDynamic rule: $dynRule" }
if ($ownerObjs.Count -gt 0) {
$ownerLines = ($ownerObjs | ForEach-Object { $_.DisplayName + ' (' + $_.Original + ')' }) -join ', '
$extra += "`nOwners : $ownerLines"
}
if ($invalidOwners.Count -gt 0) {
$joined_invalidOwners = $invalidOwners -join ', ' # PS5-safe: extracted to avoid nested double-quotes in string
$extra += "`nInvalid owner UPNs (will be skipped): $joined_invalidOwners"
}
$confirmed = Show-ConfirmationDialog `
-Title "Create Group" `
-OperationLabel "Create Group ($groupType)" `
-TargetGroup $dispName -TargetGroupId "(new)" `
-ValidObjects $validMembers -InvalidEntries $invalidMembers `
-ExtraInfo $extra
if (-not $confirmed) { Write-VerboseLog "Action cancelled by user." -Level Warning; return }
$btn = $script:Window.FindName('BtnCreateValidate')
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Creating group: $dispName ---" -Work {
$p = $script:CreateParams
$v = $script:CreateValidated
$dispName = $p['DispName']; $mailNick = $p['MailNick']
$desc = $p['Desc']; $dynRule = $p['DynRule']
$isM365 = $p['IsM365']; $sensLabelId = $p['SensLabelId']
$ownerObjs = @($v['OwnerObjs']); $validMembers = $v['ValidMembers']
$body = @{ displayName = $dispName; securityEnabled = $true; mailEnabled = $isM365 }
if ($isM365) {
$body.groupTypes = @('Unified')
$body.mailNickname = $mailNick
} else {
$body.groupTypes = @()
$body.mailNickname = ($dispName -replace '[^a-zA-Z0-9]','') + (Get-Random -Minimum 100 -Maximum 999)
}
if ($desc) { $body.description = $desc }
if ($dynRule) {
$body.membershipRule = $dynRule
$body.membershipRuleProcessingState = 'On'
# M365 dynamic requires both Unified AND DynamicMembership in groupTypes
$body.groupTypes = if ($isM365) { @('Unified','DynamicMembership') } else { @('DynamicMembership') }
}
if ($isM365 -and -not [string]::IsNullOrWhiteSpace($sensLabelId)) {
$body.assignedLabels = @(@{ labelId = $sensLabelId })
Write-VerboseLog "Sensitivity label will be applied: $($p['SensLabelName']) ($sensLabelId)" -Level Info
}
$newGroup = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/groups" `
-Body ($body | ConvertTo-Json -Depth 5) -ContentType "application/json"
Write-VerboseLog "Group created: $($newGroup['displayName']) ($($newGroup['id']))" -Level Success
if ($ownerObjs.Count -gt 0) {
Start-Sleep -Seconds 2
foreach ($ownerObj in $ownerObjs) {
try {
$ob = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/users/$($ownerObj.Id)" }
Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/groups/$($newGroup['id'])/owners/`$ref" `
-Body ($ob | ConvertTo-Json) -ContentType "application/json"
Write-VerboseLog "Owner set: $($ownerObj.DisplayName)" -Level Success
} catch {
$errMsg = $_.Exception.Message
if ($errMsg -match '400' -or $errMsg -match 'BadRequest' -or $errMsg -match 'already') {
Write-VerboseLog "Skipped owner '$($ownerObj.DisplayName)' - already an owner (auto-added as creator)" -Level Warning
} else {
Write-VerboseLog "Failed to set owner '$($ownerObj.DisplayName)': $errMsg" -Level Error
}
}
}
}
if ($validMembers.Count -gt 0) {
Start-Sleep -Seconds 2
$memResult = Add-MembersToGroup -GroupId $newGroup['id'] -Members $validMembers
Write-VerboseLog "Members added: $($memResult['Ok']) succeeded, $($memResult['Fail']) failed, $($memResult['Skipped']) skipped" -Level Info
}
$script:CreateNewGroupId = $newGroup['id']
} -Done {
$dispName = $script:CreateParams['DispName']
Show-Notification "Group '$dispName' created successfully (ID: $($script:CreateNewGroupId))" -BgColor '#D4EDDA' -FgColor '#155724'
}
}
})
# ── ADD / REMOVE MEMBERS - Validate & Preview ──
$script:Window.FindName('BtnMemberValidate').Add_Click({
Hide-Notification
if ($null -eq $script:SelectedGroup) {
Show-Notification "Please select a target group first." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$membersTxt = $script:Window.FindName('TxtMemberList').Text
if ([string]::IsNullOrWhiteSpace($membersTxt)) {
Show-Notification "Please enter at least one member entry." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$isAdd = ($script:CurrentMemberOp -eq 'Add')
$opLabel = if ($isAdd) { "Add Members" } else { "Remove Members" }
$script:MemberParams = @{ MembersTxt = $membersTxt; OpLabel = $opLabel; IsAdd = $isAdd }
$btn = $script:Window.FindName('BtnMemberValidate')
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- ${opLabel}: Validating ---" -Work {
$entries = $script:MemberParams['MembersTxt'] -split "`n" | Where-Object { $_.Trim() -ne '' }
Write-VerboseLog "Target group: $($script:SelectedGroup.DisplayName) ($($script:SelectedGroup.Id))" -Level Info
$valResult = Validate-InputList -Entries $entries
$script:MemberValidated = @{ Valid = $valResult.Valid; Invalid = $valResult.Invalid }
} -Done {
$valid = $script:MemberValidated['Valid']
$invalid = $script:MemberValidated['Invalid']
$opLabel = $script:MemberParams['OpLabel']
if ($valid.Count -eq 0) {
Write-VerboseLog "No valid entries found. Cannot proceed." -Level Error
Show-Notification "No valid Entra objects found. Correct entries and try again." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
if ($invalid.Count -gt 0) { Write-VerboseLog "$($invalid.Count) invalid entries will be skipped." -Level Warning }
$confirmed = Show-ConfirmationDialog `
-Title $opLabel -OperationLabel $opLabel `
-TargetGroup $script:SelectedGroup.DisplayName `
-TargetGroupId $script:SelectedGroup.Id `
-ValidObjects $valid -InvalidEntries $invalid
if (-not $confirmed) { Write-VerboseLog "Action cancelled by user." -Level Warning; return }
$btn = $script:Window.FindName('BtnMemberValidate')
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Executing: $opLabel ---" -Work {
$valid = $script:MemberValidated['Valid']
$opLabel = $script:MemberParams['OpLabel']
$isAdd = $script:MemberParams['IsAdd']
Write-VerboseLog "Operation: $(if ($isAdd) { 'ADD' } else { 'REMOVE' }) - Group: $($script:SelectedGroup.DisplayName)" -Level Action
if ($isAdd) {
$memResult = Add-MembersToGroup -GroupId $script:SelectedGroup.Id -Members $valid
$ok = $memResult['Ok']; $fail = $memResult['Fail']; $skipped = $memResult['Skipped']
} else {
$memResult = Remove-MembersFromGroup -GroupId $script:SelectedGroup.Id -Members $valid
$ok = $memResult['Ok']; $fail = $memResult['Fail']; $skipped = 0
}
$script:MemberExecResult = @{ Ok = $ok; Fail = $fail; Skipped = $skipped }
} -Done {
$ok = [int]$script:MemberExecResult['Ok']
$fail = [int]$script:MemberExecResult['Fail']
$skipped = [int]$script:MemberExecResult['Skipped']
$opLabel = $script:MemberParams['OpLabel']
$msg = "$opLabel complete. Succeeded: $ok | Skipped (already member): $skipped | Failed: $fail"
$vLevel = if ($fail -gt 0) { 'Warning' } else { 'Success' }
$bgColor = if ($fail -gt 0) { '#FFF3CD' } else { '#D4EDDA' }
$fgColor = if ($fail -gt 0) { '#7A4800' } else { '#155724' }
Write-VerboseLog $msg -Level $vLevel
Show-Notification $msg -BgColor $bgColor -FgColor $fgColor
}
}
})
# ── EXPORT MEMBERS ──
$script:Window.FindName('BtnRunExport').Add_Click({
Hide-Notification
if ($null -eq $script:ExportSelectedGroup) {
Show-Notification "Please select a target group first." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$outPath = $script:Window.FindName('TxtExportPath').Text.Trim()
if ([string]::IsNullOrWhiteSpace($outPath)) {
Show-Notification "Please specify an output XLSX path." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$script:ExportOutPath = $outPath
$btn = $script:Window.FindName('BtnRunExport')
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Export: $($script:ExportSelectedGroup.DisplayName) ---" -Work {
$count = Export-GroupMembersToXlsx -GroupId $script:ExportSelectedGroup.Id `
-GroupName $script:ExportSelectedGroup.DisplayName -OutputPath $script:ExportOutPath
$script:ExportCount = $count
} -Done {
$msg = "Exported $($script:ExportCount) members to: $($script:ExportOutPath)"
Write-VerboseLog $msg -Level Success
Show-Notification $msg -BgColor '#D4EDDA' -FgColor '#155724'
}
})
# ── SET DYNAMIC QUERY ──
$script:Window.FindName('BtnDynValidate').Add_Click({
Hide-Notification
if ($null -eq $script:DynSelectedGroup) {
Show-Notification "Please select a target group first." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$rule = $script:Window.FindName('TxtDynRule').Text.Trim()
if ([string]::IsNullOrWhiteSpace($rule)) {
Show-Notification "Please enter a membership rule." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$script:DynRule = $rule
$btn = $script:Window.FindName('BtnDynValidate')
Invoke-OnBackground -DisableButton $btn -BusyMessage "Validating group state and rule syntax..." -Work {
$rule = $script:DynRule
$groupId = $script:DynSelectedGroup.Id
Write-VerboseLog "Group: $($script:DynSelectedGroup.DisplayName) ($groupId)" -Level Info
Write-VerboseLog "Rule : $rule" -Level Info
# ── Re-fetch group to get current groupTypes (avoids stale cached data) ──
$freshGroupTypes = $null
try {
$fg = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/groups/$groupId`?`$select=id,groupTypes"
$freshGroupTypes = @($fg['groupTypes'])
} catch {
Write-VerboseLog "Could not re-fetch group type: $($_.Exception.Message) - using cached value." -Level Warning
$freshGroupTypes = @($script:DynSelectedGroup.GroupTypes)
}
$isDynamic = $freshGroupTypes -contains 'DynamicMembership'
$memberCount = -1
if ($isDynamic) {
# Hard block - already dynamic; no further checks needed
Write-VerboseLog "BLOCKED: Group is already a dynamic group (groupTypes: $($freshGroupTypes -join ', '))." -Level Warning
} else {
# Static group - check member count before allowing conversion
try {
$cntResp = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/groups/$groupId/members/`$count" `
-Headers @{ ConsistencyLevel = 'eventual' }
$memberCount = [int]$cntResp
Write-VerboseLog "Member count: $memberCount" -Level Info
} catch {
Write-VerboseLog "Could not get count via `$count endpoint: $($_.Exception.Message)" -Level Warning
}
# Fallback: probe for at least 1 member if primary count endpoint failed
if ($memberCount -eq -1) {
try {
$mResp = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/groups/$groupId/members?`$select=id`&`$top=1"
if ($mResp['value'] -and $mResp['value'].Count -gt 0) {
$memberCount = 1
} else {
$memberCount = 0
}
Write-VerboseLog "Member count (fallback probe): $memberCount" -Level Info
} catch {
Write-VerboseLog "Could not verify member count - will proceed with warning." -Level Warning
}
}
if ($memberCount -gt 0) {
Write-VerboseLog "BLOCKED: Static group has $memberCount active member(s) - conversion not allowed." -Level Warning
} else {
# Group is eligible - validate rule syntax
try {
$valBody = @{ ruleExpression = $rule }
Invoke-MgGraphRequest -Method POST `
-Uri "https://graph.microsoft.com/v1.0/groups/validateProperties" `
-Body ($valBody | ConvertTo-Json) -ContentType "application/json"
Write-VerboseLog "Rule syntax: valid" -Level Success
} catch {
Write-VerboseLog "Rule syntax warning: $($_.Exception.Message) (proceeding)" -Level Warning
}
}
}
$shared['DynGroupState'] = @{ IsDynamic = $isDynamic; MemberCount = $memberCount }
} -Done {
$state = $script:DynGroupState
if ($null -eq $state) {
Show-Notification "Validation did not complete - please try again." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
# ── Hard block: group is already dynamic ──
if ($state.IsDynamic) {
Show-Notification "Cannot set dynamic rule: '$($script:DynSelectedGroup.DisplayName)' is already a dynamic group. To modify its existing membership rule, edit the group directly in the Entra admin portal." `
-BgColor '#F8D7DA' -FgColor '#721C24'
return
}
# ── Hard block: static group has active members ──
if ($state.MemberCount -gt 0) {
if ($state.MemberCount -eq 1) {
$countLabel = "1 active member"
} else {
$countLabel = "$($state.MemberCount) active members"
}
Show-Notification "Cannot convert '$($script:DynSelectedGroup.DisplayName)' to dynamic membership: the group currently has $countLabel. Converting to dynamic would remove all existing members. This operation is only permitted on static groups with no members." `
-BgColor '#F8D7DA' -FgColor '#721C24'
return
}
# ── Soft warning: member count could not be verified ──
if ($state.MemberCount -eq -1) {
Show-Notification "Warning: could not verify the member count for '$($script:DynSelectedGroup.DisplayName)'. Ensure the group has no members before applying the rule." `
-BgColor '#FFF3CD' -FgColor '#7A4800'
}
# ── Group passes all checks (static, 0 members) - show confirmation ──
$rule = $script:DynRule
$emptyValid = [System.Collections.Generic.List[PSCustomObject]]::new()
$emptyInvalid = [System.Collections.Generic.List[string]]::new()
$confirmed = Show-ConfirmationDialog `
-Title "Set Dynamic Membership Rule" -OperationLabel "Set Dynamic Membership Rule" `
-TargetGroup $script:DynSelectedGroup.DisplayName `
-TargetGroupId $script:DynSelectedGroup.Id `
-ValidObjects $emptyValid -InvalidEntries $emptyInvalid `
-ExtraInfo "New rule: $rule`n`nWARNING: This will convert the group to dynamic membership.`nAll static members will be removed."
if (-not $confirmed) { Write-VerboseLog "Action cancelled by user." -Level Warning; return }
$btn = $script:Window.FindName('BtnDynValidate')
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Applying dynamic rule ---" -Work {
$body = @{
membershipRule = $script:DynRule
membershipRuleProcessingState = "On"
groupTypes = @("DynamicMembership")
}
Invoke-MgGraphRequest -Method PATCH `
-Uri "https://graph.microsoft.com/v1.0/groups/$($script:DynSelectedGroup.Id)" `
-Body ($body | ConvertTo-Json -Depth 3) -ContentType "application/json"
Write-VerboseLog "Dynamic rule applied successfully." -Level Success
} -Done {
Show-Notification "Dynamic membership rule applied to '$($script:DynSelectedGroup.DisplayName)'" -BgColor '#D4EDDA' -FgColor '#155724'
}
}
})
# ── SEARCH ENTRA OBJECTS ──
$script:Window.FindName('BtnEntraSearch').Add_Click({
Hide-Notification
$kw = $script:Window.FindName('TxtSearchKeyword').Text.Trim()
if ([string]::IsNullOrWhiteSpace($kw)) {
Show-Notification "Please enter a search keyword." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$inclUsers = $script:Window.FindName('ChkSrchUsers').IsChecked
$inclDevices = $script:Window.FindName('ChkSrchDevices').IsChecked
$inclSG = $script:Window.FindName('ChkSrchSG').IsChecked
$inclM365 = $script:Window.FindName('ChkSrchM365').IsChecked
$script:SearchExactMatch = [bool]$script:Window.FindName('ChkSrchExactMatch').IsChecked
$anyChecked = $inclUsers -or $inclDevices -or $inclSG -or $inclM365
$script:SearchKeyword = $kw
$script:SearchTypes = @{
Users = if ($anyChecked) { [bool]$inclUsers } else { $true }
Devices = if ($anyChecked) { [bool]$inclDevices } else { $true }
SG = if ($anyChecked) { [bool]$inclSG } else { $true }
M365 = if ($anyChecked) { [bool]$inclM365 } else { $true }
}
$script:Window.FindName('PnlSearchResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtSearchNoResults').Visibility = 'Collapsed'
$btn = $script:Window.FindName('BtnEntraSearch')
Invoke-OnBackground -DisableButton $btn -BusyMessage "Searching Entra objects..." -Work {
$kw = $script:SearchKeyword
$types = $script:SearchTypes
$hdrs = @{ ConsistencyLevel = 'eventual' }
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
Write-VerboseLog "Search keyword: '$kw'" -Level Info
# Users - $search contains match, full pagination via @odata.nextLink
if ($types['Users']) {
try {
$uri = "https://graph.microsoft.com/v1.0/users?`$search=`"displayName:$kw`" OR `"userPrincipalName:$kw`"`&`$select=id,displayName,userPrincipalName,mail`&`$count=true`&`$top=999"
$userCount = 0
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $uri -Headers $hdrs
foreach ($u in @($resp['value'])) {
$det = if ($u['userPrincipalName']) { [string]$u['userPrincipalName'] } elseif ($u['mail']) { [string]$u['mail'] } else { '' }
$results.Add([PSCustomObject]@{ DisplayName = [string]$u['displayName']; Detail = $det; ObjectType = 'User'; ObjectId = [string]$u['id'] })
}
$userCount += @($resp['value']).Count
$uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
} while ($uri)
Write-VerboseLog "Users found: $userCount" -Level Info
} catch { Write-VerboseLog "User search error: $($_.Exception.Message)" -Level Warning }
}
# Devices - $search contains match, full pagination
if ($types['Devices']) {
try {
$uri = "https://graph.microsoft.com/v1.0/devices?`$search=`"displayName:$kw`"`&`$select=id,displayName,operatingSystem,operatingSystemVersion,deviceId`&`$count=true`&`$top=999"
$devCount = 0
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $uri -Headers $hdrs
foreach ($d in @($resp['value'])) {
$det = [string]$d['deviceId']
$results.Add([PSCustomObject]@{ DisplayName = [string]$d['displayName']; Detail = $det; ObjectType = 'Device'; ObjectId = [string]$d['id'] })
}
$devCount += @($resp['value']).Count
$uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
} while ($uri)
Write-VerboseLog "Devices found: $devCount" -Level Info
} catch { Write-VerboseLog "Device search error: $($_.Exception.Message)" -Level Warning }
}
# Groups - $search contains match, full pagination, split client-side into SG / M365
if ($types['SG'] -or $types['M365']) {
try {
$uri = "https://graph.microsoft.com/v1.0/groups?`$search=`"displayName:$kw`"`&`$select=id,displayName,groupTypes,mail`&`$count=true`&`$top=999"
$grpCount = 0
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $uri -Headers $hdrs
foreach ($g in @($resp['value'])) {
$gTypes = @($g['groupTypes'])
$isM365 = $gTypes -contains 'Unified'
$isDyn = $gTypes -contains 'DynamicMembership'
if ($isM365 -and $types['M365']) {
$results.Add([PSCustomObject]@{ DisplayName = [string]$g['displayName']; Detail = [string]$g['id']; ObjectType = 'M365 Group'; ObjectId = [string]$g['id'] })
} elseif (-not $isM365 -and $types['SG']) {
$tl = if ($isDyn) { 'Security Group (Dynamic)' } else { 'Security Group' }
$results.Add([PSCustomObject]@{ DisplayName = [string]$g['displayName']; Detail = [string]$g['id']; ObjectType = $tl; ObjectId = [string]$g['id'] })
}
}
$grpCount += @($resp['value']).Count
$uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
} while ($uri)
Write-VerboseLog "Groups found: $grpCount" -Level Info
} catch { Write-VerboseLog "Group search error: $($_.Exception.Message)" -Level Warning }
}
# Client-side exact-match filter when checkbox is checked
if ($script:SearchExactMatch) {
$kw = $script:SearchKeyword
$results = [System.Collections.Generic.List[PSCustomObject]]($results | Where-Object { $_.DisplayName -ieq $kw })
}
$shared['SearchResults'] = $results
} -Done {
$results = $script:SearchResults
if ($null -eq $results) {
Show-Notification "Search did not complete - please try again." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
if ($results.Count -eq 0) {
$script:Window.FindName('TxtSearchNoResults').Visibility = 'Visible'
$script:Window.FindName('PnlSearchResults').Visibility = 'Collapsed'
return
}
$dg = $script:Window.FindName('DgSearchResults')
$dg.ItemsSource = $results
$dg.UnselectAll()
$n = $results.Count
$script:Window.FindName('TxtSearchCount').Text = "$n result$(if ($n -ne 1) { 's' } else { '' })"
$script:Window.FindName('BtnSearchCopyValue').IsEnabled = $false
$script:Window.FindName('BtnSearchCopyRow').IsEnabled = $false
$script:Window.FindName('BtnSearchCopyAll').IsEnabled = $true
$script:Window.FindName('TxtSearchFilter').IsEnabled = $true
$script:Window.FindName('BtnSearchFilter').IsEnabled = $true
$script:Window.FindName('BtnSearchFilterClear').IsEnabled = $true
$script:Window.FindName('BtnSearchExportXlsx').IsEnabled = $true
$script:Window.FindName('PnlSearchResults').Visibility = 'Visible'
$script:Window.FindName('TxtSearchNoResults').Visibility = 'Collapsed'
Write-VerboseLog "Search returned $n result(s)." -Level Success
}
})
# DgSearchResults: update Copy Value / Copy Row buttons on CELL selection change
# Note: SelectionUnit="CellOrRowHeader" allows cell selection by default; Copy Row switches to full-row selection
$script:Window.FindName('DgSearchResults').Add_SelectedCellsChanged({
$dg = $script:Window.FindName('DgSearchResults')
$selCells = $dg.SelectedCells.Count
$script:Window.FindName('BtnSearchCopyValue').IsEnabled = ($selCells -ge 1)
$script:Window.FindName('BtnSearchCopyRow').IsEnabled = ($selCells -gt 0)
})
# BtnSearchCopyValue: copy exact value of the single selected cell
# ── BtnSearchFilter ──
$script:Window.FindName('BtnSearchFilter').Add_Click({
$dg = $script:Window.FindName('DgSearchResults')
$keyword = $script:Window.FindName('TxtSearchFilter').Text.Trim()
if ($null -eq $script:SearchAllData) {
$script:SearchAllData = @($dg.ItemsSource)
}
if ($keyword -eq '') {
$dg.ItemsSource = $script:SearchAllData
Show-Notification "Filter cleared - $($script:SearchAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$filtered = @($script:SearchAllData | Where-Object {
$row = $_
$match = $false
foreach ($prop in $row.PSObject.Properties) {
if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
}
$match
})
# ── BtnSearchFilterClear ──
$script:Window.FindName('BtnSearchFilterClear').Add_Click({
$script:Window.FindName('TxtSearchFilter').Text = ''
$dg = $script:Window.FindName('DgSearchResults')
if ($null -ne $script:SearchAllData) {
$dg.ItemsSource = $script:SearchAllData
Show-Notification "Filter cleared - $($script:SearchAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
}
})
$dg.ItemsSource = $filtered
Show-Notification "$($filtered.Count) of $($script:SearchAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
}
Write-VerboseLog "Search: Filter applied - keyword='$keyword'" -Level Info
})
$script:Window.FindName('BtnSearchCopyValue').Add_Click({
$dg = $script:Window.FindName('DgSearchResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$vals = [System.Collections.Generic.List[string]]::new()
foreach ($cellInfo in @($dg.SelectedCells)) {
$col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
}
[System.Windows.Clipboard]::SetText($vals -join "`r`n")
Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "Copied cell value to clipboard." -Level Success
})
# BtnSearchCopyRow: copy full row(s) with headers for every row containing a selected cell
$script:Window.FindName('BtnSearchCopyRow').Add_Click({
$dg = $script:Window.FindName('DgSearchResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$seen = [System.Collections.Generic.HashSet[object]]::new()
$rows = [System.Collections.Generic.List[object]]::new()
foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rows.Add($cell.Item) } }
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add("Object Name`tDetails`tObject Type")
foreach ($row in $rows) { $lines.Add("$($row.DisplayName)`t$($row.Detail)`t$($row.ObjectType)") }
[System.Windows.Clipboard]::SetText($lines -join "`r`n")
Show-Notification "$($rows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "Copied $($rows.Count) row(s) to clipboard." -Level Success
# Highlight full rows
$dg.UnselectAllCells()
foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
})
# BtnSearchCopyAll: copy all result rows with headers
$script:Window.FindName('BtnSearchCopyAll').Add_Click({
$dg = $script:Window.FindName('DgSearchResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) { return }
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add("Object Name`tDetails`tObject Type")
foreach ($row in $allItems) { $lines.Add("$($row.DisplayName)`t$($row.Detail)`t$($row.ObjectType)") }
[System.Windows.Clipboard]::SetText($lines -join "`r`n")
Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "Copied all $($allItems.Count) result row(s) to clipboard." -Level Success
})
# BtnSearchExportXlsx: export all search results via SaveFileDialog
$script:Window.FindName('BtnSearchExportXlsx').Add_Click({
$dg = $script:Window.FindName('DgSearchResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) {
Show-Notification "No results to export." -BgColor '#FFF3CD' -FgColor '#856404'
return
}
$dlg = [Microsoft.Win32.SaveFileDialog]::new()
$dlg.Title = "Export Search Results"
$dlg.Filter = "Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*"
$dlg.FileName = "EntraSearch_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
$initDir = try { if ($script:ExportOutPath) { Split-Path $script:ExportOutPath -Parent } else { $null } } catch { $null }
$dlg.InitialDirectory = if ($initDir -and (Test-Path $initDir)) { $initDir } else { [Environment]::GetFolderPath('Desktop') }
if ($dlg.ShowDialog() -ne $true) { return }
$outPath = $dlg.FileName
try {
$folder = Split-Path $outPath -Parent
if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
$exportRows = $allItems | Select-Object DisplayName, Detail, ObjectType
if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
Import-Module ImportExcel -ErrorAction SilentlyContinue
$exportRows | Export-Excel -Path $outPath -WorksheetName "SearchResults" -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium2
Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
Show-Notification "ImportExcel not found - saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
}
Write-VerboseLog "Exported $($allItems.Count) search results to: $outPath" -Level Success
} catch {
Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
Write-VerboseLog "Search export error: $($_.Exception.Message)" -Level Error
}
})
# ════════════════════════════════════════════════════════════════════════════════
# LIST GROUP MEMBERS — helper functions and handlers
# ════════════════════════════════════════════════════════════════════════════════
function Set-LMColumns {
param([string]$Schema, [System.Windows.Controls.DataGrid]$DataGrid)
$DataGrid.Columns.Clear()
$colDefs = switch ($Schema) {
'Users' { @(
@{H='Object Name'; B='ObjectName'},
@{H='Object ID'; B='ObjectID'},
@{H='Object Type'; B='ObjectType'},
@{H='UPN'; B='UPN'},
@{H='Member'; B='MemberOf'}
)}
'Devices' { @(
@{H='Device Name'; B='ObjectName'},
@{H='Device ObjectID'; B='DeviceObjectID'},
@{H='Entra Device ID'; B='EntraDeviceID'},
@{H='OS Platform'; B='OSPlatform'},
@{H='OS Version'; B='OSVersion'},
@{H='Ownership Type'; B='OwnershipType'},
@{H='Registered User UPN'; B='RegisteredUserUPN'},
@{H='Member'; B='MemberOf'}
)}
'Groups' { @(
@{H='Group Name'; B='ObjectName'},
@{H='Group Type'; B='GroupType'},
@{H='Group ID'; B='GroupID'},
@{H='Membership Type'; B='MembershipType'},
@{H='Member'; B='MemberOf'}
)}
default { @(
@{H='Object Name'; B='ObjectName'},
@{H='Object Type'; B='ObjectType'},
@{H='Object ID'; B='ObjectID'},
@{H='Member'; B='MemberOf'}
)}
}
foreach ($def in $colDefs) {
$col = [System.Windows.Controls.DataGridTextColumn]::new()
$col.Header = $def['H']
$col.Binding = [System.Windows.Data.Binding]::new($def['B'])
$col.IsReadOnly = $true
$col.Width = [System.Windows.Controls.DataGridLength]::new(
1, [System.Windows.Controls.DataGridLengthUnitType]::Star)
$DataGrid.Columns.Add($col)
}
}
function Apply-LMFilter {
if (-not $script:LMAllResults -or $script:LMAllResults.Count -eq 0) { return }
$showUsers = $script:Window.FindName('ChkLMUsers').IsChecked
$showDevices = $script:Window.FindName('ChkLMDevices').IsChecked
$showGroups = $script:Window.FindName('ChkLMGroups').IsChecked
$allUnchecked = (-not $showUsers) -and (-not $showDevices) -and (-not $showGroups)
$filtered = @($script:LMAllResults | Where-Object {
$allUnchecked -or
($showUsers -and $_.ObjectType -eq 'User') -or
($showDevices -and $_.ObjectType -eq 'Device') -or
($showGroups -and $_.ObjectType -eq 'Group')
})
$dg = $script:Window.FindName('DgLMResults')
if ($filtered.Count -eq 0) {
$dg.ItemsSource = $null
$script:Window.FindName('PnlLMResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtLMNoResults').Visibility = 'Visible'
return
}
$types = @($filtered | Select-Object -ExpandProperty ObjectType -Unique)
$schema = if ($types.Count -eq 1) {
switch ($types[0]) { 'User'{'Users'} 'Device'{'Devices'} 'Group'{'Groups'} default{'Mixed'} }
} else { 'Mixed' }
Set-LMColumns -Schema $schema -DataGrid $dg
$dg.ItemsSource = $filtered
$script:Window.FindName('TxtLMCount').Text = "$($filtered.Count) member(s) $([char]0x2014) schema: $($schema)"
$script:Window.FindName('TxtLMNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlLMResults').Visibility = 'Visible'
$script:Window.FindName('BtnLMCopyAll').IsEnabled = $true
$script:Window.FindName('TxtLMFilter').IsEnabled = $true
$script:Window.FindName('BtnLMFilter').IsEnabled = $true
$script:Window.FindName('BtnLMFilterClear').IsEnabled = $true
$script:Window.FindName('BtnLMExportXlsx').IsEnabled = $true
$script:Window.FindName('BtnLMCopyValue').IsEnabled = $false
$script:Window.FindName('BtnLMCopyRow').IsEnabled = $false
}
# ── BtnLMQuery: List Group Members ──────────────────────────────────────────
$script:Window.FindName('BtnLMQuery').Add_Click({
Hide-Notification
$groupTxt = $script:Window.FindName('TxtLMGroupInput').Text
if ([string]::IsNullOrWhiteSpace($groupTxt)) {
Show-Notification "Please enter at least one group name or Object ID." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$script:LMGroupInput = $groupTxt
$script:LMAllResults = $null
$script:Window.FindName('PnlLMResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtLMNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlLMProgress').Visibility = 'Visible'
$script:Window.FindName('PbLMProgress').IsIndeterminate = $true
$script:Window.FindName('TxtLMProgressMsg').Text = 'Querying groups...'
$script:Window.FindName('TxtLMProgressDetail').Text = ''
# Start progress update timer (reads SharedBg written by background runspace)
if ($script:LMProgressTimer) { $script:LMProgressTimer.Stop() }
$script:LMProgressTimer = [System.Windows.Threading.DispatcherTimer]::new()
$script:LMProgressTimer.Interval = [TimeSpan]::FromMilliseconds(400)
$script:LMProgressTimer.Add_Tick({
$prog = if ($script:SharedBg) { $script:SharedBg['LMProgress'] } else { $null }
if ($prog) {
$script:Window.FindName('TxtLMProgressMsg').Text = [string]$prog['Message']
$script:Window.FindName('TxtLMProgressDetail').Text = [string]$prog['Detail']
}
})
$script:LMProgressTimer.Start()
$btn = $script:Window.FindName('BtnLMQuery')
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- List Group Members: Querying ---" -Work {
$script:LMGroupInput = $Shared['LMGroupInput']
$groupEntries = $script:LMGroupInput -split "`n" | Where-Object { $_.Trim() -ne '' } | ForEach-Object { $_.Trim() }
$totalGroups = $groupEntries.Count
$allResults = [System.Collections.Generic.List[PSCustomObject]]::new()
$Shared['LMProgress'] = @{ Message = "Starting..."; Detail = "0 groups queued" }
$gi = 0
foreach ($entry in $groupEntries) {
$gi++
$Shared['LMProgress'] = @{ Message = "Resolving group $gi of $($totalGroups): $entry"; Detail = "$($allResults.Count) members so far" }
# Resolve: GUID → direct GET, otherwise search by displayName
$groupId = $null
$groupName = $entry
if ($entry -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
$groupId = $entry
try {
$gInfo = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$groupId?`$select=displayName"
$groupName = [string]$gInfo['displayName']
} catch { Write-VerboseLog "Could not fetch display name for ID $entry" -Level Warning }
} else {
try {
$enc = [Uri]::EscapeDataString($entry)
$safe_entry = $entry -replace "'","''" # PS5-safe: extracted to avoid nested double-quotes in string
$gResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe_entry'`&`$select=id,displayName`&`$top=1"
if ($gResp['value'] -and $gResp['value'].Count -gt 0) {
$groupId = [string]$gResp['value'][0]['id']
$groupName = [string]$gResp['value'][0]['displayName']
} else {
Write-VerboseLog "Group not found: $entry" -Level Warning
continue
}
} catch { Write-VerboseLog "Error resolving group '$entry': $($_.Exception.Message)" -Level Error; continue }
}
Write-VerboseLog ('Querying members of: ' + $groupName + ' (' + $groupId + ')') -Level Info
$Shared['LMProgress'] = @{ Message = "Querying group $gi of $($totalGroups): $groupName"; Detail = "$($allResults.Count) members so far" }
# ── Users ──────────────────────────────────────────────────────────
try {
$uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.user?`$select=id,displayName,userPrincipalName`&`$top=999"
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $uri
foreach ($u in @($resp['value'])) {
$allResults.Add([PSCustomObject]@{
ObjectName = [string]$u['userPrincipalName']
ObjectType = 'User'
ObjectID = [string]$u['id']
MemberOf = $groupName
UPN = [string]$u['userPrincipalName']
DeviceObjectID = ''
EntraDeviceID = ''
OSPlatform = ''
OSVersion = ''
OwnershipType = ''
RegisteredUserUPN = ''
GroupType = ''
GroupID = ''
MembershipType = ''
})
}
$Shared['LMProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName (users)"; Detail = "$($allResults.Count) members so far" }
$uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
} while ($uri)
} catch { Write-VerboseLog "Error querying users from '$groupName': $($_.Exception.Message)" -Level Warning }
# ── Devices ────────────────────────────────────────────────────────
try {
$uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.device?`$select=id,displayName,deviceId,operatingSystem,operatingSystemVersion,deviceOwnership`&`$top=999"
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $uri
foreach ($d in @($resp['value'])) {
$allResults.Add([PSCustomObject]@{
ObjectName = [string]$d['displayName']
ObjectType = 'Device'
ObjectID = [string]$d['id']
MemberOf = $groupName
UPN = ''
DeviceObjectID = [string]$d['id']
EntraDeviceID = [string]$d['deviceId']
OSPlatform = [string]$d['operatingSystem']
OSVersion = [string]$d['operatingSystemVersion']
OwnershipType = [string]$d['deviceOwnership']
RegisteredUserUPN = ''
GroupType = ''
GroupID = ''
MembershipType = ''
})
}
$Shared['LMProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName (devices)"; Detail = "$($allResults.Count) members so far" }
$uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
} while ($uri)
} catch { Write-VerboseLog "Error querying devices from '$groupName': $($_.Exception.Message)" -Level Warning }
# ── Nested groups ──────────────────────────────────────────────────
try {
$uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.group?`$select=id,displayName,groupTypes,membershipRuleProcessingState`&`$top=999"
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $uri
foreach ($g in @($resp['value'])) {
$gt = if ($g['groupTypes'] -and ($g['groupTypes'] -contains 'Unified')) { 'Microsoft 365' } else { 'Security' }
$mt = if ($g['membershipRuleProcessingState'] -eq 'On') { 'Dynamic' } else { 'Static' }
$allResults.Add([PSCustomObject]@{
ObjectName = [string]$g['displayName']
ObjectType = 'Group'
ObjectID = [string]$g['id']
MemberOf = $groupName
UPN = ''
DeviceObjectID = ''
EntraDeviceID = ''
OSPlatform = ''
OSVersion = ''
OwnershipType = ''
RegisteredUserUPN = ''
GroupType = $gt
GroupID = [string]$g['id']
MembershipType = $mt
})
}
$Shared['LMProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName (nested groups)"; Detail = "$($allResults.Count) members so far" }
$uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
} while ($uri)
} catch { Write-VerboseLog "Error querying nested groups from '$groupName': $($_.Exception.Message)" -Level Warning }
}
# If result set is devices-only → fetch RegisteredUser UPN per device
$deviceRows = @($allResults | Where-Object { $_.ObjectType -eq 'Device' })
$nonDeviceRows = @($allResults | Where-Object { $_.ObjectType -ne 'Device' })
if ($nonDeviceRows.Count -eq 0 -and $deviceRows.Count -gt 0) {
Write-VerboseLog "Devices-only result set — fetching registered user UPNs..." -Level Info
$di = 0
foreach ($dev in $deviceRows) {
$di++
$Shared['LMProgress'] = @{ Message = ('Fetching registered users (' + $di + ' / ' + $deviceRows.Count + ')...'); Detail = ([string]$deviceRows.Count + ' devices total') }
try {
$rResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices/$($dev.DeviceObjectID)/registeredUsers?`$select=userPrincipalName`&`$top=1"
if ($rResp['value'] -and $rResp['value'].Count -gt 0) {
$dev.RegisteredUserUPN = [string]$rResp['value'][0]['userPrincipalName']
}
} catch { Write-VerboseLog "Could not get registered user for $($dev.ObjectName): $($_.Exception.Message)" -Level Warning }
}
}
$Shared['LMProgress'] = @{ Message = "Processing complete."; Detail = "$($allResults.Count) member(s) collected" }
Write-VerboseLog "LM query complete: $($allResults.Count) member(s) from $gi group(s)" -Level Success
$script:LMAllResults = $allResults
} -Done {
if ($script:LMProgressTimer) { $script:LMProgressTimer.Stop() }
$script:Window.FindName('PnlLMProgress').Visibility = 'Collapsed'
if (-not $script:LMAllResults -or $script:LMAllResults.Count -eq 0) {
$script:Window.FindName('PnlLMResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtLMNoResults').Visibility = 'Visible'
Show-Notification "No members found for the specified group(s)." -BgColor '#FFF3CD' -FgColor '#856404'
return
}
Apply-LMFilter
}
})
# ── DgLMResults: cell selection enables Copy Value / Copy Row ────────────────
$script:Window.FindName('DgLMResults').Add_SelectedCellsChanged({
$dg = $script:Window.FindName('DgLMResults')
$selCells = $dg.SelectedCells.Count
$script:Window.FindName('BtnLMCopyValue').IsEnabled = ($selCells -ge 1)
$script:Window.FindName('BtnLMCopyRow').IsEnabled = ($selCells -gt 0)
})
# ── BtnLMCopyValue ───────────────────────────────────────────────────────────
# ── BtnLMFilter ──
$script:Window.FindName('BtnLMFilter').Add_Click({
$dg = $script:Window.FindName('DgLMResults')
$keyword = $script:Window.FindName('TxtLMFilter').Text.Trim()
if ($null -eq $script:LMAllData) {
$script:LMAllData = @($dg.ItemsSource)
}
if ($keyword -eq '') {
$dg.ItemsSource = $script:LMAllData
Show-Notification "Filter cleared - $($script:LMAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$filtered = @($script:LMAllData | Where-Object {
$row = $_
$match = $false
foreach ($prop in $row.PSObject.Properties) {
if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
}
$match
})
# ── BtnLMFilterClear ──
$script:Window.FindName('BtnLMFilterClear').Add_Click({
$script:Window.FindName('TxtLMFilter').Text = ''
$dg = $script:Window.FindName('DgLMResults')
if ($null -ne $script:LMAllData) {
$dg.ItemsSource = $script:LMAllData
Show-Notification "Filter cleared - $($script:LMAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
}
})
$dg.ItemsSource = $filtered
Show-Notification "$($filtered.Count) of $($script:LMAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
}
Write-VerboseLog "LM: Filter applied - keyword='$keyword'" -Level Info
})
$script:Window.FindName('BtnLMCopyValue').Add_Click({
$dg = $script:Window.FindName('DgLMResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$vals = [System.Collections.Generic.List[string]]::new()
foreach ($cellInfo in @($dg.SelectedCells)) {
$col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
}
[System.Windows.Clipboard]::SetText($vals -join "`r`n")
Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "LM: Copied cell value to clipboard." -Level Success
})
# ── BtnLMCopyRow ─────────────────────────────────────────────────────────────
$script:Window.FindName('BtnLMCopyRow').Add_Click({
$dg = $script:Window.FindName('DgLMResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$seen = [System.Collections.Generic.HashSet[object]]::new()
$rows = [System.Collections.Generic.List[object]]::new()
foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rows.Add($cell.Item) } }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
foreach ($row in $rows) {
$vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
$lines.Add($vals -join "`t")
}
[System.Windows.Clipboard]::SetText($lines -join "`r`n")
Show-Notification "$($rows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "LM: Copied $($rows.Count) row(s) to clipboard." -Level Success
# Highlight full rows
$dg.UnselectAllCells()
foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
})
# ── BtnLMCopyAll ─────────────────────────────────────────────────────────────
$script:Window.FindName('BtnLMCopyAll').Add_Click({
$dg = $script:Window.FindName('DgLMResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) { return }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
foreach ($row in $allItems) {
$vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
$lines.Add($vals -join "`t")
}
[System.Windows.Clipboard]::SetText($lines -join "`r`n")
Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "LM: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})
# ── BtnLMExportXlsx ──────────────────────────────────────────────────────────
$script:Window.FindName('BtnLMExportXlsx').Add_Click({
$dg = $script:Window.FindName('DgLMResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) {
Show-Notification "No results to export." -BgColor '#FFF3CD' -FgColor '#856404'
return
}
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$dlg = [Microsoft.Win32.SaveFileDialog]::new()
$dlg.Title = "Export Group Members"
$dlg.Filter = "Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*"
$dlg.FileName = "GroupMembers_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
$initDir = try { if ($script:ExportOutPath) { Split-Path $script:ExportOutPath -Parent } else { $null } } catch { $null }
$dlg.InitialDirectory = if ($initDir -and (Test-Path $initDir)) { $initDir } else { [Environment]::GetFolderPath('Desktop') }
if ($dlg.ShowDialog() -ne $true) { return }
$outPath = $dlg.FileName
try {
$folder = Split-Path $outPath -Parent
if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
$exportRows = $allItems | ForEach-Object {
$row = $_; $ht = [ordered]@{}
foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$row.($cd['P']) }
[PSCustomObject]$ht
}
if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
Import-Module ImportExcel -ErrorAction SilentlyContinue
$exportRows | Export-Excel -Path $outPath -WorksheetName "GroupMembers" -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium2
Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
Show-Notification "ImportExcel not found — saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
}
Write-VerboseLog "LM: Exported $($allItems.Count) members to: $outPath" -Level Success
} catch {
Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
Write-VerboseLog "LM: Export failed: $($_.Exception.Message)" -Level Error
}
})
# ── Filter checkboxes re-apply filter without re-querying ────────────────────
foreach ($cbName in @('ChkLMUsers','ChkLMDevices','ChkLMGroups')) {
$script:Window.FindName($cbName).Add_Click({
if ($script:LMAllResults -and $script:LMAllResults.Count -gt 0) { Apply-LMFilter }
})
}
# ════════════════════════════════════════════════════════════════════════════════
# COMPARE GROUPS — handlers
# ════════════════════════════════════════════════════════════════════════════════
# ── BtnCGCommon ── (Common Members) ─────────────────────────────────────────
$script:Window.FindName('BtnCGCommon').Add_Click({
Hide-Notification
$groupTxt = $script:Window.FindName('TxtCGGroupInput').Text
if ([string]::IsNullOrWhiteSpace($groupTxt)) {
Show-Notification "Please enter at least one group name or Object ID." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$script:CGGroupInput = $groupTxt
$script:CGAllResults = $null
$script:Window.FindName('PnlCGResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtCGNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlCGProgress').Visibility = 'Visible'
$script:Window.FindName('PbCGProgress').IsIndeterminate = $true
$script:Window.FindName('TxtCGProgressMsg').Text = 'Finding common members...'
$script:Window.FindName('TxtCGProgressDetail').Text = ''
if ($script:CGProgressTimer) { $script:CGProgressTimer.Stop() }
$script:CGProgressTimer = [System.Windows.Threading.DispatcherTimer]::new()
$script:CGProgressTimer.Interval = [TimeSpan]::FromMilliseconds(400)
$script:CGProgressTimer.Add_Tick({
$prog = if ($script:SharedBg) { $script:SharedBg['CGProgress'] } else { $null }
if ($prog) {
$script:Window.FindName('TxtCGProgressMsg').Text = [string]$prog['Message']
$script:Window.FindName('TxtCGProgressDetail').Text = [string]$prog['Detail']
}
})
$script:CGProgressTimer.Start()
$script:Window.FindName('BtnCGDistinct').IsEnabled = $false
$btn = $script:Window.FindName('BtnCGCommon')
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Compare Groups: Common Members ---" -Work {
$script:CGGroupInput = $Shared['CGGroupInput']
$groupEntries = $script:CGGroupInput -split "`n" | Where-Object { $_.Trim() -ne '' } | ForEach-Object { $_.Trim() }
$totalGroups = $groupEntries.Count
$allResults = [System.Collections.Generic.List[PSCustomObject]]::new()
$Shared['CGProgress'] = @{ Message = "Starting..."; Detail = "0 groups queued" }
$gi = 0
foreach ($entry in $groupEntries) {
$gi++
$Shared['CGProgress'] = @{ Message = "Resolving group $gi of $($totalGroups): $entry"; Detail = "$($allResults.Count) objects so far" }
$groupId = $null
$groupName = $entry
if ($entry -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
$groupId = $entry
try {
$gInfo = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$groupId?`$select=displayName"
$groupName = [string]$gInfo['displayName']
} catch { Write-VerboseLog "CG: Could not fetch display name for ID $entry" -Level Warning }
} else {
try {
$safe_entry = $entry -replace "'","''" # PS5-safe: extracted to avoid nested double-quotes in string
$gResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe_entry'`&`$select=id,displayName`&`$top=1"
if ($gResp['value'] -and $gResp['value'].Count -gt 0) {
$groupId = [string]$gResp['value'][0]['id']
$groupName = [string]$gResp['value'][0]['displayName']
} else {
Write-VerboseLog "CG: Group not found: $entry" -Level Warning
continue
}
} catch { Write-VerboseLog "CG: Error resolving '$entry': $($_.Exception.Message)" -Level Error; continue }
}
$Shared['CGProgress'] = @{ Message = "Querying group $gi of $($totalGroups): $groupName"; Detail = "$($allResults.Count) objects so far" }
Write-VerboseLog ('CG: Querying members of ' + $groupName + ' (' + $groupId + ')') -Level Info
# ── Users ──────────────────────────────────────────────────────────
try {
$uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.user?`$select=id,userPrincipalName`&`$top=999"
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $uri
foreach ($u in @($resp['value'])) {
$allResults.Add([PSCustomObject]@{
DisplayName = [string]$u['userPrincipalName']
ObjectID = [string]$u['id']
ObjectType = 'User'
SourceGroup = $groupName
})
}
$Shared['CGProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName (users)"; Detail = "$($allResults.Count) objects so far" }
$uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
} while ($uri)
} catch { Write-VerboseLog "CG: Error querying users from '$groupName': $($_.Exception.Message)" -Level Warning }
# ── Devices ────────────────────────────────────────────────────────
try {
$uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.device?`$select=id,displayName`&`$top=999"
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $uri
foreach ($d in @($resp['value'])) {
$allResults.Add([PSCustomObject]@{
DisplayName = [string]$d['displayName']
ObjectID = [string]$d['id']
ObjectType = 'Device'
SourceGroup = $groupName
})
}
$Shared['CGProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName (devices)"; Detail = "$($allResults.Count) objects so far" }
$uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
} while ($uri)
} catch { Write-VerboseLog "CG: Error querying devices from '$groupName': $($_.Exception.Message)" -Level Warning }
# ── Nested Groups ──────────────────────────────────────────────────
try {
$uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.group?`$select=id,displayName,groupTypes,membershipRuleProcessingState`&`$top=999"
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $uri
foreach ($g in @($resp['value'])) {
$isM365 = $g['groupTypes'] -and ($g['groupTypes'] -contains 'Unified')
$isDynamic = $g['membershipRuleProcessingState'] -eq 'On'
$gt = if ($isM365) { 'Microsoft 365' } else { 'Security' }
$mt = if ($isDynamic) { 'Dynamic' } else { 'Assigned' }
$allResults.Add([PSCustomObject]@{
DisplayName = [string]$g['displayName']
ObjectID = [string]$g['id']
ObjectType = ('Group (' + $gt + ', ' + $mt + ')')
SourceGroup = $groupName
})
}
$Shared['CGProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName (nested groups)"; Detail = "$($allResults.Count) objects so far" }
$uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
} while ($uri)
} catch { Write-VerboseLog "CG: Error querying nested groups from '$groupName': $($_.Exception.Message)" -Level Warning }
}
$Shared['CGProgress'] = @{ Message = "Complete."; Detail = "$($allResults.Count) object(s) from $($totalGroups) group(s)" }
Write-VerboseLog "CG: Complete — $($allResults.Count) object(s) from $gi group(s)" -Level Success
$script:CGAllResults = $allResults
} -Done {
if ($script:CGProgressTimer) { $script:CGProgressTimer.Stop() }
$script:Window.FindName('BtnCGDistinct').IsEnabled = $true
$script:Window.FindName('PnlCGProgress').Visibility = 'Collapsed'
if (-not $script:CGAllResults -or $script:CGAllResults.Count -eq 0) {
$script:Window.FindName('TxtCGNoResults').Visibility = 'Visible'
Show-Notification "No members found for the specified group(s)." -BgColor '#FFF3CD' -FgColor '#856404'
return
}
# Intersect: ObjectIDs that appear in every successfully resolved source group
$resolvedGroups = @($script:CGAllResults | Select-Object -ExpandProperty SourceGroup | Sort-Object -Unique)
$resolvedCount = $resolvedGroups.Count
$grouped = $script:CGAllResults | Group-Object -Property ObjectID
$commonItems = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($grp in $grouped) {
$distinctSrc = @($grp.Group | Select-Object -ExpandProperty SourceGroup | Sort-Object -Unique)
if ($distinctSrc.Count -ge $resolvedCount) {
$first = $grp.Group[0]
$commonItems.Add([PSCustomObject]@{
DisplayName = $first.DisplayName
ObjectID = $first.ObjectID
ObjectType = $first.ObjectType
SourceGroup = 'Common'
})
}
}
$dg = $script:Window.FindName('DgCGResults')
if ($commonItems.Count -eq 0) {
$dg.ItemsSource = $null
$script:Window.FindName('TxtCGNoResults').Visibility = 'Visible'
Show-Notification "No members are common across all $resolvedCount queried group(s)." -BgColor '#FFF3CD' -FgColor '#856404'
return
}
$dg.ItemsSource = $commonItems
$script:Window.FindName('TxtCGCount').Text = "$($commonItems.Count) common member(s) across $resolvedCount group(s)"
$script:Window.FindName('TxtCGNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlCGResults').Visibility = 'Visible'
$script:Window.FindName('BtnCGCopyAll').IsEnabled = $true
$script:Window.FindName('TxtCGFilter').IsEnabled = $true
$script:Window.FindName('BtnCGFilter').IsEnabled = $true
$script:Window.FindName('BtnCGFilterClear').IsEnabled = $true
$script:Window.FindName('BtnCGExportXlsx').IsEnabled = $true
$script:Window.FindName('BtnCGCopyValue').IsEnabled = $false
$script:Window.FindName('BtnCGCopyRow').IsEnabled = $false
}
})
# ── BtnCGDistinct ── (Distinct Members) ──────────────────────────────────────
$script:Window.FindName('BtnCGDistinct').Add_Click({
Hide-Notification
$groupTxt = $script:Window.FindName('TxtCGGroupInput').Text
if ([string]::IsNullOrWhiteSpace($groupTxt)) {
Show-Notification "Please enter at least one group name or Object ID." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$script:CGGroupInput = $groupTxt
$script:CGAllResults = $null
$script:Window.FindName('PnlCGResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtCGNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlCGProgress').Visibility = 'Visible'
$script:Window.FindName('PbCGProgress').IsIndeterminate = $true
$script:Window.FindName('TxtCGProgressMsg').Text = 'Querying distinct members...'
$script:Window.FindName('TxtCGProgressDetail').Text = ''
if ($script:CGProgressTimer) { $script:CGProgressTimer.Stop() }
$script:CGProgressTimer = [System.Windows.Threading.DispatcherTimer]::new()
$script:CGProgressTimer.Interval = [TimeSpan]::FromMilliseconds(400)
$script:CGProgressTimer.Add_Tick({
$prog = if ($script:SharedBg) { $script:SharedBg['CGProgress'] } else { $null }
if ($prog) {
$script:Window.FindName('TxtCGProgressMsg').Text = [string]$prog['Message']
$script:Window.FindName('TxtCGProgressDetail').Text = [string]$prog['Detail']
}
})
$script:CGProgressTimer.Start()
$script:Window.FindName('BtnCGCommon').IsEnabled = $false
$btn = $script:Window.FindName('BtnCGDistinct')
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Compare Groups: Distinct Members ---" -Work {
$script:CGGroupInput = $Shared['CGGroupInput']
$groupEntries = $script:CGGroupInput -split "`n" | Where-Object { $_.Trim() -ne '' } | ForEach-Object { $_.Trim() }
$totalGroups = $groupEntries.Count
$allResults = [System.Collections.Generic.List[PSCustomObject]]::new()
$Shared['CGProgress'] = @{ Message = "Starting..."; Detail = "0 groups queued" }
$gi = 0
foreach ($entry in $groupEntries) {
$gi++
$Shared['CGProgress'] = @{ Message = "Resolving group $gi of $($totalGroups): $entry"; Detail = "$($allResults.Count) objects so far" }
$groupId = $null
$groupName = $entry
if ($entry -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
$groupId = $entry
try {
$gInfo = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$groupId?`$select=displayName"
$groupName = [string]$gInfo['displayName']
} catch { Write-VerboseLog "CG: Could not fetch display name for ID $entry" -Level Warning }
} else {
try {
$safe_entry = $entry -replace "'","''" # PS5-safe: extracted to avoid nested double-quotes in string
$gResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe_entry'`&`$select=id,displayName`&`$top=1"
if ($gResp['value'] -and $gResp['value'].Count -gt 0) {
$groupId = [string]$gResp['value'][0]['id']
$groupName = [string]$gResp['value'][0]['displayName']
} else {
Write-VerboseLog "CG: Group not found: $entry" -Level Warning
continue
}
} catch { Write-VerboseLog "CG: Error resolving '$entry': $($_.Exception.Message)" -Level Error; continue }
}
$Shared['CGProgress'] = @{ Message = "Querying group $gi of $($totalGroups): $groupName"; Detail = "$($allResults.Count) objects so far" }
Write-VerboseLog ('CG: Querying members of ' + $groupName + ' (' + $groupId + ')') -Level Info
# ── Users ──────────────────────────────────────────────────────────
try {
$uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.user?`$select=id,userPrincipalName`&`$top=999"
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $uri
foreach ($u in @($resp['value'])) {
$allResults.Add([PSCustomObject]@{
DisplayName = [string]$u['userPrincipalName']
ObjectID = [string]$u['id']
ObjectType = 'User'
SourceGroup = $groupName
})
}
$Shared['CGProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName (users)"; Detail = "$($allResults.Count) objects so far" }
$uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
} while ($uri)
} catch { Write-VerboseLog "CG: Error querying users from '$groupName': $($_.Exception.Message)" -Level Warning }
# ── Devices ────────────────────────────────────────────────────────
try {
$uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.device?`$select=id,displayName`&`$top=999"
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $uri
foreach ($d in @($resp['value'])) {
$allResults.Add([PSCustomObject]@{
DisplayName = [string]$d['displayName']
ObjectID = [string]$d['id']
ObjectType = 'Device'
SourceGroup = $groupName
})
}
$Shared['CGProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName (devices)"; Detail = "$($allResults.Count) objects so far" }
$uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
} while ($uri)
} catch { Write-VerboseLog "CG: Error querying devices from '$groupName': $($_.Exception.Message)" -Level Warning }
# ── Nested Groups ──────────────────────────────────────────────────
try {
$uri = "https://graph.microsoft.com/v1.0/groups/$groupId/members/microsoft.graph.group?`$select=id,displayName,groupTypes,membershipRuleProcessingState`&`$top=999"
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $uri
foreach ($g in @($resp['value'])) {
$isM365 = $g['groupTypes'] -and ($g['groupTypes'] -contains 'Unified')
$isDynamic = $g['membershipRuleProcessingState'] -eq 'On'
$gt = if ($isM365) { 'Microsoft 365' } else { 'Security' }
$mt = if ($isDynamic) { 'Dynamic' } else { 'Assigned' }
$allResults.Add([PSCustomObject]@{
DisplayName = [string]$g['displayName']
ObjectID = [string]$g['id']
ObjectType = ('Group (' + $gt + ', ' + $mt + ')')
SourceGroup = $groupName
})
}
$Shared['CGProgress'] = @{ Message = "Group $gi/$($totalGroups): $groupName (nested groups)"; Detail = "$($allResults.Count) objects so far" }
$uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
} while ($uri)
} catch { Write-VerboseLog "CG: Error querying nested groups from '$groupName': $($_.Exception.Message)" -Level Warning }
}
$Shared['CGProgress'] = @{ Message = "Complete."; Detail = "$($allResults.Count) object(s) from $($totalGroups) group(s)" }
Write-VerboseLog "CG: Complete — $($allResults.Count) object(s) from $gi group(s)" -Level Success
$script:CGAllResults = $allResults
} -Done {
if ($script:CGProgressTimer) { $script:CGProgressTimer.Stop() }
$script:Window.FindName('BtnCGCommon').IsEnabled = $true
$script:Window.FindName('PnlCGProgress').Visibility = 'Collapsed'
if (-not $script:CGAllResults -or $script:CGAllResults.Count -eq 0) {
$script:Window.FindName('TxtCGNoResults').Visibility = 'Visible'
Show-Notification "No members found for the specified group(s)." -BgColor '#FFF3CD' -FgColor '#856404'
return
}
$dg = $script:Window.FindName('DgCGResults')
$dg.ItemsSource = $script:CGAllResults
$groupCount = ($script:CGGroupInput -split "`n" | Where-Object { $_.Trim() -ne '' }).Count
$script:Window.FindName('TxtCGCount').Text = "$($script:CGAllResults.Count) object(s) across $groupCount group(s)"
$script:Window.FindName('TxtCGNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlCGResults').Visibility = 'Visible'
$script:Window.FindName('BtnCGCopyAll').IsEnabled = $true
$script:Window.FindName('TxtCGFilter').IsEnabled = $true
$script:Window.FindName('BtnCGFilter').IsEnabled = $true
$script:Window.FindName('BtnCGFilterClear').IsEnabled = $true
$script:Window.FindName('BtnCGExportXlsx').IsEnabled = $true
$script:Window.FindName('BtnCGCopyValue').IsEnabled = $false
$script:Window.FindName('BtnCGCopyRow').IsEnabled = $false
}
})
# ── DgCGResults: cell selection ───────────────────────────────────────────────
$script:Window.FindName('DgCGResults').Add_SelectedCellsChanged({
$dg = $script:Window.FindName('DgCGResults')
$selCells = $dg.SelectedCells.Count
$script:Window.FindName('BtnCGCopyValue').IsEnabled = ($selCells -ge 1)
$script:Window.FindName('BtnCGCopyRow').IsEnabled = ($selCells -gt 0)
})
# ── BtnCGCopyValue ────────────────────────────────────────────────────────────
# ── BtnCGFilter ──
$script:Window.FindName('BtnCGFilter').Add_Click({
$dg = $script:Window.FindName('DgCGResults')
$keyword = $script:Window.FindName('TxtCGFilter').Text.Trim()
if ($null -eq $script:CGAllData) {
$script:CGAllData = @($dg.ItemsSource)
}
if ($keyword -eq '') {
$dg.ItemsSource = $script:CGAllData
Show-Notification "Filter cleared - $($script:CGAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$filtered = @($script:CGAllData | Where-Object {
$row = $_
$match = $false
foreach ($prop in $row.PSObject.Properties) {
if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
}
$match
})
# ── BtnCGFilterClear ──
$script:Window.FindName('BtnCGFilterClear').Add_Click({
$script:Window.FindName('TxtCGFilter').Text = ''
$dg = $script:Window.FindName('DgCGResults')
if ($null -ne $script:CGAllData) {
$dg.ItemsSource = $script:CGAllData
Show-Notification "Filter cleared - $($script:CGAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
}
})
$dg.ItemsSource = $filtered
Show-Notification "$($filtered.Count) of $($script:CGAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
}
Write-VerboseLog "CG: Filter applied - keyword='$keyword'" -Level Info
})
$script:Window.FindName('BtnCGCopyValue').Add_Click({
$dg = $script:Window.FindName('DgCGResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$vals = [System.Collections.Generic.List[string]]::new()
foreach ($cellInfo in @($dg.SelectedCells)) {
$col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
}
[System.Windows.Clipboard]::SetText($vals -join "`r`n")
Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "CG: Copied cell value to clipboard." -Level Success
})
# ── BtnCGCopyRow ──────────────────────────────────────────────────────────────
$script:Window.FindName('BtnCGCopyRow').Add_Click({
$dg = $script:Window.FindName('DgCGResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$seen = [System.Collections.Generic.HashSet[object]]::new()
$rows = [System.Collections.Generic.List[object]]::new()
foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rows.Add($cell.Item) } }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
foreach ($row in $rows) {
$vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
$lines.Add($vals -join "`t")
}
[System.Windows.Clipboard]::SetText($lines -join "`r`n")
Show-Notification "$($rows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "CG: Copied $($rows.Count) row(s) to clipboard." -Level Success
# Highlight full rows
$dg.UnselectAllCells()
foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
})
# ── BtnCGCopyAll ──────────────────────────────────────────────────────────────
$script:Window.FindName('BtnCGCopyAll').Add_Click({
$dg = $script:Window.FindName('DgCGResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) { return }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
foreach ($row in $allItems) {
$vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
$lines.Add($vals -join "`t")
}
[System.Windows.Clipboard]::SetText($lines -join "`r`n")
Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "CG: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})
# ── BtnCGExportXlsx ───────────────────────────────────────────────────────────
$script:Window.FindName('BtnCGExportXlsx').Add_Click({
$dg = $script:Window.FindName('DgCGResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) {
Show-Notification "No results to export." -BgColor '#FFF3CD' -FgColor '#856404'
return
}
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$dlg = [Microsoft.Win32.SaveFileDialog]::new()
$dlg.Title = "Export Compare Groups Results"
$dlg.Filter = "Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*"
$dlg.FileName = "CompareGroups_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
$initDir = try { if ($script:ExportOutPath) { Split-Path $script:ExportOutPath -Parent } else { $null } } catch { $null }
$dlg.InitialDirectory = if ($initDir -and (Test-Path $initDir)) { $initDir } else { [Environment]::GetFolderPath('Desktop') }
if ($dlg.ShowDialog() -ne $true) { return }
$outPath = $dlg.FileName
try {
$folder = Split-Path $outPath -Parent
if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
$exportRows = $allItems | ForEach-Object {
$row = $_; $ht = [ordered]@{}
foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$row.($cd['P']) }
[PSCustomObject]$ht
}
if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
Import-Module ImportExcel -ErrorAction SilentlyContinue
$exportRows | Export-Excel -Path $outPath -WorksheetName "CompareGroups" -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium6
Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
Show-Notification "ImportExcel not found — saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
}
Write-VerboseLog "CG: Exported $($allItems.Count) rows to: $outPath" -Level Success
} catch {
Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
Write-VerboseLog "CG: Export failed: $($_.Exception.Message)" -Level Error
}
})
# ── RENAME GROUP ──
$script:Window.FindName('BtnRenValidate').Add_Click({
Hide-Notification
if ($null -eq $script:RenSelectedGroup) {
Show-Notification "Please select a target group first." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$newName = $script:Window.FindName('TxtNewGroupName').Text.Trim()
if ([string]::IsNullOrWhiteSpace($newName)) {
Show-Notification "Please enter a new display name." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$script:RenNewName = $newName
$btn = $script:Window.FindName('BtnRenValidate')
Invoke-OnBackground -DisableButton $btn -BusyMessage "Checking name uniqueness..." -Work {
$newName = $script:RenNewName
Write-VerboseLog "Current: $($script:RenSelectedGroup.DisplayName) -> New: $newName" -Level Info
$existing = $null
try {
$safe_newName = $newName -replace "'","''" # PS5-safe: extracted to avoid nested double-quotes in string
$resp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe_newName'`&`$select=id`&`$top=1"
if ($resp['value'] -and $resp['value'].Count -gt 0) { $existing = $resp['value'][0]['id'] }
} catch {}
if ($existing) { Write-VerboseLog "WARNING: A group named '$newName' already exists." -Level Warning }
$script:RenExistingId = $existing
} -Done {
$newName = $script:RenNewName
$existing = $script:RenExistingId
if ($existing) {
Show-Notification "Warning: A group named '$newName' already exists. Continuing will create a duplicate." -BgColor '#FFF3CD' -FgColor '#7A4800'
}
$emptyValid = [System.Collections.Generic.List[PSCustomObject]]::new()
$emptyInvalid = [System.Collections.Generic.List[string]]::new()
$confirmed = Show-ConfirmationDialog `
-Title "Rename Group" -OperationLabel "Rename Group" `
-TargetGroup $script:RenSelectedGroup.DisplayName `
-TargetGroupId $script:RenSelectedGroup.Id `
-ValidObjects $emptyValid -InvalidEntries $emptyInvalid `
-ExtraInfo "Current name : $($script:RenSelectedGroup.DisplayName)`nNew name : $newName"
if (-not $confirmed) { Write-VerboseLog "Action cancelled by user." -Level Warning; return }
$btn = $script:Window.FindName('BtnRenValidate')
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Renaming group ---" -Work {
Invoke-MgGraphRequest -Method PATCH `
-Uri "https://graph.microsoft.com/v1.0/groups/$($script:RenSelectedGroup.Id)" `
-Body (@{ displayName = $script:RenNewName } | ConvertTo-Json) -ContentType "application/json"
Write-VerboseLog "Group renamed to '$($script:RenNewName)'" -Level Success
} -Done {
Show-Notification "Group renamed to '$($script:RenNewName)'" -BgColor '#D4EDDA' -FgColor '#155724'
$script:Window.FindName('TxtRenGroupCurrentName').Text = $script:RenNewName
$script:RenSelectedGroup = [PSCustomObject]@{
Id = $script:RenSelectedGroup.Id; DisplayName = $script:RenNewName
GroupTypes = $script:RenSelectedGroup.GroupTypes; MembershipRule = $script:RenSelectedGroup.MembershipRule
}
}
}
})
# ── SET GROUP OWNER (multi-group) ──
$script:Window.FindName('BtnOwnerValidate').Add_Click({
Hide-Notification
$groupListTxt = $script:Window.FindName('TxtOwnerGroupList').Text
if ([string]::IsNullOrWhiteSpace($groupListTxt)) {
Show-Notification "Please enter at least one target group (name or ID)." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$ownerTxt = $script:Window.FindName('TxtOwnerList').Text
if ([string]::IsNullOrWhiteSpace($ownerTxt)) {
Show-Notification "Please enter at least one owner UPN." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$script:OwnerGroupsTxt = $groupListTxt
$script:OwnerUpnsTxt = $ownerTxt
$btn = $script:Window.FindName('BtnOwnerValidate')
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Set Owner: Validating groups and UPNs ---" -Work {
# -- Resolve target groups --
$groupLines = $script:OwnerGroupsTxt -split "`n" | Where-Object { $_.Trim() -ne '' }
$validGroups = [System.Collections.Generic.List[PSCustomObject]]::new()
$invalidGroups = [System.Collections.Generic.List[string]]::new()
foreach ($line in $groupLines) {
$entry = $line.Trim()
if ([string]::IsNullOrWhiteSpace($entry)) { continue }
Write-VerboseLog "Resolving group: $entry" -Level Info
$g = Resolve-GroupByNameOrId -GroupEntry $entry
if ($g) {
$validGroups.Add($g)
Write-VerboseLog " $([char]0x2713) $($g.DisplayName) ($($g.Id))" -Level Success
} else {
$invalidGroups.Add($entry)
Write-VerboseLog " $([char]0x2717) Not found: $entry" -Level Warning
}
}
# -- Resolve owner UPNs --
$upns = $script:OwnerUpnsTxt -split "`n" | Where-Object { $_.Trim() -ne '' }
$validOwners = [System.Collections.Generic.List[PSCustomObject]]::new()
$invalidOwners = [System.Collections.Generic.List[string]]::new()
foreach ($upn in $upns) {
$u = $upn.Trim()
if ([string]::IsNullOrWhiteSpace($u)) { continue }
Write-VerboseLog "Resolving owner UPN: $u" -Level Info
try {
$userObj = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($u))?`$select=id,displayName"
$validOwners.Add([PSCustomObject]@{ Id = $userObj['id']; DisplayName = $userObj['displayName']; Type = 'User'; Found = $true; Original = $u })
Write-VerboseLog " $([char]0x2713) $($userObj['displayName']) ($($userObj['id']))" -Level Success
} catch {
$invalidOwners.Add($u)
Write-VerboseLog " $([char]0x2717) Not found: $u" -Level Warning
}
}
$script:OwnerGroupsValidated = @{ ValidGroups = $validGroups; InvalidGroups = $invalidGroups }
$script:OwnerValidated = @{ Valid = $validOwners; Invalid = $invalidOwners }
} -Done {
$validGroups = $script:OwnerGroupsValidated['ValidGroups']
$invalidGroups = $script:OwnerGroupsValidated['InvalidGroups']
$validOwners = $script:OwnerValidated['Valid']
$invalidOwners = $script:OwnerValidated['Invalid']
if ($validGroups.Count -eq 0) {
Show-Notification "No valid target groups found. Cannot proceed." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
if ($validOwners.Count -eq 0) {
Show-Notification "No valid owner UPNs found. Cannot proceed." -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
# Build ExtraInfo block listing all target groups for the confirmation dialog
$sbGroups = [System.Text.StringBuilder]::new()
$null = $sbGroups.AppendLine(('TARGET GROUPS (' + $validGroups.Count + ' valid, ' + $invalidGroups.Count + ' not found):'))
foreach ($g in $validGroups) { $null = $sbGroups.AppendLine(" $([char]0x2713) $($g.DisplayName) ($($g.Id))") }
foreach ($inv in $invalidGroups) { $null = $sbGroups.AppendLine(" $([char]0x2717) $inv (not found - will be skipped)") }
$extraInfo = $sbGroups.ToString().TrimEnd()
$confirmed = Show-ConfirmationDialog `
-Title "Set Group Owner (Multi-Group)" -OperationLabel "Set Group Owner" `
-TargetGroup "Multiple groups ($($validGroups.Count))" `
-TargetGroupId "(see group list below)" `
-ValidObjects $validOwners -InvalidEntries $invalidOwners `
-ExtraInfo $extraInfo
if (-not $confirmed) { Write-VerboseLog "Action cancelled by user." -Level Warning; return }
$btn = $script:Window.FindName('BtnOwnerValidate')
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Adding owners to groups ---" -Work {
$ok = 0; $fail = 0; $skipped = 0
$validGroups = $script:OwnerGroupsValidated['ValidGroups']
foreach ($grp in $validGroups) {
Write-VerboseLog "Processing group: $($grp.DisplayName)" -Level Info
foreach ($owner in $script:OwnerValidated['Valid']) {
try {
$ob = @{ "@odata.id" = "https://graph.microsoft.com/v1.0/users/$($owner.Id)" }
Invoke-MgGraphRequest -Method POST `
-Uri "https://graph.microsoft.com/v1.0/groups/$($grp.Id)/owners/`$ref" `
-Body ($ob | ConvertTo-Json) -ContentType "application/json"
Write-VerboseLog "Owner added: $($owner.DisplayName) -> $($grp.DisplayName)" -Level Success
$ok++
} catch {
$errMsg = $_.Exception.Message
$errDetail = if ($_.ErrorDetails) { $_.ErrorDetails.Message } else { '' }
if ($errMsg -match 'already exist' -or $errDetail -match 'already exist') {
Write-VerboseLog "Skipping $($owner.DisplayName) - already an owner of $($grp.DisplayName)" -Level Warning
$skipped++
} else {
Write-VerboseLog "Failed: $($owner.DisplayName) -> $($grp.DisplayName) - $errMsg" -Level Error
$fail++
}
}
}
}
$script:OwnerExecResult = @{ Ok = $ok; Fail = $fail; Skipped = $skipped }
} -Done {
$ok = $script:OwnerExecResult['Ok']
$fail = $script:OwnerExecResult['Fail']
$skipped = $script:OwnerExecResult['Skipped']
$msg = "Set Owner complete. Succeeded: $ok | Skipped (already owner): $skipped | Failed: $fail"
$vLevel = if ($fail -gt 0) { 'Warning' } else { 'Success' }
$bgColor = if ($fail -gt 0) { '#FFF3CD' } else { '#D4EDDA' }
$fgColor = if ($fail -gt 0) { '#7A4800' } else { '#155724' }
Write-VerboseLog $msg -Level $vLevel
Show-Notification $msg -BgColor $bgColor -FgColor $fgColor
}
}
})
# ── REMOVE GROUPS ─────────────────────────────────────────────────────────────
$script:Window.FindName('BtnOpRemoveGroups').Add_Click({
$script:Window.FindName('TxtRGGroupList').Text = ''
$script:Window.FindName('PnlRGProgress').Visibility = 'Collapsed'
$script:Window.FindName('PnlRGWarning').Visibility = 'Collapsed'
$script:Window.FindName('PnlRGPreview').Visibility = 'Collapsed'
$script:Window.FindName('PnlRGExecProgress').Visibility = 'Collapsed'
$script:Window.FindName('PnlRGResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtRGNoResults').Visibility = 'Collapsed'
$dg = $script:Window.FindName('DgRGPreview')
if ($dg) { $dg.ItemsSource = $null }
$dg2 = $script:Window.FindName('DgRGResults')
if ($dg2) { $dg2.ItemsSource = $null }
$script:Window.FindName('BtnRGExecute').IsEnabled = $false
$script:Window.FindName('BtnRGCopyAll').IsEnabled = $false
$script:Window.FindName('BtnRGExportXlsx').IsEnabled = $false
$script:RGGroupList = $null
$script:RGValidated = $null
$script:RGExecResult = $null
Show-Panel 'PanelRemoveGroups'
Hide-Notification
Write-VerboseLog 'Panel: Remove Groups' -Level Info
})
$script:Window.FindName('BtnRGValidate').Add_Click({
Hide-Notification
$inputTxt = $script:Window.FindName('TxtRGGroupList').Text
if ([string]::IsNullOrWhiteSpace($inputTxt)) {
Show-Notification 'Please enter at least one group name or Object ID.' -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$script:RGGroupList = $inputTxt
$dg = $script:Window.FindName('DgRGPreview')
if ($dg) { $dg.ItemsSource = $null }
$dg2 = $script:Window.FindName('DgRGResults')
if ($dg2) { $dg2.ItemsSource = $null }
$script:Window.FindName('PnlRGPreview').Visibility = 'Collapsed'
$script:Window.FindName('PnlRGResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlRGWarning').Visibility = 'Collapsed'
$script:Window.FindName('TxtRGNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlRGExecProgress').Visibility = 'Collapsed'
$script:Window.FindName('BtnRGExecute').IsEnabled = $false
$script:Window.FindName('BtnRGCopyAll').IsEnabled = $false
$script:Window.FindName('BtnRGExportXlsx').IsEnabled = $false
$script:Window.FindName('PnlRGProgress').Visibility = 'Visible'
$script:Window.FindName('TxtRGProgressMsg').Text = 'Validating groups...'
$script:Window.FindName('TxtRGProgressDetail').Text = ''
$btn = $script:Window.FindName('BtnRGValidate')
Invoke-OnBackground -DisableButton $btn -BusyMessage '--- Remove Groups: Validating ---' -Work {
$inputLines = ($script:RGGroupList -split "`n") | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
$validated = [System.Collections.Generic.List[PSCustomObject]]::new()
$total = $inputLines.Count
$idx = 0
foreach ($entry in $inputLines) {
$idx++
$Shared['LogQueue'].Enqueue(@{ Line = "RG: Resolving ($idx/$total): $entry"; Level = 'Info' })
$grp = Resolve-GroupByNameOrId -GroupEntry $entry
if ($null -eq $grp) {
$validated.Add([PSCustomObject]@{
DisplayName = $entry
GroupID = '(not found)'
GroupType = [char]0x2014
MemberCount = [char]0x2014
HasMembers = $false
IsResolved = $false
OrigEntry = $entry
})
$Shared['LogQueue'].Enqueue(@{ Line = "RG: Not found: $entry"; Level = 'Warning' })
continue
}
# Determine group type from Graph metadata
$groupTypeName = 'Unknown'
try {
$meta = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$($grp.Id)?`$select=mailEnabled,securityEnabled,groupTypes"
$mailEnabled = [bool]$meta['mailEnabled']
$secEnabled = [bool]$meta['securityEnabled']
$gtArr = @($meta['groupTypes'])
$groupTypeName = if ($gtArr -contains 'Unified') { 'Microsoft 365' }
elseif ($mailEnabled -and -not $secEnabled) { 'Distribution' }
elseif ($secEnabled -and -not $mailEnabled) { 'Security' }
elseif ($secEnabled -and $mailEnabled) { 'Mail-Enabled Security' }
else { 'Unknown' }
} catch {}
$memberCount = Get-GroupMemberCount -GroupId $grp.Id
$mc = if ($null -eq $memberCount) { '?' } else { [string]$memberCount }
$hasMembers = ($null -ne $memberCount -and $memberCount -gt 0)
$validated.Add([PSCustomObject]@{
DisplayName = $grp.DisplayName
GroupID = $grp.Id
GroupType = $groupTypeName
MemberCount = $mc
HasMembers = $hasMembers
IsResolved = $true
OrigEntry = $entry
})
$Shared['LogQueue'].Enqueue(@{ Line = "RG: Resolved: $($grp.DisplayName) [$($grp.Id)] | $groupTypeName | Members: $mc"; Level = 'Success' })
}
$script:RGValidated = $validated
} -Done {
$script:Window.FindName('PnlRGProgress').Visibility = 'Collapsed'
$rows = $script:RGValidated
if ($null -eq $rows -or $rows.Count -eq 0) {
$script:Window.FindName('TxtRGNoResults').Visibility = 'Visible'
Show-Notification 'No groups resolved from the provided input.' -BgColor '#FFF3CD' -FgColor '#7A4800'
return
}
$resolved = @($rows | Where-Object { $_.IsResolved })
$withMembers = @($rows | Where-Object { $_.HasMembers })
$notFound = @($rows | Where-Object { -not $_.IsResolved })
if ($resolved.Count -eq 0) {
Show-Notification 'No groups could be resolved. Check the names/IDs and try again.' -BgColor '#FFF3CD' -FgColor '#7A4800'
return
}
# Build ValidObjects and InvalidEntries for Show-ConfirmationDialog
$validObjs = @($resolved | ForEach-Object { [PSCustomObject]@{ DisplayName = $_.DisplayName; Type = $_.GroupType; Id = $_.GroupID } })
$invalidEntries = @($notFound | ForEach-Object { $_.OrigEntry })
# ExtraInfo — warn about groups that still have members
$extra = ''
if ($withMembers.Count -gt 0) {
$memberLines = ($withMembers | ForEach-Object { " $([char]0x26A0) $($_.DisplayName) ($($_.MemberCount) member(s))" }) -join "`n"
$extra = "$([char]0x26A0) $($withMembers.Count) group$(if($withMembers.Count -eq 1){''}else{'s'}) still ha$(if($withMembers.Count -eq 1){'s'}else{'ve'}) members.`nMembers lose group access but are NOT deleted:`n$memberLines"
}
$msg = "Validation complete. $($resolved.Count) group(s) ready, $($notFound.Count) not found."
Write-VerboseLog "RG: $msg" -Level Info
$confirmed = Show-ConfirmationDialog `
-Title 'Remove Groups' -OperationLabel 'Remove Groups' `
-TargetGroup "$($resolved.Count) group(s) selected" -TargetGroupId '(see list below)' `
-ValidObjects $validObjs -InvalidEntries $invalidEntries `
-ExtraInfo $extra
if (-not $confirmed) { Write-VerboseLog 'RG: Action cancelled by user.' -Level Warning; return }
# Set up UI for execution progress
$dg2 = $script:Window.FindName('DgRGResults')
if ($dg2) { $dg2.ItemsSource = $null }
$script:Window.FindName('PnlRGResults').Visibility = 'Collapsed'
$script:Window.FindName('BtnRGCopyAll').IsEnabled = $false
$script:Window.FindName('BtnRGExportXlsx').IsEnabled = $false
$script:Window.FindName('PnlRGExecProgress').Visibility = 'Visible'
$script:Window.FindName('TxtRGExecProgressMsg').Text = 'Removing groups...'
$script:Window.FindName('TxtRGExecProgressDetail').Text = ''
$btn = $script:Window.FindName('BtnRGValidate')
Invoke-OnBackground -DisableButton $btn -BusyMessage '--- Remove Groups: Executing ---' -Work {
$toDelete = @($script:RGValidated | Where-Object { $_.IsResolved })
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
$total = $toDelete.Count
$idx = 0
foreach ($g in $toDelete) {
$idx++
$Shared['LogQueue'].Enqueue(@{ Line = "RG: Deleting ($idx/$total): $($g.DisplayName)"; Level = 'Action' })
try {
$null = Invoke-MgGraphRequest -Method DELETE -Uri "https://graph.microsoft.com/v1.0/groups/$($g.GroupID)"
$results.Add([PSCustomObject]@{
DisplayName = $g.DisplayName
GroupID = $g.GroupID
Status = 'Removed'
Message = 'Successfully deleted.'
IsSuccess = $true
})
$Shared['LogQueue'].Enqueue(@{ Line = "RG: Removed: $($g.DisplayName) [$($g.GroupID)]"; Level = 'Success' })
} catch {
$errMsg = $_.Exception.Message
$results.Add([PSCustomObject]@{
DisplayName = $g.DisplayName
GroupID = $g.GroupID
Status = 'Failed'
Message = $errMsg
IsSuccess = $false
})
$Shared['LogQueue'].Enqueue(@{ Line = "RG: Failed: $($g.DisplayName) - $errMsg"; Level = 'Error' })
}
}
$script:RGExecResult = $results
} -Done {
$script:Window.FindName('PnlRGExecProgress').Visibility = 'Collapsed'
$exRows = $script:RGExecResult
if ($null -eq $exRows -or $exRows.Count -eq 0) {
Show-Notification 'No results returned from deletion.' -BgColor '#FFF3CD' -FgColor '#7A4800'
return
}
$dg2 = $script:Window.FindName('DgRGResults')
$dg2.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($exRows)
$ok = @($exRows | Where-Object { $_.IsSuccess }).Count
$fail = @($exRows | Where-Object { -not $_.IsSuccess }).Count
$script:Window.FindName('TxtRGResultCount').Text = "Removed: $ok | Failed: $fail"
$script:Window.FindName('PnlRGResults').Visibility = 'Visible'
$script:Window.FindName('BtnRGCopyAll').IsEnabled = $true
$script:Window.FindName('BtnRGExportXlsx').IsEnabled = $true
$rgMsg = "Remove Groups complete. Removed: $ok | Failed: $fail"
$bgColor = if ($fail -gt 0) { '#FFF3CD' } else { '#D4EDDA' }
$fgColor = if ($fail -gt 0) { '#7A4800' } else { '#155724' }
Show-Notification $rgMsg -BgColor $bgColor -FgColor $fgColor
Write-VerboseLog "RG: $rgMsg" -Level $(if ($fail -gt 0) { 'Warning' } else { 'Success' })
}
}
})
# BtnRGExecute is no longer used as a separate button — Remove Groups validation now flows
# directly into Show-ConfirmationDialog and execution is triggered from BtnRGValidate Done.
# ── BtnRGCopyAll ───────────────────────────────────────────────────────────────
$script:Window.FindName('BtnRGCopyAll').Add_Click({
$dg = $script:Window.FindName('DgRGResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) { return }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$txtLines = [System.Collections.Generic.List[string]]::new()
$txtLines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
foreach ($row in $allItems) {
$vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
$txtLines.Add($vals -join "`t")
}
[System.Windows.Clipboard]::SetText($txtLines -join "`r`n")
Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "RG: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})
# ── BtnRGExportXlsx ───────────────────────────────────────────────────────────
$script:Window.FindName('BtnRGExportXlsx').Add_Click({
$dg = $script:Window.FindName('DgRGResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) {
Show-Notification 'No results to export.' -BgColor '#FFF3CD' -FgColor '#856404'
return
}
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$dlg = [Microsoft.Win32.SaveFileDialog]::new()
$dlg.Title = 'Export Remove Groups Results'
$dlg.Filter = 'Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*'
$dlg.FileName = "RemoveGroups_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
$dlg.InitialDirectory = [Environment]::GetFolderPath('Desktop')
if ($dlg.ShowDialog() -ne $true) { return }
$outPath = $dlg.FileName
try {
$folder = Split-Path $outPath -Parent
if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
$exportRows = $allItems | ForEach-Object {
$rowObj = $_; $ht = [ordered]@{}
foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$rowObj.($cd['P']) }
[PSCustomObject]$ht
}
if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
Import-Module ImportExcel -ErrorAction SilentlyContinue
$exportRows | Export-Excel -Path $outPath -WorksheetName 'RemoveGroups' -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium6
Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
Show-Notification "ImportExcel not found — saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
}
Write-VerboseLog "RG: Exported $($allItems.Count) rows to: $outPath" -Level Success
} catch {
Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
Write-VerboseLog "RG: Export failed: $($_.Exception.Message)" -Level Error
}
})
# ── SESSION NOTES ──────────────────────────────────────────────────────────────
$script:Window.FindName('BtnSessionNotes').Add_Click({
$script:Window.FindName('OverlaySessionNotes').Visibility = 'Visible'
$script:Window.FindName('TxtSessionNotes').Focus() | Out-Null
})
$script:Window.FindName('BtnSessionNotesClose').Add_Click({
$script:Window.FindName('OverlaySessionNotes').Visibility = 'Collapsed'
})
$script:Window.FindName('SnDimmer').Add_MouseLeftButtonDown({
$script:Window.FindName('OverlaySessionNotes').Visibility = 'Collapsed'
})
# ── ADD USER DEVICES TO GROUP ──────────────────────────────────────────────
Wire-GroupPicker `
-SearchBoxName 'TxtUDGroupSearch' -SearchBtnName 'BtnUDGroupSearch' -ByIdBtnName 'BtnUDGroupById' `
-ResultsListName 'LstUDGroupResults' -ResultsBorderName 'UDGroupSearchResults' `
-BadgeName 'UDSelectedGroupBadge' -BadgeNameTxt 'TxtUDGroupName' -BadgeIdTxt 'TxtUDGroupId' `
-ScriptVarName 'UDSelectedGroup'
$script:Window.FindName('BtnOpUserDevices').Add_Click({
$script:Window.FindName('TxtUDGroupSearch').Text = ''
$script:Window.FindName('TxtUDUpnList').Text = ''
$script:Window.FindName('UDSelectedGroupBadge').Visibility = 'Collapsed'
$script:Window.FindName('UDGroupSearchResults').Visibility = 'Collapsed'
$script:Window.FindName('ChkUDWindows').IsChecked = $true
$script:Window.FindName('ChkUDAndroid').IsChecked = $true
$script:Window.FindName('ChkUDiOS').IsChecked = $true
$script:Window.FindName('ChkUDMacOS').IsChecked = $true
$script:Window.FindName('CmbUDOwnership').SelectedIndex = 0
$script:Window.FindName('ChkUDIntuneOnly').IsChecked = $false
$script:UDSelectedGroup = $null
Show-Panel 'PanelUserDevices'
Hide-Notification
Write-VerboseLog 'Panel: Add User Devices to Group' -Level Info
})
$script:Window.FindName('BtnUDValidate').Add_Click({
Hide-Notification
if ($null -eq $script:UDSelectedGroup) {
Show-Notification 'Please select a target group first.' -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$upnTxt = $script:Window.FindName('TxtUDUpnList').Text
if ([string]::IsNullOrWhiteSpace($upnTxt)) {
Show-Notification 'Please enter at least one user UPN.' -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
# Collect filter selections on the UI thread before handing off to background
$selPlatforms = [System.Collections.Generic.List[string]]::new()
if ($script:Window.FindName('ChkUDWindows').IsChecked) { $selPlatforms.Add('Windows') }
if ($script:Window.FindName('ChkUDAndroid').IsChecked) { $selPlatforms.Add('Android') }
if ($script:Window.FindName('ChkUDiOS').IsChecked) { $selPlatforms.Add('iOS') }
if ($script:Window.FindName('ChkUDMacOS').IsChecked) { $selPlatforms.Add('macOS') }
if ($selPlatforms.Count -eq 0) {
Show-Notification 'Please select at least one device platform.' -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$ownershipSel = $script:Window.FindName('CmbUDOwnership').SelectedItem.Content
$ownershipFilter = switch ($ownershipSel) {
'Company only' { 'Company' }
'Personal only' { 'Personal' }
default { 'All' }
}
$intuneOnly = [bool]$script:Window.FindName('ChkUDIntuneOnly').IsChecked
$script:UDParams = @{
UpnTxt = $upnTxt
Platforms = $selPlatforms
OwnershipFilter = $ownershipFilter
IntuneOnly = $intuneOnly
}
$btn = $script:Window.FindName('BtnUDValidate')
Invoke-OnBackground -DisableButton $btn -BusyMessage '--- Add User Devices: Validating ---' -Work {
$params = $script:UDParams
$upns = $params['UpnTxt'] -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
$platforms = $params['Platforms']
$ownershipFilter = $params['OwnershipFilter']
$intuneOnly = $params['IntuneOnly']
$validDevices = [System.Collections.Generic.List[PSCustomObject]]::new()
$invalidUpns = [System.Collections.Generic.List[string]]::new()
$skippedDevices = [System.Collections.Generic.List[string]]::new()
Write-VerboseLog "Target group: $($script:UDSelectedGroup.DisplayName) ($($script:UDSelectedGroup.Id))" -Level Info
Write-VerboseLog "Platform filter : $($platforms -join ', ')" -Level Info
Write-VerboseLog "Ownership filter: $ownershipFilter" -Level Info
Write-VerboseLog "Processing $($upns.Count) UPN(s)..." -Level Info
foreach ($upn in $upns) {
Write-VerboseLog "Resolving user: $upn" -Level Info
$userId = $null
$userDisplayName = $upn
try {
$userObj = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($upn))?`$select=id,displayName"
$userId = $userObj['id']
$userDisplayName = $userObj['displayName']
Write-VerboseLog " $([char]0x2713) User found: $userDisplayName ($userId)" -Level Success
} catch {
Write-VerboseLog " $([char]0x2717) User not found: $upn" -Level Warning
$invalidUpns.Add($upn)
continue
}
# ── Choose data source based on IntuneOnly filter ──────────────────
# IntuneOnly = $true → Intune managedDevices endpoint:
# - Only returns devices enrolled in Intune (filter is implicit)
# - operatingSystem values are clean: iOS, Windows, Android, macOS
# IntuneOnly = $false → Entra ownedDevices endpoint:
# - Returns all Entra-registered devices (enrolled or not)
# - operatingSystem strings vary: iPhone, iPadOS, MacMDM, AndroidForWork, etc.
# - normalised below before comparison.
if ($intuneOnly) {
# Intune endpoint - requires DeviceManagementManagedDevices.Read.All
$deviceUri = "https://graph.microsoft.com/v1.0/users/$userId/managedDevices" +
"?`$select=id,deviceName,operatingSystem,managedDeviceOwnerType,azureADDeviceId`&`$top=999"
} else {
# Entra endpoint - requires Device.Read.All (already consented)
$deviceUri = "https://graph.microsoft.com/v1.0/users/$userId/ownedDevices" +
"?`$select=id,displayName,operatingSystem,deviceOwnership`&`$top=999"
}
$devicesRaw = @()
try {
$devResp = Invoke-MgGraphRequest -Method GET -Uri $deviceUri
$devicesRaw = @($devResp['value'])
} catch {
Write-VerboseLog " [!] Could not fetch devices for $upn : $($_.Exception.Message)" -Level Warning
continue
}
if ($devicesRaw.Count -eq 0) {
Write-VerboseLog " No owned devices found for $upn" -Level Info
continue
}
$srcLabel = if ($intuneOnly) { 'Intune' } else { 'Entra' }
Write-VerboseLog " Found $($devicesRaw.Count) device(s) from $srcLabel - applying filters..." -Level Info
foreach ($dev in $devicesRaw) {
# Field names differ between the two endpoints
if ($intuneOnly) {
$devId = $dev['id']
$devName = $dev['deviceName']
$devOS = $dev['operatingSystem'] # already clean: iOS/Windows/Android/macOS
$devOwnership = $dev['managedDeviceOwnerType'] # 'company' or 'personal'
$mgmtLabel = 'Intune'
} else {
$devId = $dev['id']
$devName = $dev['displayName']
$devOS = $dev['operatingSystem']
$devOwnership = $dev['deviceOwnership'] # 'Company' or 'Personal'
$mgmtLabel = 'Entra'
}
# Platform filter
# Entra stores OS with varying strings - normalise to the four UI values.
# Intune values are already clean so the switch still matches correctly.
$osNorm = switch -Wildcard ($devOS) {
'Windows*' { 'Windows' }
'Android*' { 'Android' }
'iOS' { 'iOS' }
'iPadOS' { 'iOS' } # Entra: iPads enrolled as iPadOS
'iPhone' { 'iOS' } # Entra: iPhones stored as 'iPhone'
'MacMDM' { 'macOS' } # Entra: Macs enrolled via MDM profile
'Mac OS X' { 'macOS' } # Entra: legacy macOS string
'macOS' { 'macOS' }
default { $devOS }
}
if ($osNorm -notin $platforms) {
$skippedDevices.Add(($devName + ' (' + $devOS + ' - not in platform filter)'))
Write-VerboseLog " Skipped (platform): $devName [$devOS]" -Level Info
continue
}
# Ownership filter
# Intune uses lowercase ('company'/'personal'); Entra uses title-case ('Company'/'Personal').
# Normalise both to title-case for the comparison.
$ownerNorm = if ($devOwnership) {
[System.Globalization.CultureInfo]::InvariantCulture.TextInfo.ToTitleCase($devOwnership.ToLower())
} else { '' }
if ($ownershipFilter -ne 'All' -and $ownerNorm -ne $ownershipFilter) {
$skippedDevices.Add("$devName (ownership: $devOwnership - filter: $ownershipFilter)")
Write-VerboseLog " Skipped (ownership): $devName [$devOwnership]" -Level Info
continue
}
$_devSummary = $osNorm + ' | ' + $ownerNorm + ' | ' + $mgmtLabel + ' | owner: ' + $userDisplayName
Write-VerboseLog " $([char]0x2713) Matched: $devName [$_devSummary]" -Level Success
# ── For Intune devices: resolve the Entra directory object ID ────────────────────────
# Intune managedDevices returns its own managed-device ID, which is NOT a valid Entra
# directory object ID. We use azureADDeviceId to look up the Entra device object ID.
if ($intuneOnly) {
$aadDevId = $dev['azureADDeviceId']
if ([string]::IsNullOrEmpty($aadDevId)) {
Write-VerboseLog " [!] No azureADDeviceId on Intune record for $devName — skipping." -Level Warning
$skippedDevices.Add("$devName (Intune record missing azureADDeviceId)")
continue
}
try {
$entraLookup = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/devices?`$filter=deviceId eq '$aadDevId'&`$select=id"
$entraObjId = $entraLookup['value'][0]['id']
if ([string]::IsNullOrEmpty($entraObjId)) {
Write-VerboseLog " [!] Entra device not found for azureADDeviceId=$aadDevId ($devName) — skipping." -Level Warning
$skippedDevices.Add("$devName (device not found in Entra directory)")
continue
}
$devId = $entraObjId # override with Entra object ID for group membership add
Write-VerboseLog " Resolved Entra object ID for $devName : $devId" -Level Info
} catch {
Write-VerboseLog " [!] Entra ID lookup failed for $devName : $($_.Exception.Message) — skipping." -Level Warning
$skippedDevices.Add("$devName (Entra ID resolution failed: $($_.Exception.Message))")
continue
}
}
$validDevices.Add([PSCustomObject]@{
Id = $devId
DisplayName = "$devName [$_devSummary]"
Type = 'Device'
Found = $true
Original = $devName
})
}
}
$script:UDValidated = @{
ValidDevices = $validDevices
InvalidUpns = $invalidUpns
SkippedDevices = $skippedDevices
}
} -Done {
$validDevices = $script:UDValidated['ValidDevices']
$invalidUpns = $script:UDValidated['InvalidUpns']
$skippedDevices = $script:UDValidated['SkippedDevices']
$params = $script:UDParams
if ($validDevices.Count -eq 0) {
$msg = 'No devices matched the selected filters. Check UPNs, platform and ownership selections.'
Write-VerboseLog $msg -Level Warning
Show-Notification $msg -BgColor '#FFF3CD' -FgColor '#7A4800'
return
}
$extraLines = [System.Collections.Generic.List[string]]::new()
$extraLines.Add("Platform filter : $($params['Platforms'] -join ', ')")
$extraLines.Add("Ownership filter : $($params['OwnershipFilter'])")
$extraLines.Add("Intune only : $(if ($params['IntuneOnly']) { 'Yes' } else { 'No' })")
if ($invalidUpns.Count -gt 0) { $extraLines.Add("Users not found : $($invalidUpns.Count) UPN(s) could not be resolved") }
if ($skippedDevices.Count -gt 0) { $extraLines.Add("Devices skipped (filter mismatch): $($skippedDevices.Count)") }
$invalidForDlg = [System.Collections.Generic.List[string]]::new()
foreach ($u in $invalidUpns) { $invalidForDlg.Add($u) }
$confirmed = Show-ConfirmationDialog `
-Title 'Add User Devices to Group' -OperationLabel 'Add User Devices to Group' `
-TargetGroup $script:UDSelectedGroup.DisplayName `
-TargetGroupId $script:UDSelectedGroup.Id `
-ValidObjects $validDevices -InvalidEntries $invalidForDlg `
-ExtraInfo ($extraLines -join "`n")
if (-not $confirmed) { Write-VerboseLog 'Action cancelled by user.' -Level Warning; return }
$btn = $script:Window.FindName('BtnUDValidate')
Invoke-OnBackground -DisableButton $btn -BusyMessage '--- Adding devices to group ---' -Work {
$devices = $script:UDValidated['ValidDevices']
$groupId = $script:UDSelectedGroup.Id
$ok = 0; $fail = 0
foreach ($dev in $devices) {
if ($null -ne $Shared -and $Shared['StopRequested']) {
Write-VerboseLog 'Stop requested - halting.' -Level Warning
break
}
Write-VerboseLog "Adding device: $($dev.Original)..." -Level Action
try {
$body = @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$($dev.Id)" }
Invoke-MgGraphRequest -Method POST `
-Uri "https://graph.microsoft.com/v1.0/groups/$groupId/members/`$ref" `
-Body ($body | ConvertTo-Json) -ContentType 'application/json'
Write-VerboseLog " $([char]0x2713) Added: $($dev.Original)" -Level Success
$ok++
} catch {
$errMsg = $_.Exception.Message
if ($errMsg -match 'already exist') {
Write-VerboseLog " Already a member: $($dev.Original)" -Level Warning
} else {
Write-VerboseLog " $([char]0x2717) Failed: $($dev.Original) - $errMsg" -Level Error
$fail++
}
}
}
$script:UDExecResult = @{ Ok = $ok; Fail = $fail }
} -Done {
$ok = $script:UDExecResult['Ok']
$fail = $script:UDExecResult['Fail']
$msg = "Add User Devices complete. Added: $ok | Failed: $fail"
$vLevel = if ($fail -gt 0) { 'Warning' } else { 'Success' }
$bgColor = if ($fail -gt 0) { '#FFF3CD' } else { '#D4EDDA' }
$fgColor = if ($fail -gt 0) { '#7A4800' } else { '#155724' }
Write-VerboseLog $msg -Level $vLevel
Show-Notification $msg -BgColor $bgColor -FgColor $fgColor
}
}
})
# ── GET POLICY ASSIGNMENTS ───────────────────────────────────────────────────
# ── Sub-type normalisation ──
function Get-GPASubType {
param([string]$PolicyCollection, [string]$ODataType, [string]$TemplateDisplayName)
switch ($PolicyCollection) {
'configurationPolicies' {
if (-not [string]::IsNullOrEmpty($TemplateDisplayName)) {
switch -Wildcard ($TemplateDisplayName) {
'*Antivirus exclusion*' { return 'Microsoft Defender Antivirus Exclusions' }
'*Defender Antivirus*' { return 'Microsoft Defender Antivirus' }
'*Defender Update*' { return 'Defender Update Controls' }
'*Attack Surface*' { return 'Attack Surface Reduction Rules' }
'*BitLocker*' { return 'BitLocker' }
'*Windows Firewall Rules*' { return 'Windows Firewall Rules' }
'*Windows Firewall*' { return 'Windows Firewall' }
'*MDE*onboard*' { return 'MDE Onboarding Policy' }
'*Exploit Protection*' { return 'Exploit Protection' }
'*App Control*' { return 'App Control for Business' }
'*Windows Security Experience*' { return 'Windows Security Experience' }
'*Identity Protection*' { return 'Identity Protection' }
'*Local user group*' { return 'Local User Group Membership' }
'*LAPS*' { return 'Local Admin Password Solution (Windows LAPS)' }
'*Health Monitoring*' { return 'Health Monitoring' }
'*Windows operating system recovery*'{ return 'Windows OS Recovery' }
'*Update ring*' { return 'Update Rings' }
'*Software update*' { return 'Software Update' }
'*Endpoint detection*' { return 'Endpoint Protection' }
'*Extension*' { return 'Extensions' }
default { return $TemplateDisplayName }
}
} else { return 'Settings Catalog' }
}
'deviceConfigurations' {
$c = $ODataType -replace '^#microsoft\.graph\.', ''
switch -Wildcard ($c) {
'*trustedRoot*' { return 'Trusted Certificate' }
'*scepCertificate*' { return 'SCEP Certificate' }
'*pkcsCertificate*' { return 'Certificate' }
'*pkcs12Import*' { return 'Certificate' }
'*vpn*' { return 'VPN' }
'*wifiConfiguration*' { return 'Wi-Fi' }
'*wiFiConfiguration*' { return 'Wi-Fi' }
'*wifi*' { return 'Wi-Fi' }
'*sharedPC*' { return 'Shared PC' }
'*editionUpgrade*' { return 'Edition Upgrade' }
'*kiosk*' { return 'Kiosk' }
'*updateForBusiness*' { return 'Update Rings' }
'*softwareUpdate*' { return 'Software Update' }
'*endpointProtection*' { return 'Endpoint Protection' }
'*bitLocker*' { return 'BitLocker' }
'*deviceFirmware*' { return 'Device Features' }
'*deviceFeatures*' { return 'Device Features' }
'*windowsHealthMonitoring*' { return 'Health Monitoring' }
'*localAdminPassword*' { return 'Local Admin Password Solution (Windows LAPS)' }
'*custom*' { return 'Custom' }
'*general*' { return 'General Device' }
default { return $c }
}
}
'groupPolicyConfigurations' { return 'Administrative Templates' }
'deviceManagementScripts' { return 'PowerShell' }
'deviceHealthScripts' { return 'Remediation' }
'windowsAutopilotDeploymentProfiles' { return 'Device Preparation' }
default { return '' }
}
}
# ── OS Platform normalisation ──
function Get-GPAPlatform {
param([string]$PolicyCollection, [string]$ODataType, [string]$Platforms)
switch ($PolicyCollection) {
'configurationPolicies' {
switch -Wildcard ($Platforms) {
'windows10' { return 'Windows' }
'windows*' { return 'Windows' }
'iOS' { return 'iOS/iPadOS' }
'macOS' { return 'macOS' }
'android' { return 'Android' }
'androidAOSP' { return 'AOSP' }
'linux' { return 'Linux' }
default { return $Platforms }
}
}
'deviceConfigurations' {
$c = $ODataType -replace '^#microsoft\.graph\.', ''
switch -Wildcard ($c) {
'windows*' { return 'Windows' }
'sharedPC*' { return 'Windows' }
'editionUpgrade*' { return 'Windows' }
'windows81*' { return 'Windows' }
'windowsPhone*' { return 'Windows' }
'ios*' { return 'iOS/iPadOS' }
'macOS*' { return 'macOS' }
'android*' { return 'Android' }
'aosp*' { return 'AOSP' }
'linux*' { return 'Linux' }
default { return '' }
}
}
'groupPolicyConfigurations' { return 'Windows' }
'deviceManagementScripts' { return 'Windows' }
'deviceHealthScripts' { return 'Windows' }
'windowsAutopilotDeploymentProfiles' { return 'Windows' }
default { return '' }
}
}
# ── Panel open ──
$script:Window.FindName('BtnOpGetPolicyAssignments').Add_Click({
$script:Window.FindName('TxtGPANameFilter').Text = ''
$script:Window.FindName('CmbGPAType').SelectedIndex = 0
$script:Window.FindName('CmbGPASubType').SelectedIndex = 0
$script:Window.FindName('CmbGPAPlatform').SelectedIndex = 0
$script:Window.FindName('PnlGPAProgress').Visibility = 'Collapsed'
$script:Window.FindName('PnlGPAResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtGPANoResults').Visibility = 'Collapsed'
foreach ($b in @('BtnGPACopyValue','BtnGPACopyRow','BtnGPACopyAll','BtnGPAExportXlsx')) {
$script:Window.FindName($b).IsEnabled = $false
}
$dg = $script:Window.FindName('DgGPAResults'); if ($dg) { $dg.ItemsSource = $null }
$script:GPAResult = $null
Show-Panel 'PanelGetPolicyAssignments'
Hide-Notification
Write-VerboseLog 'Panel: Get Policy Assignments' -Level Info
})
# ── Shared launch function (called by both Get Selected and Get All buttons) ──
function Start-GPAQuery {
param([bool]$ApplyFilters)
Hide-Notification
$script:Window.FindName('PnlGPAResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtGPANoResults').Visibility = 'Collapsed'
foreach ($b in @('BtnGPACopyValue','BtnGPACopyRow','BtnGPACopyAll','BtnGPAExportXlsx')) {
$script:Window.FindName($b).IsEnabled = $false
}
$dg = $script:Window.FindName('DgGPAResults'); if ($dg) { $dg.ItemsSource = $null }
$nameKw = if ($ApplyFilters) { $script:Window.FindName('TxtGPANameFilter').Text.Trim() } else { '' }
$typeF = if ($ApplyFilters) { $script:Window.FindName('CmbGPAType').SelectedItem.Content } else { '(All types)' }
$subTypeF = if ($ApplyFilters) { $script:Window.FindName('CmbGPASubType').SelectedItem.Content } else { '(All sub-types)' }
$platF = if ($ApplyFilters) { $script:Window.FindName('CmbGPAPlatform').SelectedItem.Content } else { '(All platforms)' }
$script:GPAParams = @{
NameKeyword = $nameKw
TypeFilter = $typeF
SubTypeFilter = $subTypeF
PlatFilter = $platF
}
$script:Window.FindName('PnlGPAProgress').Visibility = 'Visible'
$script:Window.FindName('TxtGPAProgressMsg').Text = 'Loading policies...'
$script:Window.FindName('TxtGPAProgressDetail').Text = ''
foreach ($b in @('BtnGPAGetSelected','BtnGPAGetAll')) {
$script:Window.FindName($b).IsEnabled = $false
}
Write-VerboseLog ('--- Get Policy Assignments: starting (ApplyFilters=' + $ApplyFilters + ') ---') -Level Action
Invoke-OnBackground -BusyMessage '--- Get Policy Assignments ---' -Work {
$params = $script:GPAParams
$nameKw = $params['NameKeyword']
$typeFilter = $params['TypeFilter']
$subFilter = $params['SubTypeFilter']
$platFilter = $params['PlatFilter']
# ── Helper: page through Graph collection ──────────────────────────────
function GPA-Page { param([string]$Uri)
$all = [System.Collections.Generic.List[object]]::new()
$next = $Uri
while ($next) {
$r = Invoke-MgGraphRequest -Method GET -Uri $next
if ($r['value']) { foreach ($v in @($r['value'])) { $all.Add($v) } }
$next = $r['@odata.nextLink']
}
return $all
}
# ── Load assignment filters lookup ─────────────────────────────────────
Write-VerboseLog 'GPA: Loading assignment filters...' -Level Info
$filterLookup = @{}
try {
$filters = GPA-Page -Uri 'https://graph.microsoft.com/beta/deviceManagement/assignmentFilters?$select=id,displayName'
foreach ($f in $filters) { if ($f['id']) { $filterLookup[$f['id']] = $f['displayName'] } }
Write-VerboseLog "GPA: $($filterLookup.Count) assignment filter(s) loaded" -Level Info
} catch { Write-VerboseLog "GPA: Could not load assignment filters: $($_.Exception.Message)" -Level Warning }
# ── Group display name cache ───────────────────────────────────────────
$groupCache = @{}
function GPA-GroupName { param([string]$GroupId)
if (-not $GroupId) { return '' }
if ($groupCache.ContainsKey($GroupId)) { return $groupCache[$GroupId] }
try {
$enc = [Uri]::EscapeDataString($GroupId)
$g = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/${enc}?`$select=id,displayName"
$groupCache[$GroupId] = $g['displayName']; return $g['displayName']
} catch { $groupCache[$GroupId] = $GroupId; return $GroupId }
}
# ── Decide which collections to query ──────────────────────────────────
$collections = @(
@{ Key='deviceConfigurations'; Label='Device Configuration'; Uri='https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations?$select=id,displayName' }
@{ Key='configurationPolicies'; Label='Configuration Policy'; Uri='https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$select=id,name,platforms,technologies,templateReference' }
@{ Key='groupPolicyConfigurations'; Label='Administrative Templates'; Uri='https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations?$select=id,displayName' }
@{ Key='deviceManagementScripts'; Label='Platform Script (Windows PowerShell)'; Uri='https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts?$select=id,displayName' }
@{ Key='deviceHealthScripts'; Label='Detection/Remediation Script'; Uri='https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts?$select=id,displayName' }
@{ Key='windowsAutopilotDeploymentProfiles';Label='Autopilot Deployment Profile'; Uri='https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles?$select=id,displayName' }
)
# Skip collections not matching the type filter
if ($typeFilter -ne '(All types)') {
$collections = $collections | Where-Object { $_['Label'] -eq $typeFilter }
}
# ── Platform-based collection pre-filter (skip Windows-only endpoints) ──
$winOnlyKeys = @('groupPolicyConfigurations','deviceManagementScripts',
'deviceHealthScripts','windowsAutopilotDeploymentProfiles')
if ($platFilter -ne '(All platforms)' -and $platFilter -ne 'Windows') {
$before = @($collections).Count
$skippedNames = @($collections | Where-Object { $winOnlyKeys -contains $_['Key'] }) | ForEach-Object { $_['Label'] }
$collections = @($collections | Where-Object { $winOnlyKeys -notcontains $_['Key'] })
if ($skippedNames.Count -gt 0) {
Write-VerboseLog ("GPA: Skipped " + $skippedNames.Count + " Windows-only collection(s): " + ($skippedNames -join ', ')) -Level Info
}
}
# ── Server-side platform filter for configurationPolicies ──
$platApiMap = @{ 'Windows'='windows10'; 'macOS'='macOS'; 'iOS/iPadOS'='iOS';
'Android'='android'; 'AOSP'='androidAOSP'; 'Linux'='linux' }
if ($platFilter -ne '(All platforms)' -and $platApiMap.ContainsKey($platFilter)) {
$apiPlat = $platApiMap[$platFilter]
foreach ($c in $collections) {
if ($c['Key'] -eq 'configurationPolicies') {
$c['OriginalUri'] = $c['Uri']
$c['Uri'] += "&`$filter=platforms has '$apiPlat'"
Write-VerboseLog "GPA: Server-side platform filter added for Configuration Policy ($apiPlat)" -Level Info
break
}
}
}
$rows = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($col in $collections) {
Write-VerboseLog "GPA: Loading $($col['Label'])..." -Level Info
$policies = @()
try { $policies = @(GPA-Page -Uri $col['Uri']) }
catch {
if ($col['OriginalUri']) {
Write-VerboseLog "GPA: Server-side filter failed, falling back to full list..." -Level Warning
try { $policies = @(GPA-Page -Uri $col['OriginalUri']) }
catch { Write-VerboseLog "GPA: Failed to load $($col['Key']): $($_.Exception.Message)" -Level Warning; continue }
} else { Write-VerboseLog "GPA: Failed to load $($col['Key']): $($_.Exception.Message)" -Level Warning; continue }
}
Write-VerboseLog "GPA: $($policies.Count) $($col['Label']) policy/policies found" -Level Info
$pIdx = 0
foreach ($policy in $policies) {
$pIdx++
# Get display name (field differs per endpoint)
$pName = if ($col['Key'] -eq 'configurationPolicies') { [string]$policy['name'] }
else { [string]$policy['displayName'] }
# Keyword filter
if ($nameKw -and ($pName -notlike "*$nameKw*")) { continue }
# Derive sub-type and platform
$odataType = [string]$policy['@odata.type']
$tmplName = if ($policy['templateReference']) { [string]$policy['templateReference']['templateDisplayName'] } else { '' }
$platforms = [string]$policy['platforms']
$subType = Get-GPASubType -PolicyCollection $col['Key'] -ODataType $odataType -TemplateDisplayName $tmplName
$platform = Get-GPAPlatform -PolicyCollection $col['Key'] -ODataType $odataType -Platforms $platforms
# Sub-type filter
if ($subFilter -ne '(All sub-types)' -and $subType -ne $subFilter) { continue }
# Platform filter
if ($platFilter -ne '(All platforms)' -and $platform -ne $platFilter) { continue }
# Get assignments for this policy
$assignUri = "https://graph.microsoft.com/beta/deviceManagement/$($col['Key'])/$($policy['id'])/assignments"
$assignments = @()
try { $assignments = @(GPA-Page -Uri $assignUri) }
catch { Write-VerboseLog "GPA: Could not get assignments for '$pName': $($_.Exception.Message)" -Level Warning; continue }
if ($assignments.Count -eq 0) {
$rows.Add([PSCustomObject]@{
PolicyName = $pName
PolicyType = $col['Label']
PolicySubType = $subType
OSPlatform = $platform
IncludedGroup = ''
ExcludedGroup = ''
IncludeFilter = ''
ExcludeFilter = ''
}); continue
}
foreach ($asgn in $assignments) {
$target = $asgn['target']
if (-not $target) { continue }
$otype = [string]$target['@odata.type']
$isExclude = $otype -match 'exclusionGroup'
$groupId = if ($target['groupId']) { [string]$target['groupId'] } else { [string]$target['entraObjectId'] }
$targetName = ''
if ($groupId) {
$targetName = GPA-GroupName -GroupId $groupId
} elseif ($otype -match 'allDevices') {
$targetName = '[All devices]'
} elseif ($otype -match 'allLicensedUsers|allUsers') {
$targetName = '[All users]'
} else { continue }
$filterId = [string]$target['deviceAndAppManagementAssignmentFilterId']
$filterType = [string]$target['deviceAndAppManagementAssignmentFilterType']
$filterName = if ($filterId -and $filterLookup.ContainsKey($filterId)) { $filterLookup[$filterId] } else { $filterId }
$rows.Add([PSCustomObject]@{
PolicyName = $pName
PolicyType = $col['Label']
PolicySubType = $subType
OSPlatform = $platform
IncludedGroup = if (-not $isExclude) { $targetName } else { '' }
ExcludedGroup = if ($isExclude) { $targetName } else { '' }
IncludeFilter = if ($filterType -eq 'include') { $filterName } else { '' }
ExcludeFilter = if ($filterType -eq 'exclude') { $filterName } else { '' }
})
}
}
}
$script:GPAResult = $rows
Write-VerboseLog "GPA: Complete. $($rows.Count) assignment row(s) returned." -Level Info
} -Done {
foreach ($b in @('BtnGPAGetSelected','BtnGPAGetAll')) {
$script:Window.FindName($b).IsEnabled = $true
}
$script:Window.FindName('PnlGPAProgress').Visibility = 'Collapsed'
$rows = $script:GPAResult
if (-not $rows -or $rows.Count -eq 0) {
$script:Window.FindName('TxtGPANoResults').Visibility = 'Visible'
Write-VerboseLog 'GPA: No results to display.' -Level Warning
return
}
$sorted = $rows | Sort-Object PolicyName, PolicyType, IncludedGroup, ExcludedGroup
$dg = $script:Window.FindName('DgGPAResults')
$dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($sorted)
$script:Window.FindName('TxtGPACount').Text = "$($rows.Count) assignment row(s)"
$script:Window.FindName('BtnGPACopyAll').IsEnabled = $true
$script:Window.FindName('TxtGPAFilter').IsEnabled = $true
$script:Window.FindName('BtnGPAFilter').IsEnabled = $true
$script:Window.FindName('BtnGPAFilterClear').IsEnabled = $true
$script:Window.FindName('BtnGPAExportXlsx').IsEnabled = $true
$script:Window.FindName('PnlGPAResults').Visibility = 'Visible'
# Enable Copy Value / Copy Row on selection
$dg.Add_SelectedCellsChanged({
$selCells = $script:Window.FindName('DgGPAResults').SelectedCells.Count
$script:Window.FindName('BtnGPACopyValue').IsEnabled = ($selCells -ge 1)
$script:Window.FindName('BtnGPACopyRow').IsEnabled = ($selCells -gt 0)
})
}
}
$script:Window.FindName('BtnGPAGetSelected').Add_Click({ Start-GPAQuery -ApplyFilters $true })
$script:Window.FindName('BtnGPAGetAll').Add_Click( { Start-GPAQuery -ApplyFilters $false })
# ── Sub-Type filter: grey-out Policy Type when a specific sub-type is chosen ──
$script:Window.FindName('CmbGPASubType').Add_SelectionChanged({
$sub = $script:Window.FindName('CmbGPASubType')
$type = $script:Window.FindName('CmbGPAType')
if ($sub.SelectedIndex -le 0) {
# Back to "All sub-types" — restore Policy Type filter
$type.IsEnabled = $true
} else {
# Specific sub-type chosen — clear and disable Policy Type to avoid contradictions
$type.SelectedIndex = 0
$type.IsEnabled = $false
}
})
# ── Copy / Export buttons ──
# ── BtnGPAFilter ──
$script:Window.FindName('BtnGPAFilter').Add_Click({
$dg = $script:Window.FindName('DgGPAResults')
$keyword = $script:Window.FindName('TxtGPAFilter').Text.Trim()
if ($null -eq $script:GPAAllData) {
$script:GPAAllData = @($dg.ItemsSource)
}
if ($keyword -eq '') {
$dg.ItemsSource = $script:GPAAllData
Show-Notification "Filter cleared - $($script:GPAAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$filtered = @($script:GPAAllData | Where-Object {
$row = $_
$match = $false
foreach ($prop in $row.PSObject.Properties) {
if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
}
$match
})
# ── BtnGPAFilterClear ──
$script:Window.FindName('BtnGPAFilterClear').Add_Click({
$script:Window.FindName('TxtGPAFilter').Text = ''
$dg = $script:Window.FindName('DgGPAResults')
if ($null -ne $script:GPAAllData) {
$dg.ItemsSource = $script:GPAAllData
Show-Notification "Filter cleared - $($script:GPAAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
}
})
$dg.ItemsSource = $filtered
Show-Notification "$($filtered.Count) of $($script:GPAAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
}
Write-VerboseLog "GPA: Filter applied - keyword='$keyword'" -Level Info
})
$script:Window.FindName('BtnGPACopyValue').Add_Click({
$dg = $script:Window.FindName('DgGPAResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$vals = [System.Collections.Generic.List[string]]::new()
foreach ($cellInfo in @($dg.SelectedCells)) {
$col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
}
[System.Windows.Clipboard]::SetText($vals -join "`r`n")
Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
})
$script:Window.FindName('BtnGPACopyRow').Add_Click({
$dg = $script:Window.FindName('DgGPAResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$seen = [System.Collections.Generic.HashSet[object]]::new()
$rows = [System.Collections.Generic.List[object]]::new()
foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rows.Add($cell.Item) } }
$lines = foreach ($r in $rows) {
"$($r.PolicyName)`t$($r.PolicyType)`t$($r.PolicySubType)`t$($r.OSPlatform)`t$($r.IncludedGroup)`t$($r.ExcludedGroup)`t$($r.IncludeFilter)`t$($r.ExcludeFilter)"
}
[System.Windows.Clipboard]::SetText(($lines -join [Environment]::NewLine))
Show-Notification "Copied $($rows.Count) row(s)." -BgColor '#D4EDDA' -FgColor '#155724'
# Highlight full rows
$dg.UnselectAllCells()
foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
})
$script:Window.FindName('BtnGPACopyAll').Add_Click({
$dg = $script:Window.FindName('DgGPAResults')
$src = @($dg.ItemsSource)
$hdr = "Policy Name`tType`tSub-Type`tOS Platform`tIncluded Group`tExcluded Group`tInclude Filter`tExclude Filter"
$body = foreach ($r in $src) {
"$($r.PolicyName)`t$($r.PolicyType)`t$($r.PolicySubType)`t$($r.OSPlatform)`t$($r.IncludedGroup)`t$($r.ExcludedGroup)`t$($r.IncludeFilter)`t$($r.ExcludeFilter)"
}
[System.Windows.Clipboard]::SetText(($hdr, ($body -join [Environment]::NewLine) -join [Environment]::NewLine))
Show-Notification "Copied $($src.Count) row(s) with headers." -BgColor '#D4EDDA' -FgColor '#155724'
})
$script:Window.FindName('BtnGPAExportXlsx').Add_Click({
$dg = $script:Window.FindName('DgGPAResults')
$src = @($dg.ItemsSource)
if ($src.Count -eq 0) { Show-Notification 'No data to export.' -BgColor '#FFF3CD' -FgColor '#7A4800'; return }
$stamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$xlsxPath = [System.IO.Path]::Combine($env:USERPROFILE, 'Downloads', "GPAExport_$stamp.xlsx")
try {
$headers = @('Policy Name','Type','Sub-Type','OS Platform','Included Group','Excluded Group','Include Filter','Exclude Filter')
$props = @('PolicyName','PolicyType','PolicySubType','OSPlatform','IncludedGroup','ExcludedGroup','IncludeFilter','ExcludeFilter')
Export-ToXlsx -Rows $src -Headers $headers -Properties $props -Path $xlsxPath
Show-Notification "Exported $($src.Count) rows → $([System.IO.Path]::GetFileName($xlsxPath))" -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "GPA: XLSX exported to $xlsxPath" -Level Success
} catch {
Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
Write-VerboseLog "GPA: XLSX export error: $($_.Exception.Message)" -Level Error
}
})
# ── FIND COMMON GROUPS ────────────────────────────────────────────────────
# Update the input label when the input type RadioButton changes
$script:Window.FindName('RbFCUsers').Add_Checked({
$script:Window.FindName('TxtFCInputLabel').Text = 'USER UPNs - one per line'
$script:Window.FindName('TxtFCInputList').Tag = 'user1@domain.com'
})
$script:Window.FindName('RbFCDevices').Add_Checked({
$script:Window.FindName('TxtFCInputLabel').Text = 'DEVICE NAMES or Object IDs - one per line'
$script:Window.FindName('TxtFCInputList').Tag = 'LAPTOP-001'
})
$script:Window.FindName('RbFCGroups').Add_Checked({
$script:Window.FindName('TxtFCInputLabel').Text = 'GROUP NAMES or Object IDs - one per line'
$script:Window.FindName('TxtFCInputList').Tag = 'SG-Finance-All'
})
$script:Window.FindName('BtnOpFindCommon').Add_Click({
$script:Window.FindName('TxtFCInputList').Text = ''
$script:Window.FindName('RbFCUsers').IsChecked = $true
$script:Window.FindName('TxtFCInputLabel').Text = 'USER UPNs - one per line'
$script:Window.FindName('TxtFCInputList').Tag = 'user1@domain.com'
Show-Panel 'PanelFindCommon'
Hide-Notification
Write-VerboseLog 'Panel: Find Common Groups' -Level Info
})
$script:Window.FindName('BtnFCFind').Add_Click({
Hide-Notification
$inputTxt = $script:Window.FindName('TxtFCInputList').Text
if ([string]::IsNullOrWhiteSpace($inputTxt)) {
Show-Notification 'Please enter at least one identifier.' -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$inputType = if ($script:Window.FindName('RbFCUsers').IsChecked) { 'Users' }
elseif ($script:Window.FindName('RbFCDevices').IsChecked) { 'Devices' }
else { 'Groups' }
$script:FCParams = @{ InputTxt = $inputTxt; InputType = $inputType }
$btn = $script:Window.FindName('BtnFCFind')
$script:Window.FindName('PnlFCResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtFCNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlFCProgress').Visibility = 'Visible'
$script:Window.FindName('TxtFCProgressMsg').Text = "Resolving $inputType..."
$script:Window.FindName('TxtFCProgressDetail').Text = ''
$script:Window.FindName('BtnFCCopyValue').IsEnabled = $false
$script:Window.FindName('BtnFCCopyRow').IsEnabled = $false
$script:Window.FindName('BtnFCCopyAll').IsEnabled = $false
$script:Window.FindName('TxtFCFilter').IsEnabled = $false
$script:Window.FindName('BtnFCFilter').IsEnabled = $false
$script:Window.FindName('BtnFCFilterClear').IsEnabled = $false
$script:Window.FindName('TxtFCFilter').Text = ''
$script:FCAllData = $null
$script:Window.FindName('BtnFCExportXlsx').IsEnabled = $false
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Find Common Groups: resolving $inputType ---" -Work {
$params = $script:FCParams
$inputType = $params['InputType']
$entries = $params['InputTxt'] -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
Write-VerboseLog "Input type : $inputType" -Level Info
Write-VerboseLog "Entries : $($entries.Count)" -Level Info
if ($entries.Count -lt 2) {
throw 'Please enter at least two identifiers to find common groups.'
}
# ── Step 1: resolve each entry to an Entra Object ID ─────────────────
$resolvedIds = [System.Collections.Generic.List[string]]::new()
$resolvedLabels = [System.Collections.Generic.List[string]]::new()
$failedEntries = [System.Collections.Generic.List[string]]::new()
$isGuidRx = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
foreach ($entry in $entries) {
$objId = $null; $label = $entry
try {
switch ($inputType) {
'Users' {
$u = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($entry))?`$select=id,displayName"
$objId = $u['id']; $label = $u['displayName']
}
'Devices' {
if ($entry -match $isGuidRx) {
$d = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/devices/$entry`?`$select=id,displayName"
$objId = $d['id']; $label = $d['displayName']
} else {
$safe = $entry -replace "'","''"
$dr = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safe'`&`$select=id,displayName`&`$top=1"
if ($dr['value'] -and $dr['value'].Count -gt 0) {
$objId = $dr['value'][0]['id']; $label = $dr['value'][0]['displayName']
} else { throw "Device not found: $entry" }
}
}
'Groups' {
if ($entry -match $isGuidRx) {
$g = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/groups/$entry`?`$select=id,displayName"
$objId = $g['id']; $label = $g['displayName']
} else {
$safe = $entry -replace "'","''"
$gr = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe'`&`$select=id,displayName`&`$top=1"
if ($gr['value'] -and $gr['value'].Count -gt 0) {
$objId = $gr['value'][0]['id']; $label = $gr['value'][0]['displayName']
} else { throw "Group not found: $entry" }
}
}
}
Write-VerboseLog " $([char]0x2713) $label ($objId)" -Level Success
$resolvedIds.Add($objId)
$resolvedLabels.Add($label)
} catch {
Write-VerboseLog " $([char]0x2717) Could not resolve: $entry - $($_.Exception.Message)" -Level Warning
$failedEntries.Add($entry)
}
}
if ($resolvedIds.Count -eq 0) {
throw 'None of the entries could be resolved in Entra. Check identifiers and try again.'
}
if ($resolvedIds.Count -lt 2) {
throw "Only one entry was resolved ($($resolvedLabels[0])). At least two are needed to find common groups."
}
# ── Step 2: fetch group memberships for each object ──────────────────
$membershipSets = [System.Collections.Generic.List[System.Collections.Generic.HashSet[string]]]::new()
$groupMeta = [System.Collections.Generic.Dictionary[string,hashtable]]::new()
for ($i = 0; $i -lt $resolvedIds.Count; $i++) {
$objId = $resolvedIds[$i]
$objLbl = $resolvedLabels[$i]
Write-VerboseLog "Fetching memberships for: $objLbl" -Level Info
$groupIds = [System.Collections.Generic.HashSet[string]]::new()
$baseEndpoint = if ($inputType -eq 'Users') {
"https://graph.microsoft.com/v1.0/users/$objId/transitiveMemberOf"
} else {
"https://graph.microsoft.com/v1.0/directoryObjects/$objId/transitiveMemberOf"
}
$nextUri = $baseEndpoint + "?`$select=id,displayName,groupTypes,mailEnabled,securityEnabled,membershipRuleProcessingState`&`$top=999"
do {
try {
$resp = Invoke-MgGraphRequest -Method GET -Uri $nextUri
foreach ($obj in @($resp['value'])) {
$odataType = [string]$obj['@odata.type']
if ($odataType -and $odataType -ne '#microsoft.graph.group') { continue }
$gid = $obj['id']
$null = $groupIds.Add($gid)
if (-not $groupMeta.ContainsKey($gid)) {
$groupMeta[$gid] = @{
DisplayName = [string]$obj['displayName']
GroupTypes = @($obj['groupTypes'])
MailEnabled = [bool]$obj['mailEnabled']
SecurityEnabled = [bool]$obj['securityEnabled']
MembershipRuleProcessingState = [string]$obj['membershipRuleProcessingState']
}
}
}
$nextUri = if ($resp.ContainsKey('@odata.nextLink')) { $resp['@odata.nextLink'] } else { $null }
} catch {
Write-VerboseLog " [!] Membership fetch error for $objLbl : $($_.Exception.Message)" -Level Warning
$nextUri = $null
}
} while ($nextUri)
Write-VerboseLog " $($groupIds.Count) group(s)" -Level Info
$membershipSets.Add($groupIds)
}
# ── Step 3: intersect ────────────────────────────────────────────────
$commonIds = [System.Collections.Generic.HashSet[string]]::new($membershipSets[0])
for ($i = 1; $i -lt $membershipSets.Count; $i++) {
$commonIds.IntersectWith($membershipSets[$i])
}
# ── Step 4: build flat result list ────────────────────────────────────
$fcList = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($gid in $commonIds) {
$meta = $groupMeta[$gid]
if ($meta['GroupTypes'] -contains 'Unified') {
$gType = 'M365'
} elseif ($meta['MailEnabled'] -and $meta['SecurityEnabled']) {
$gType = 'Mail-Enabled Security'
} elseif ($meta['SecurityEnabled']) {
$gType = 'Security'
} else {
$gType = 'Other'
}
$mType = if ($meta['MembershipRuleProcessingState'] -eq 'On') { 'Dynamic' } else { 'Assigned' }
$fcList.Add([PSCustomObject]@{
GroupName = $meta['DisplayName']
GroupID = $gid
GroupType = $gType
MembershipType = $mType
})
}
$script:FCResult = [System.Collections.Generic.List[PSCustomObject]]($fcList | Sort-Object GroupType, GroupName)
$cgLevel = if ($commonIds.Count -gt 0) { 'Success' } else { 'Warning' }
Write-VerboseLog "Common groups total: $($commonIds.Count)" -Level $cgLevel
if ($failedEntries.Count -gt 0) {
Write-VerboseLog " Skipped (unresolved): $($failedEntries -join ', ')" -Level Warning
}
} -Done {
$script:Window.FindName('PnlFCProgress').Visibility = 'Collapsed'
$resultList = $script:FCResult
$count = if ($resultList) { $resultList.Count } else { 0 }
if ($count -gt 0) {
$script:Window.FindName('DgFCResults').ItemsSource = $resultList
$script:Window.FindName('TxtFCCount').Text = "$count common group(s) found"
$script:Window.FindName('PnlFCResults').Visibility = 'Visible'
$script:Window.FindName('TxtFCNoResults').Visibility = 'Collapsed'
$script:Window.FindName('BtnFCCopyAll').IsEnabled = $true
$script:Window.FindName('TxtFCFilter').IsEnabled = $true
$script:Window.FindName('BtnFCFilter').IsEnabled = $true
$script:Window.FindName('BtnFCFilterClear').IsEnabled = $true
$script:Window.FindName('BtnFCExportXlsx').IsEnabled = $true
Write-VerboseLog "Find Common Groups complete. Found $count group(s)." -Level Success
} else {
$script:Window.FindName('PnlFCResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtFCNoResults').Visibility = 'Visible'
Write-VerboseLog "Find Common Groups complete. No common groups found." -Level Warning
}
}
})
# ── DgFCResults: cell selection ───────────────────────────────────────────────
$script:Window.FindName('DgFCResults').Add_SelectedCellsChanged({
$dg = $script:Window.FindName('DgFCResults')
$selCells = $dg.SelectedCells.Count
$script:Window.FindName('BtnFCCopyValue').IsEnabled = ($selCells -ge 1)
$script:Window.FindName('BtnFCCopyRow').IsEnabled = ($selCells -gt 0)
})
# ── BtnFCCopyValue ────────────────────────────────────────────────────────────
# ── BtnFCFilter ──
$script:Window.FindName('BtnFCFilter').Add_Click({
$dg = $script:Window.FindName('DgFCResults')
$keyword = $script:Window.FindName('TxtFCFilter').Text.Trim()
if ($null -eq $script:FCAllData) {
$script:FCAllData = @($dg.ItemsSource)
}
if ($keyword -eq '') {
$dg.ItemsSource = $script:FCAllData
Show-Notification "Filter cleared - $($script:FCAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$filtered = @($script:FCAllData | Where-Object {
$row = $_
$match = $false
foreach ($prop in $row.PSObject.Properties) {
if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
}
$match
})
# ── BtnFCFilterClear ──
$script:Window.FindName('BtnFCFilterClear').Add_Click({
$script:Window.FindName('TxtFCFilter').Text = ''
$dg = $script:Window.FindName('DgFCResults')
if ($null -ne $script:FCAllData) {
$dg.ItemsSource = $script:FCAllData
Show-Notification "Filter cleared - $($script:FCAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
}
})
$dg.ItemsSource = $filtered
Show-Notification "$($filtered.Count) of $($script:FCAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
}
Write-VerboseLog "FC: Filter applied - keyword='$keyword'" -Level Info
})
$script:Window.FindName('BtnFCCopyValue').Add_Click({
$dg = $script:Window.FindName('DgFCResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$vals = [System.Collections.Generic.List[string]]::new()
foreach ($cellInfo in @($dg.SelectedCells)) {
$col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
}
[System.Windows.Clipboard]::SetText($vals -join "`r`n")
Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "FC: Copied cell value to clipboard." -Level Success
})
# ── BtnFCCopyRow ──────────────────────────────────────────────────────────────
$script:Window.FindName('BtnFCCopyRow').Add_Click({
$dg = $script:Window.FindName('DgFCResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$seen = [System.Collections.Generic.HashSet[object]]::new()
$rows = [System.Collections.Generic.List[object]]::new()
foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rows.Add($cell.Item) } }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
foreach ($row in $rows) {
$vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
$lines.Add($vals -join "`t")
}
[System.Windows.Clipboard]::SetText($lines -join "`r`n")
Show-Notification "$($rows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "FC: Copied $($rows.Count) row(s) to clipboard." -Level Success
# Highlight full rows
$dg.UnselectAllCells()
foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
})
# ── BtnFCCopyAll ──────────────────────────────────────────────────────────────
$script:Window.FindName('BtnFCCopyAll').Add_Click({
$dg = $script:Window.FindName('DgFCResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) { return }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
foreach ($row in $allItems) {
$vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
$lines.Add($vals -join "`t")
}
[System.Windows.Clipboard]::SetText($lines -join "`r`n")
Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "FC: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})
# ── BtnFCExportXlsx ───────────────────────────────────────────────────────────
$script:Window.FindName('BtnFCExportXlsx').Add_Click({
$dg = $script:Window.FindName('DgFCResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) {
Show-Notification "No results to export." -BgColor '#FFF3CD' -FgColor '#856404'
return
}
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$dlg = [Microsoft.Win32.SaveFileDialog]::new()
$dlg.Title = "Export Find Common Groups Results"
$dlg.Filter = "Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*"
$dlg.FileName = "FindCommonGroups_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
$dlg.InitialDirectory = [Environment]::GetFolderPath('Desktop')
if ($dlg.ShowDialog() -ne $true) { return }
$outPath = $dlg.FileName
try {
$folder = Split-Path $outPath -Parent
if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
$exportRows = $allItems | ForEach-Object {
$row = $_; $ht = [ordered]@{}
foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$row.($cd['P']) }
[PSCustomObject]$ht
}
if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
Import-Module ImportExcel -ErrorAction SilentlyContinue
$exportRows | Export-Excel -Path $outPath -WorksheetName "CommonGroups" -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium6
Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
Show-Notification "ImportExcel not found — saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
}
Write-VerboseLog "FC: Exported $($allItems.Count) rows to: $outPath" -Level Success
} catch {
Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
Write-VerboseLog "FC: Export failed: $($_.Exception.Message)" -Level Error
}
})
# ── FIND DISTINCT GROUPS ────────────────────────────────────────────────────
# Update the input label when the input type RadioButton changes
$script:Window.FindName('RbFDUsers').Add_Checked({
$script:Window.FindName('TxtFDInputLabel').Text = 'USER UPNs - one per line'
$script:Window.FindName('TxtFDInputList').Tag = 'user1@domain.com'
})
$script:Window.FindName('RbFDDevices').Add_Checked({
$script:Window.FindName('TxtFDInputLabel').Text = 'DEVICE NAMES or Object IDs - one per line'
$script:Window.FindName('TxtFDInputList').Tag = 'LAPTOP-001'
})
$script:Window.FindName('RbFDGroups').Add_Checked({
$script:Window.FindName('TxtFDInputLabel').Text = 'GROUP NAMES or Object IDs - one per line'
$script:Window.FindName('TxtFDInputList').Tag = 'SG-Finance-All'
})
$script:Window.FindName('BtnOpFindDistinct').Add_Click({
$script:Window.FindName('TxtFDInputList').Text = ''
$script:Window.FindName('RbFDUsers').IsChecked = $true
$script:Window.FindName('TxtFDInputLabel').Text = 'USER UPNs - one per line'
$script:Window.FindName('TxtFDInputList').Tag = 'user1@domain.com'
Show-Panel 'PanelFindDistinct'
Hide-Notification
Write-VerboseLog 'Panel: Find Distinct Groups' -Level Info
})
$script:Window.FindName('BtnFDFind').Add_Click({
Hide-Notification
$inputTxt = $script:Window.FindName('TxtFDInputList').Text
if ([string]::IsNullOrWhiteSpace($inputTxt)) {
Show-Notification 'Please enter at least one identifier.' -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
$inputType = if ($script:Window.FindName('RbFDUsers').IsChecked) { 'Users' }
elseif ($script:Window.FindName('RbFDDevices').IsChecked) { 'Devices' }
else { 'Groups' }
$script:FDParams = @{ InputTxt = $inputTxt; InputType = $inputType }
$btn = $script:Window.FindName('BtnFDFind')
$script:Window.FindName('PnlFDResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtFDNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlFDProgress').Visibility = 'Visible'
$script:Window.FindName('TxtFDProgressMsg').Text = "Resolving $inputType..."
$script:Window.FindName('TxtFDProgressDetail').Text = ''
$script:Window.FindName('BtnFDCopyValue').IsEnabled = $false
$script:Window.FindName('BtnFDCopyRow').IsEnabled = $false
$script:Window.FindName('BtnFDCopyAll').IsEnabled = $false
$script:Window.FindName('BtnFDFilter').IsEnabled = $false
$script:Window.FindName('BtnFDFilterClear').IsEnabled = $false
$script:Window.FindName('TxtFDFilter').Text = ''
$script:FDAllData = $null
$script:Window.FindName('TxtFDFilter').IsEnabled = $false
$script:Window.FindName('BtnFDExportXlsx').IsEnabled = $false
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Find Distinct Groups: resolving $inputType ---" -Work {
$params = $script:FDParams
$inputType = $params['InputType']
$entries = $params['InputTxt'] -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
Write-VerboseLog "Input type : $inputType" -Level Info
Write-VerboseLog "Entries : $($entries.Count)" -Level Info
if ($entries.Count -lt 2) {
throw 'Please enter at least two identifiers to find distinct groups.'
}
# ── Step 1: resolve each entry to an Entra Object ID ─────────────────
$resolvedIds = [System.Collections.Generic.List[string]]::new()
$resolvedLabels = [System.Collections.Generic.List[string]]::new()
$failedEntries = [System.Collections.Generic.List[string]]::new()
$isGuidRx = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
foreach ($entry in $entries) {
$objId = $null; $label = $entry
try {
switch ($inputType) {
'Users' {
$u = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($entry))?`$select=id,displayName,userPrincipalName"
$objId = $u['id']; $label = $u['userPrincipalName']
}
'Devices' {
if ($entry -match $isGuidRx) {
$d = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/devices/$entry`?`$select=id,displayName"
$objId = $d['id']; $label = $d['displayName']
} else {
$safe = $entry -replace "'","''"
$dr = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safe'`&`$select=id,displayName`&`$top=1"
if ($dr['value'] -and $dr['value'].Count -gt 0) {
$objId = $dr['value'][0]['id']; $label = $dr['value'][0]['displayName']
} else { throw "Device not found: $entry" }
}
}
'Groups' {
if ($entry -match $isGuidRx) {
$g = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/groups/$entry`?`$select=id,displayName"
$objId = $g['id']; $label = $g['displayName']
} else {
$safe = $entry -replace "'","''"
$gr = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe'`&`$select=id,displayName`&`$top=1"
if ($gr['value'] -and $gr['value'].Count -gt 0) {
$objId = $gr['value'][0]['id']; $label = $gr['value'][0]['displayName']
} else { throw "Group not found: $entry" }
}
}
}
Write-VerboseLog " $([char]0x2713) $label ($objId)" -Level Success
$resolvedIds.Add($objId)
$resolvedLabels.Add($label)
} catch {
Write-VerboseLog " $([char]0x2717) Could not resolve: $entry - $($_.Exception.Message)" -Level Warning
$failedEntries.Add($entry)
}
}
if ($resolvedIds.Count -eq 0) {
throw 'None of the entries could be resolved in Entra. Check identifiers and try again.'
}
if ($resolvedIds.Count -lt 2) {
throw ('Only one entry was resolved (' + $resolvedLabels[0] + '). At least two are needed to find distinct groups.')
}
# ── Step 2: fetch group memberships for each object ──────────────────
$membershipSets = [System.Collections.Generic.List[System.Collections.Generic.HashSet[string]]]::new()
$groupMeta = [System.Collections.Generic.Dictionary[string,hashtable]]::new()
for ($i = 0; $i -lt $resolvedIds.Count; $i++) {
$objId = $resolvedIds[$i]
$objLbl = $resolvedLabels[$i]
Write-VerboseLog "Fetching memberships for: $objLbl" -Level Info
$groupIds = [System.Collections.Generic.HashSet[string]]::new()
$baseEndpoint = if ($inputType -eq 'Users') {
"https://graph.microsoft.com/v1.0/users/$objId/transitiveMemberOf"
} else {
"https://graph.microsoft.com/v1.0/directoryObjects/$objId/transitiveMemberOf"
}
$nextUri = $baseEndpoint + "?`$select=id,displayName,groupTypes,mailEnabled,securityEnabled,membershipRuleProcessingState`&`$top=999"
do {
try {
$resp = Invoke-MgGraphRequest -Method GET -Uri $nextUri
foreach ($obj in @($resp['value'])) {
$odataType = [string]$obj['@odata.type']
if ($odataType -and $odataType -ne '#microsoft.graph.group') { continue }
$gid = $obj['id']
$null = $groupIds.Add($gid)
if (-not $groupMeta.ContainsKey($gid)) {
$groupMeta[$gid] = @{
DisplayName = [string]$obj['displayName']
GroupTypes = @($obj['groupTypes'])
MailEnabled = [bool]$obj['mailEnabled']
SecurityEnabled = [bool]$obj['securityEnabled']
MembershipRuleProcessingState = [string]$obj['membershipRuleProcessingState']
}
}
}
$nextUri = if ($resp.ContainsKey('@odata.nextLink')) { $resp['@odata.nextLink'] } else { $null }
} catch {
Write-VerboseLog " [!] Membership fetch error for $objLbl : $($_.Exception.Message)" -Level Warning
$nextUri = $null
}
} while ($nextUri)
Write-VerboseLog " $($groupIds.Count) group(s)" -Level Info
$membershipSets.Add($groupIds)
}
# ── Step 3: compute union minus intersection (distinct groups) ────────
$unionIds = [System.Collections.Generic.HashSet[string]]::new($membershipSets[0])
for ($i = 1; $i -lt $membershipSets.Count; $i++) {
$unionIds.UnionWith($membershipSets[$i])
}
$intersectIds = [System.Collections.Generic.HashSet[string]]::new($membershipSets[0])
for ($i = 1; $i -lt $membershipSets.Count; $i++) {
$intersectIds.IntersectWith($membershipSets[$i])
}
# Distinct = groups in the union but NOT in the intersection
# (i.e. groups that belong to SOME but NOT ALL input objects)
$distinctIds = [System.Collections.Generic.HashSet[string]]::new($unionIds)
$distinctIds.ExceptWith($intersectIds)
# ── Step 3b: map distinct groups → input object labels ──────────────
$groupOwnerMap = [System.Collections.Generic.Dictionary[string,System.Collections.Generic.List[string]]]::new()
foreach ($gid in $distinctIds) {
$owners = [System.Collections.Generic.List[string]]::new()
for ($i = 0; $i -lt $membershipSets.Count; $i++) {
if ($membershipSets[$i].Contains($gid)) { $owners.Add($resolvedLabels[$i]) }
}
$groupOwnerMap[$gid] = $owners
}
# ── Step 4: build flat result list ────────────────────────────────────
$fdList = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($gid in $distinctIds) {
$meta = $groupMeta[$gid]
if ($meta['GroupTypes'] -contains 'Unified') {
$gType = 'M365'
} elseif ($meta['MailEnabled'] -and $meta['SecurityEnabled']) {
$gType = 'Mail-Enabled Security'
} elseif ($meta['SecurityEnabled']) {
$gType = 'Security'
} else {
$gType = 'Other'
}
$mType = if ($meta['MembershipRuleProcessingState'] -eq 'On') { 'Dynamic' } else { 'Assigned' }
$fdList.Add([PSCustomObject]@{
GroupName = $meta['DisplayName']
GroupID = $gid
GroupType = $gType
MembershipType = $mType
MemberOf = ($groupOwnerMap[$gid] -join ', ')
})
}
$script:FDResult = [System.Collections.Generic.List[PSCustomObject]]($fdList | Sort-Object GroupType, GroupName)
$fdLevel = if ($distinctIds.Count -gt 0) { 'Success' } else { 'Warning' }
Write-VerboseLog "Distinct groups total: $($distinctIds.Count) (union: $($unionIds.Count) common: $($intersectIds.Count))" -Level $fdLevel
if ($failedEntries.Count -gt 0) {
Write-VerboseLog " Skipped (unresolved): $($failedEntries -join ', ')" -Level Warning
}
} -Done {
$script:Window.FindName('PnlFDProgress').Visibility = 'Collapsed'
$resultList = $script:FDResult
$count = if ($resultList) { $resultList.Count } else { 0 }
if ($count -gt 0) {
$script:Window.FindName('DgFDResults').ItemsSource = $resultList
$script:Window.FindName('TxtFDCount').Text = "$count distinct group(s) found"
$script:Window.FindName('PnlFDResults').Visibility = 'Visible'
$script:Window.FindName('TxtFDNoResults').Visibility = 'Collapsed'
$script:Window.FindName('BtnFDCopyAll').IsEnabled = $true
$script:Window.FindName('TxtFDFilter').IsEnabled = $true
$script:Window.FindName('BtnFDFilter').IsEnabled = $true
$script:Window.FindName('BtnFDFilterClear').IsEnabled = $true
$script:Window.FindName('BtnFDExportXlsx').IsEnabled = $true
Write-VerboseLog "Find Distinct Groups complete. Found $count group(s)." -Level Success
} else {
$script:Window.FindName('PnlFDResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtFDNoResults').Visibility = 'Visible'
Write-VerboseLog "Find Distinct Groups complete. No distinct groups found." -Level Warning
}
}
})
# ── DgFDResults: cell selection ───────────────────────────────────────────────
$script:Window.FindName('DgFDResults').Add_SelectedCellsChanged({
$dg = $script:Window.FindName('DgFDResults')
$selCells = $dg.SelectedCells.Count
$script:Window.FindName('BtnFDCopyValue').IsEnabled = ($selCells -ge 1)
$script:Window.FindName('BtnFDCopyRow').IsEnabled = ($selCells -gt 0)
})
# ── BtnFDCopyValue ────────────────────────────────────────────────────────────
# ── BtnFDFilter ──
$script:Window.FindName('BtnFDFilter').Add_Click({
$dg = $script:Window.FindName('DgFDResults')
$keyword = $script:Window.FindName('TxtFDFilter').Text.Trim()
if ($null -eq $script:FDAllData) {
$script:FDAllData = @($dg.ItemsSource)
}
if ($keyword -eq '') {
$dg.ItemsSource = $script:FDAllData
Show-Notification "Filter cleared - $($script:FDAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$filtered = @($script:FDAllData | Where-Object {
$row = $_
$match = $false
foreach ($prop in $row.PSObject.Properties) {
if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
}
$match
})
# ── BtnFDFilterClear ──
$script:Window.FindName('BtnFDFilterClear').Add_Click({
$script:Window.FindName('TxtFDFilter').Text = ''
$dg = $script:Window.FindName('DgFDResults')
if ($null -ne $script:FDAllData) {
$dg.ItemsSource = $script:FDAllData
Show-Notification "Filter cleared - $($script:FDAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
}
})
$dg.ItemsSource = $filtered
Show-Notification "$($filtered.Count) of $($script:FDAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
}
Write-VerboseLog "FD: Filter applied - keyword='$keyword'" -Level Info
})
$script:Window.FindName('BtnFDCopyValue').Add_Click({
$dg = $script:Window.FindName('DgFDResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$vals = [System.Collections.Generic.List[string]]::new()
foreach ($cellInfo in @($dg.SelectedCells)) {
$col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
}
[System.Windows.Clipboard]::SetText($vals -join "`r`n")
Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "FD: Copied cell value to clipboard." -Level Success
})
# ── BtnFDCopyRow ──────────────────────────────────────────────────────────────
$script:Window.FindName('BtnFDCopyRow').Add_Click({
$dg = $script:Window.FindName('DgFDResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$seen = [System.Collections.Generic.HashSet[object]]::new()
$rows = [System.Collections.Generic.List[object]]::new()
foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rows.Add($cell.Item) } }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
foreach ($row in $rows) {
$vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
$lines.Add($vals -join "`t")
}
[System.Windows.Clipboard]::SetText($lines -join "`r`n")
Show-Notification "$($rows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "FD: Copied $($rows.Count) row(s) to clipboard." -Level Success
# Highlight full rows
$dg.UnselectAllCells()
foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
})
# ── BtnFDCopyAll ──────────────────────────────────────────────────────────────
$script:Window.FindName('BtnFDCopyAll').Add_Click({
$dg = $script:Window.FindName('DgFDResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) { return }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
foreach ($row in $allItems) {
$vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
$lines.Add($vals -join "`t")
}
[System.Windows.Clipboard]::SetText($lines -join "`r`n")
Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "FD: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})
# ── BtnFDExportXlsx ───────────────────────────────────────────────────────────
$script:Window.FindName('BtnFDExportXlsx').Add_Click({
$dg = $script:Window.FindName('DgFDResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) {
Show-Notification "No results to export." -BgColor '#FFF3CD' -FgColor '#856404'
return
}
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$dlg = [Microsoft.Win32.SaveFileDialog]::new()
$dlg.Title = "Export Find Distinct Groups Results"
$dlg.Filter = "Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*"
$dlg.FileName = "FindDistinctGroups_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
$dlg.InitialDirectory = [Environment]::GetFolderPath('Desktop')
if ($dlg.ShowDialog() -ne $true) { return }
$outPath = $dlg.FileName
try {
$folder = Split-Path $outPath -Parent
if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
$exportRows = $allItems | ForEach-Object {
$row = $_; $ht = [ordered]@{}
foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$row.($cd['P']) }
[PSCustomObject]$ht
}
if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
Import-Module ImportExcel -ErrorAction SilentlyContinue
$exportRows | Export-Excel -Path $outPath -WorksheetName "DistinctGroups" -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium6
Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
Show-Notification "ImportExcel not found — saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
}
Write-VerboseLog "FD: Exported $($allItems.Count) rows to: $outPath" -Level Success
} catch {
Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
Write-VerboseLog "FD: Export failed: $($_.Exception.Message)" -Level Error
}
})
# ── DEVICE MANAGEMENT BLADE ─────────────────────────────────────────
# Blade toggle
$script:BladeDevExpanded = $false
$script:Window.FindName('BtnBladeDevMgmt').Add_Click({
$script:BladeDevExpanded = -not $script:BladeDevExpanded
$content = $script:Window.FindName('BladeDevMgmtContent')
$divider = $script:Window.FindName('BladeDevDivider')
$arrow = $script:Window.FindName('TxtBladeDevArrow')
if ($script:BladeDevExpanded) {
$content.Visibility = 'Visible'
$divider.Visibility = 'Visible'
$arrow.Text = [char]0x2212
} else {
$content.Visibility = 'Collapsed'
$divider.Visibility = 'Collapsed'
$arrow.Text = '+'
}
})
# ÄÄ Collapse Device Mgmt blade on startup ÄÄ
$script:Window.FindName('BladeDevMgmtContent').Visibility = 'Collapsed'
$script:Window.FindName('BladeDevDivider').Visibility = 'Collapsed'
$script:Window.FindName('TxtBladeDevArrow').Text = '+'
# Input type ComboBox → update label
$script:Window.FindName('CmbGDIInputType').Add_SelectionChanged({
$sel = $script:Window.FindName('CmbGDIInputType').SelectedItem.Content
$lbl = switch ($sel) {
'User UPNs (resolve owned devices)' { 'USER UPNs - one per line' }
'Device names' { 'DEVICE NAMES - one per line' }
'Device serial numbers' { 'SERIAL NUMBERS - one per line' }
'Entra Device Object IDs' { 'ENTRA DEVICE OBJECT IDs - one per line (GUIDs)' }
'Intune Device IDs' { 'INTUNE DEVICE IDs - one per line (GUIDs)' }
'Groups (Users and Devices)' { 'GROUP NAMES or Object IDs - one per line' }
default { 'INPUT - one per line' }
}
$script:Window.FindName('TxtGDIInputLabel').Text = $lbl
})
# Panel open
$script:Window.FindName('BtnOpGetDeviceInfo').Add_Click({
$script:Window.FindName('TxtGDIInputList').Text = ''
$script:Window.FindName('CmbGDIInputType').SelectedIndex = 0
$script:Window.FindName('CmbGDIOwnership').SelectedIndex = 0
$script:Window.FindName('TxtGDIInputLabel').Text = 'USER UPNs - one per line'
foreach ($chk in @('ChkGDIWindows','ChkGDIAndroid','ChkGDIiOS','ChkGDIMacOS')) {
$script:Window.FindName($chk).IsChecked = $false
}
$script:Window.FindName('PnlGDIProgress').Visibility = 'Collapsed'
$script:Window.FindName('PnlGDIResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtGDINoResults').Visibility = 'Collapsed'
$script:Window.FindName('BtnGDICopyValue').IsEnabled = $false
$script:Window.FindName('BtnGDICopyRow').IsEnabled = $false
$script:Window.FindName('BtnGDICopyAll').IsEnabled = $false
$script:Window.FindName('TxtGDIFilter').IsEnabled = $false
$script:Window.FindName('BtnGDIFilter').IsEnabled = $false
$script:Window.FindName('BtnGDIFilterClear').IsEnabled = $false
$script:Window.FindName('TxtGDIFilter').Text = ''
$script:GDIAllData = $null
$script:Window.FindName('BtnGDIExportXlsx').IsEnabled = $false
$dg = $script:Window.FindName('DgGDIResults')
if ($dg) { $dg.ItemsSource = $null }
Show-Panel 'PanelGetDeviceInfo'
Hide-Notification
Write-VerboseLog 'Panel: Get Device Info' -Level Info
})
# Panel open: Get Discovered Apps
$script:Window.FindName('BtnOpGetDiscoveredApps').Add_Click({
$script:Window.FindName('TxtDAInputList').Text = ''
$script:Window.FindName('TxtDAKeyword').Text = ''
$script:Window.FindName('CmbDAInputType').SelectedIndex = 0
$script:Window.FindName('CmbDAOwnership').SelectedIndex = 0
$script:Window.FindName('TxtDAInputLabel').Text = 'USER UPNs - one per line'
foreach ($chk in @('ChkDAWindows','ChkDAAndroid','ChkDAiOS','ChkDAMacOS')) {
$script:Window.FindName($chk).IsChecked = $false
}
$script:Window.FindName('PnlDAProgress').Visibility = 'Collapsed'
$script:Window.FindName('PnlDAResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtDANoResults').Visibility = 'Collapsed'
$script:Window.FindName('BtnDACopyValue').IsEnabled = $false
$script:Window.FindName('BtnDACopyRow').IsEnabled = $false
$script:Window.FindName('BtnDACopyAll').IsEnabled = $false
$script:Window.FindName('TxtDAFilter').IsEnabled = $false
$script:Window.FindName('BtnDAFilter').IsEnabled = $false
$script:Window.FindName('BtnDAFilterClear').IsEnabled = $false
$script:Window.FindName('TxtDAFilter').Text = ''
$script:DAAllData = $null
$script:Window.FindName('BtnDAExportXlsx').IsEnabled = $false
$dg = $script:Window.FindName('DgDAResults')
if ($dg) { $dg.ItemsSource = $null }
Show-Panel 'PanelGetDiscoveredApps'
Hide-Notification
Write-VerboseLog 'Panel: Get Discovered Apps' -Level Info
})
$script:Window.FindName('CmbDAInputType').Add_SelectionChanged({
$sel = $script:Window.FindName('CmbDAInputType').SelectedItem.Content
$label = switch ($sel) {
'User UPNs (resolve owned devices)' { 'USER UPNs - one per line' }
'Device names' { 'DEVICE NAMES - one per line' }
'Device IDs (Entra)' { 'ENTRA DEVICE IDs - one per line' }
'Serial numbers' { 'SERIAL NUMBERS - one per line' }
'Groups (names or IDs)' { 'GROUP names or IDs - one per line' }
default { 'IDENTIFIERS - one per line' }
}
$script:Window.FindName('TxtDAInputLabel').Text = $label
})
# ── GET DISCOVERED APPS - Run ────────────────────────────────────────
$script:Window.FindName('BtnDARun').Add_Click({
Hide-Notification
$inputTxt = $script:Window.FindName('TxtDAInputList').Text
$inputTypeSel = $script:Window.FindName('CmbDAInputType').SelectedItem.Content
if ([string]::IsNullOrWhiteSpace($inputTxt)) {
Show-Notification 'Please enter at least one identifier.' -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
# Collect filters on UI thread
$platFilter = [System.Collections.Generic.List[string]]::new()
if ($script:Window.FindName('ChkDAWindows').IsChecked) { $platFilter.Add('Windows') }
if ($script:Window.FindName('ChkDAAndroid').IsChecked) { $platFilter.Add('Android') }
if ($script:Window.FindName('ChkDAiOS').IsChecked) { $platFilter.Add('iOS') }
if ($script:Window.FindName('ChkDAMacOS').IsChecked) { $platFilter.Add('macOS') }
$ownershipSel = $script:Window.FindName('CmbDAOwnership').SelectedItem.Content
$ownershipFilter = switch ($ownershipSel) {
'Company only' { 'Company' }
'Personal only' { 'Personal' }
default { 'All' }
}
$keywordTxt = $script:Window.FindName('TxtDAKeyword').Text
$script:DAParams = @{
InputTxt = $inputTxt
InputType = $inputTypeSel
PlatformFilter = $platFilter
OwnershipFilter = $ownershipFilter
Keyword = $keywordTxt
}
# Reset UI
$script:Window.FindName('PnlDAResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtDANoResults').Visibility = 'Collapsed'
$script:Window.FindName('BtnDACopyValue').IsEnabled = $false
$script:Window.FindName('BtnDACopyRow').IsEnabled = $false
$script:Window.FindName('BtnDACopyAll').IsEnabled = $false
$script:Window.FindName('TxtDAFilter').Text = ''
$script:DAAllData = $null
$script:Window.FindName('TxtDAFilter').IsEnabled = $false
$script:Window.FindName('BtnDAFilter').IsEnabled = $false
$script:Window.FindName('BtnDAFilterClear').IsEnabled = $false
$script:Window.FindName('BtnDAExportXlsx').IsEnabled = $false
$dg = $script:Window.FindName('DgDAResults')
if ($dg) { $dg.ItemsSource = $null }
$script:Window.FindName('PnlDAProgress').Visibility = 'Visible'
$script:Window.FindName('TxtDAProgressMsg').Text = 'Resolving devices...'
$script:Window.FindName('TxtDAProgressDetail').Text = ''
$script:Window.FindName('BtnDARun').IsEnabled = $false
$script:Window.FindName('BtnDARunAll').IsEnabled = $false
$script:Window.FindName('BtnStop').Visibility = 'Visible'
$script:Window.FindName('BtnStop').IsEnabled = $true
$script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
$script:BgStopped = $false
Write-VerboseLog '--- Get Discovered Apps: starting ---' -Level Action
$script:DaQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
$script:DaStop = [hashtable]::Synchronized(@{ Stop = $false })
$script:DaRs = [runspacefactory]::CreateRunspace()
$script:DaRs.ApartmentState = 'STA'
$script:DaRs.ThreadOptions = 'ReuseThread'
$script:DaRs.Open()
$script:DaRs.SessionStateProxy.SetVariable('DaQueue', $script:DaQueue)
$script:DaRs.SessionStateProxy.SetVariable('DaStop', $script:DaStop)
$script:DaRs.SessionStateProxy.SetVariable('DaParams', $script:DAParams)
$script:DaRs.SessionStateProxy.SetVariable('LogFile', $script:LogFile)
$script:DaPs = [powershell]::Create()
$script:DaPs.Runspace = $script:DaRs
$null = $script:DaPs.AddScript({
function DLog {
param([string]$Msg, [string]$Level = 'Info')
$prefix = switch ($Level) {
"Success" { "$([char]0x2713) " } "Warning" { "[!] " } "Error" { "$([char]0x2717) " }
'Action' { '> ' } default { ' ' }
}
$line = "[$(Get-Date -Format 'HH:mm:ss')] $prefix$Msg"
$DaQueue.Enqueue(@{ Type='log'; Line=$line; Level=$Level })
if ($LogFile) {
try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } catch {}
}
}
function Get-GraphAll([string]$Uri) {
$items = [System.Collections.Generic.List[object]]::new()
$next = $Uri
do {
$r = Invoke-MgGraphRequest -Method GET -Uri $next
if ($r['value']) { foreach ($i in @($r['value'])) { $items.Add($i) } }
$next = if ($r.ContainsKey('@odata.nextLink')) { $r['@odata.nextLink'] } else { $null }
} while ($next)
return ,$items
}
$isGuidRx = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$'
function Normalize-OS([string]$os) {
switch -Wildcard ($os) {
'Windows*' { 'Windows' } 'Android*' { 'Android' }
'iPhone' { 'iOS' } 'iPadOS' { 'iOS' }
'iOS' { 'iOS' } 'MacMDM' { 'macOS' }
'Mac OS X' { 'macOS' } 'macOS' { 'macOS' }
default { $os }
}
}
try {
$p = $DaParams
$entries = @($p['InputTxt'] -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
$inputType = $p['InputType']
$platFilter = $p['PlatformFilter']
$ownerFilter = $p['OwnershipFilter']
$keyword = $p['Keyword']
$intuneCache = @{}
DLog "Processing $($entries.Count) input(s) - Type: $inputType" 'Action'
# ── STEP 1: Resolve inputs to Intune device records ──
$deviceRecords = [System.Collections.Generic.List[hashtable]]::new()
$seenIntuneIds = [System.Collections.Generic.HashSet[string]]::new()
$idx = 0
foreach ($entry in $entries) {
if ($DaStop['Stop']) { DLog 'Stop requested - halting.' 'Warning'; break }
$idx++
$DaQueue.Enqueue(@{ Type='progress'; Msg="Step 1/3: Resolving input $idx of $($entries.Count)..."; Detail=$entry })
DLog "Resolving: $entry" 'Info'
switch ($inputType) {
'User UPNs (resolve owned devices)' {
try {
$enc = [Uri]::EscapeDataString($entry)
$uDevs = Get-GraphAll "https://graph.microsoft.com/v1.0/users/$enc/ownedDevices?`$select=deviceId,displayName`&`$top=999"
DLog " User owns $($uDevs.Count) device(s)" 'Info'
foreach ($ud in $uDevs) {
$azDevId = [string]$ud['deviceId']
if ([string]::IsNullOrWhiteSpace($azDevId)) { continue }
try {
$encDev = [Uri]::EscapeDataString($azDevId)
$iResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$encDev'`&`$select=id,deviceName,operatingSystem,osVersion,managedDeviceOwnerType,azureADDeviceId,userPrincipalName`&`$top=1"
if ($iResp['value'] -and $iResp['value'].Count -gt 0) {
$iDev = $iResp['value'][0]
$iId = [string]$iDev['id']
if ($seenIntuneIds.Add($iId)) {
$deviceRecords.Add(@{
IntuneId = $iId
DeviceName = [string]$iDev['deviceName']
EntraDeviceId = $azDevId
OS = [string]$iDev['operatingSystem']
OSVersion = [string]$iDev['osVersion']
Ownership = [string]$iDev['managedDeviceOwnerType']
UPN = [string]$iDev['userPrincipalName']
})
}
}
} catch {}
}
} catch { DLog " User resolve failed: $($_.Exception.Message)" 'Warning' }
}
'Device names' {
try {
$safe = $entry -replace "'","''"
$dResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safe'`&`$select=deviceId,displayName`&`$top=5"
foreach ($d in @($dResp['value'])) {
$azDevId = [string]$d['deviceId']
if ([string]::IsNullOrWhiteSpace($azDevId)) { continue }
$encDev = [Uri]::EscapeDataString($azDevId)
try {
$iResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$encDev'`&`$select=id,deviceName,operatingSystem,osVersion,managedDeviceOwnerType,azureADDeviceId,userPrincipalName`&`$top=1"
if ($iResp['value'] -and $iResp['value'].Count -gt 0) {
$iDev = $iResp['value'][0]
$iId = [string]$iDev['id']
if ($seenIntuneIds.Add($iId)) {
$deviceRecords.Add(@{
IntuneId = $iId
DeviceName = [string]$iDev['deviceName']
EntraDeviceId = $azDevId
OS = [string]$iDev['operatingSystem']
OSVersion = [string]$iDev['osVersion']
Ownership = [string]$iDev['managedDeviceOwnerType']
UPN = [string]$iDev['userPrincipalName']
})
}
}
} catch {}
}
} catch { DLog " Device name resolve failed: $($_.Exception.Message)" 'Warning' }
}
'Device IDs (Entra)' {
try {
$encDev = [Uri]::EscapeDataString($entry)
$iResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$encDev'`&`$select=id,deviceName,operatingSystem,osVersion,managedDeviceOwnerType,azureADDeviceId,userPrincipalName`&`$top=1"
if ($iResp['value'] -and $iResp['value'].Count -gt 0) {
$iDev = $iResp['value'][0]
$iId = [string]$iDev['id']
if ($seenIntuneIds.Add($iId)) {
$deviceRecords.Add(@{
IntuneId = $iId
DeviceName = [string]$iDev['deviceName']
EntraDeviceId = [string]$iDev['azureADDeviceId']
OS = [string]$iDev['operatingSystem']
OSVersion = [string]$iDev['osVersion']
Ownership = [string]$iDev['managedDeviceOwnerType']
UPN = [string]$iDev['userPrincipalName']
})
}
} else { DLog " No Intune record for Entra ID: $entry" 'Warning' }
} catch { DLog " Entra ID resolve failed: $($_.Exception.Message)" 'Warning' }
}
'Serial numbers' {
try {
$encSer = [Uri]::EscapeDataString($entry)
$iResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=serialNumber eq '$encSer'`&`$select=id,deviceName,operatingSystem,osVersion,managedDeviceOwnerType,azureADDeviceId,userPrincipalName`&`$top=1"
if ($iResp['value'] -and $iResp['value'].Count -gt 0) {
$iDev = $iResp['value'][0]
$iId = [string]$iDev['id']
if ($seenIntuneIds.Add($iId)) {
$deviceRecords.Add(@{
IntuneId = $iId
DeviceName = [string]$iDev['deviceName']
EntraDeviceId = [string]$iDev['azureADDeviceId']
OS = [string]$iDev['operatingSystem']
OSVersion = [string]$iDev['osVersion']
Ownership = [string]$iDev['managedDeviceOwnerType']
UPN = [string]$iDev['userPrincipalName']
})
}
} else { DLog " No Intune record for serial: $entry" 'Warning' }
} catch { DLog " Serial resolve failed: $($_.Exception.Message)" 'Warning' }
}
'Groups (names or IDs)' {
try {
$groupId = $null
if ($entry -match $isGuidRx) {
$groupId = $entry
} else {
$safe = $entry -replace "'","''"
$gr = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe'`&`$select=id`&`$top=1"
if ($gr['value'] -and $gr['value'].Count -gt 0) {
$groupId = [string]$gr['value'][0]['id']
}
}
if (-not $groupId) { DLog " Group not found: $entry" 'Warning'; continue }
DLog " Scanning group members (transitive)..." 'Info'
$members = Get-GraphAll "https://graph.microsoft.com/v1.0/groups/$groupId/transitiveMembers?`$select=id&`$top=999"
DLog " $($members.Count) member(s) found" 'Info'
foreach ($mem in $members) {
if ($DaStop['Stop']) { break }
$odataType = [string]$mem['@odata.type']
if ($odataType -eq '#microsoft.graph.device') {
# Direct device member - resolve via Entra deviceId
$devObjId = [string]$mem['id']
try {
$entraDevRaw = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices/$devObjId`?`$select=deviceId"
$azDevId = [string]$entraDevRaw['deviceId']
if ([string]::IsNullOrWhiteSpace($azDevId)) { continue }
$encDev = [Uri]::EscapeDataString($azDevId)
$iResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$encDev'&`$select=id,deviceName,operatingSystem,osVersion,managedDeviceOwnerType,azureADDeviceId,userPrincipalName&`$top=1"
if ($iResp['value'] -and $iResp['value'].Count -gt 0) {
$iDev = $iResp['value'][0]
$iId = [string]$iDev['id']
if ($seenIntuneIds.Add($iId)) {
$deviceRecords.Add(@{
IntuneId = $iId
DeviceName = [string]$iDev['deviceName']
EntraDeviceId = $azDevId
OS = [string]$iDev['operatingSystem']
OSVersion = [string]$iDev['osVersion']
Ownership = [string]$iDev['managedDeviceOwnerType']
UPN = [string]$iDev['userPrincipalName']
})
}
}
} catch {}
} elseif ($odataType -eq '#microsoft.graph.user') {
# User member - resolve their owned devices
$uid = [string]$mem['id']
try {
$uDevs = Get-GraphAll "https://graph.microsoft.com/v1.0/users/$uid/ownedDevices?`$select=deviceId,displayName&`$top=999"
DLog " User $uid owns $($uDevs.Count) device(s)" 'Info'
foreach ($ud in $uDevs) {
if ($DaStop['Stop']) { break }
$azDevId = [string]$ud['deviceId']
if ([string]::IsNullOrWhiteSpace($azDevId)) { continue }
try {
$encDev = [Uri]::EscapeDataString($azDevId)
$iResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$encDev'&`$select=id,deviceName,operatingSystem,osVersion,managedDeviceOwnerType,azureADDeviceId,userPrincipalName&`$top=1"
if ($iResp['value'] -and $iResp['value'].Count -gt 0) {
$iDev = $iResp['value'][0]
$iId = [string]$iDev['id']
if ($seenIntuneIds.Add($iId)) {
$deviceRecords.Add(@{
IntuneId = $iId
DeviceName = [string]$iDev['deviceName']
EntraDeviceId = $azDevId
OS = [string]$iDev['operatingSystem']
OSVersion = [string]$iDev['osVersion']
Ownership = [string]$iDev['managedDeviceOwnerType']
UPN = [string]$iDev['userPrincipalName']
})
}
}
} catch {}
}
} catch { DLog " User device resolve failed for $uid : $($_.Exception.Message)" 'Warning' }
}
}
} catch { DLog " Group resolve failed: $($_.Exception.Message)" 'Warning' }
}
}
}
DLog "Step 1 complete: $($deviceRecords.Count) unique Intune device(s) resolved" 'Info'
if ($deviceRecords.Count -eq 0) {
$DaQueue.Enqueue(@{ Type='done'; Rows=@(); Error='No devices resolved from the provided input.' })
return
}
# ── STEP 2: Apply platform and ownership filters ──
$DaQueue.Enqueue(@{ Type='progress'; Msg="Step 2/3: Applying filters..."; Detail='' })
$filtered = [System.Collections.Generic.List[hashtable]]::new()
foreach ($rec in $deviceRecords) {
$osNorm = Normalize-OS $rec['OS']
if ($platFilter.Count -gt 0 -and $osNorm -notin $platFilter) { continue }
if ($ownerFilter -ne 'All' -and $rec['Ownership'] -ne $ownerFilter) { continue }
$filtered.Add($rec)
}
DLog "Step 2 complete: $($filtered.Count) device(s) after filtering (from $($deviceRecords.Count))" 'Info'
if ($filtered.Count -eq 0) {
$DaQueue.Enqueue(@{ Type='done'; Rows=@(); Error='No devices matched the platform/ownership filters.' })
return
}
# ── STEP 3: Fetch discovered apps per device ──
$allRows = [System.Collections.Generic.List[PSCustomObject]]::new()
$devIdx = 0
foreach ($rec in $filtered) {
if ($DaStop['Stop']) { DLog 'Stop requested - halting.' 'Warning'; break }
$devIdx++
$DaQueue.Enqueue(@{ Type='progress'; Msg="Step 3/3: Fetching apps for device $devIdx of $($filtered.Count)..."; Detail=$rec['DeviceName'] })
DLog "[$devIdx] Fetching apps for: $($rec['DeviceName'])" 'Info'
try {
$appsUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($rec['IntuneId'])/detectedApps?`$select=displayName,version,platform,publisher"
$apps = Get-GraphAll $appsUri
DLog " $($apps.Count) app(s) found" 'Info'
$collectTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
foreach ($app in $apps) {
$appName = [string]$app['displayName']
# Apply keyword filter
if (-not [string]::IsNullOrWhiteSpace($keyword)) {
if ($appName -notlike "*$keyword*") { continue }
}
$allRows.Add([PSCustomObject]@{
DeviceName = [string]$rec['DeviceName']
EntraDeviceId = [string]$rec['EntraDeviceId']
PrimaryUserUPN = [string]$rec['UPN']
OSVersion = [string]$rec['OSVersion']
DiscoveredApp = $appName
AppVersion = [string]$app['version']
AppPublisher = [string]$app['publisher']
CollectedAt = $collectTime
})
}
} catch {
DLog " App fetch failed for $($rec['DeviceName']): $($_.Exception.Message)" 'Warning'
}
}
DLog "Completed: $($allRows.Count) app record(s) across $($filtered.Count) device(s)." 'Success'
$DaQueue.Enqueue(@{ Type='done'; Rows=$allRows; Error=$null })
} catch {
$DaQueue.Enqueue(@{ Type='done'; Rows=@(); Error=$_.Exception.Message })
}
})
$script:DaHandle = $script:DaPs.BeginInvoke()
# Timer to drain DaQueue on the UI thread
$script:DaTimer = [System.Windows.Threading.DispatcherTimer]::new()
$script:DaTimer.Interval = [TimeSpan]::FromMilliseconds(300)
$script:DaTimer.Add_Tick({
$entry = $null
while ($script:DaQueue.TryDequeue([ref]$entry)) {
switch ($entry['Type']) {
'log' {
$lvl = $entry['Level']
$col = switch ($lvl) {
'Success' { $DevConfig.LogColorSuccess }
'Warning' { $DevConfig.LogColorWarning }
'Error' { $DevConfig.LogColorError }
'Action' { $DevConfig.LogColorAction }
default { $DevConfig.LogColorInfo }
}
try {
$para = [System.Windows.Documents.Paragraph]::new()
$run = [System.Windows.Documents.Run]::new($entry['Line'])
$run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString($col))
$para.Margin = [System.Windows.Thickness]::new(0)
$para.Inlines.Add($run)
$script:RtbLog.Document.Blocks.Add($para)
$script:RtbLog.ScrollToEnd()
} catch {}
}
'progress' {
$script:Window.FindName('PnlDAProgress').Visibility = 'Visible'
$script:Window.FindName('TxtDAProgressMsg').Text = $entry['Msg']
$script:Window.FindName('TxtDAProgressDetail').Text = $entry['Detail']
Update-StatusBar -Text "Get Discovered Apps: $($entry['Msg'])"
}
'done' {
$script:DaTimer.Stop()
try { $script:DaPs.EndInvoke($script:DaHandle) } catch {}
try { $script:DaRs.Close() } catch {}
try { $script:DaPs.Dispose() } catch {}
$script:DaPs = $null; $script:DaRs = $null
$script:Window.FindName('BtnStop').Visibility = 'Collapsed'
$script:Window.FindName('BtnStop').IsEnabled = $true
$script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
$script:Window.FindName('BtnDARun').IsEnabled = $true
$script:Window.FindName('BtnDARunAll').IsEnabled = $true
$script:Window.FindName('PnlDAProgress').Visibility = 'Collapsed'
$errMsg = $entry['Error']
if ($errMsg) {
Write-VerboseLog "Get Discovered Apps error: $errMsg" -Level Error
Show-Notification "Error: $errMsg" -BgColor '#F8D7DA' -FgColor '#721C24'
Update-StatusBar -Text 'Connected'
return
}
$rows = @($entry['Rows'])
if ($rows.Count -eq 0) {
Write-VerboseLog 'Get Discovered Apps: no results.' -Level Warning
$script:Window.FindName('TxtDANoResults').Visibility = 'Visible'
Show-Notification 'No discovered apps matched the filters.' -BgColor '#FFF3CD' -FgColor '#7A4800'
Update-StatusBar -Text 'Connected'
return
}
$dg = $script:Window.FindName('DgDAResults')
$dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($rows)
$script:Window.FindName('TxtDACount').Text = "$($rows.Count) app record(s) found"
$script:Window.FindName('PnlDAResults').Visibility = 'Visible'
$script:Window.FindName('BtnDACopyAll').IsEnabled = $true
$script:Window.FindName('TxtDAFilter').IsEnabled = $true
$script:Window.FindName('BtnDAFilter').IsEnabled = $true
$script:Window.FindName('BtnDAFilterClear').IsEnabled = $true
$script:Window.FindName('BtnDAExportXlsx').IsEnabled = $true
Write-VerboseLog "Get Discovered Apps: $($rows.Count) record(s) loaded." -Level Success
Show-Notification "$($rows.Count) app record(s) ready." -BgColor '#D4EDDA' -FgColor '#155724'
Update-StatusBar -Text 'Connected'
}
}
}
})
$script:DaTimer.Start()
})
# -- BtnDARunAll: Get Discovered Apps from All Devices --
$script:Window.FindName('BtnDARunAll').Add_Click({
Hide-Notification
# Collect filters on UI thread (no input list or keyword needed)
$platFilter = [System.Collections.Generic.List[string]]::new()
if ($script:Window.FindName('ChkDAWindows').IsChecked) { $platFilter.Add('Windows') }
if ($script:Window.FindName('ChkDAAndroid').IsChecked) { $platFilter.Add('Android') }
if ($script:Window.FindName('ChkDAiOS').IsChecked) { $platFilter.Add('iOS') }
if ($script:Window.FindName('ChkDAMacOS').IsChecked) { $platFilter.Add('macOS') }
$ownershipSel = $script:Window.FindName('CmbDAOwnership').SelectedItem.Content
$ownershipFilter = switch ($ownershipSel) {
'Company only' { 'Company' }
'Personal only' { 'Personal' }
default { 'All' }
}
$script:DAParams = @{
AllDevices = $true
PlatformFilter = $platFilter
OwnershipFilter = $ownershipFilter
}
# Reset UI
$script:Window.FindName('PnlDAResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtDANoResults').Visibility = 'Collapsed'
$script:Window.FindName('BtnDACopyValue').IsEnabled = $false
$script:Window.FindName('BtnDACopyRow').IsEnabled = $false
$script:Window.FindName('BtnDACopyAll').IsEnabled = $false
$script:Window.FindName('TxtDAFilter').IsEnabled = $false
$script:Window.FindName('BtnDAFilter').IsEnabled = $false
$script:Window.FindName('BtnDAFilterClear').IsEnabled = $false
$script:Window.FindName('TxtDAFilter').Text = ''
$script:DAAllData = $null
$script:Window.FindName('BtnDAExportXlsx').IsEnabled = $false
$dg = $script:Window.FindName('DgDAResults')
if ($dg) { $dg.ItemsSource = $null }
$script:Window.FindName('PnlDAProgress').Visibility = 'Visible'
$script:Window.FindName('TxtDAProgressMsg').Text = 'Fetching all managed devices...'
$script:Window.FindName('TxtDAProgressDetail').Text = ''
$script:Window.FindName('BtnDARun').IsEnabled = $false
$script:Window.FindName('BtnDARunAll').IsEnabled = $false
$script:Window.FindName('BtnStop').Visibility = 'Visible'
$script:Window.FindName('BtnStop').IsEnabled = $true
$script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
$script:BgStopped = $false
Write-VerboseLog '--- Get Discovered Apps (All Devices): starting ---' -Level Action
$script:DaQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
$script:DaStop = [hashtable]::Synchronized(@{ Stop = $false })
$script:DaRs = [runspacefactory]::CreateRunspace()
$script:DaRs.ApartmentState = 'STA'
$script:DaRs.ThreadOptions = 'ReuseThread'
$script:DaRs.Open()
$script:DaRs.SessionStateProxy.SetVariable('DaQueue', $script:DaQueue)
$script:DaRs.SessionStateProxy.SetVariable('DaStop', $script:DaStop)
$script:DaRs.SessionStateProxy.SetVariable('DaParams', $script:DAParams)
$script:DaRs.SessionStateProxy.SetVariable('LogFile', $script:LogFile)
$script:DaPs = [powershell]::Create()
$script:DaPs.Runspace = $script:DaRs
$null = $script:DaPs.AddScript({
function DLog {
param([string]$Msg, [string]$Level = 'Info')
$prefix = switch ($Level) {
"Success" { "$([char]0x2713) " } "Warning" { "[!] " } "Error" { "$([char]0x2717) " }
'Action' { '> ' } default { ' ' }
}
$line = "[$(Get-Date -Format 'HH:mm:ss')] $prefix$Msg"
$DaQueue.Enqueue(@{ Type='log'; Line=$line; Level=$Level })
if ($LogFile) {
try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } catch {}
}
}
function Get-GraphAll([string]$Uri) {
$items = [System.Collections.Generic.List[object]]::new()
$next = $Uri
do {
$r = Invoke-MgGraphRequest -Method GET -Uri $next
if ($r['value']) { foreach ($i in @($r['value'])) { $items.Add($i) } }
$next = if ($r.ContainsKey('@odata.nextLink')) { $r['@odata.nextLink'] } else { $null }
} while ($next)
return ,$items
}
function Normalize-OS([string]$os) {
switch -Wildcard ($os) {
'Windows*' { 'Windows' } 'Android*' { 'Android' }
'iPhone' { 'iOS' } 'iPadOS' { 'iOS' }
'iOS' { 'iOS' } 'MacMDM' { 'macOS' }
'Mac OS X' { 'macOS' } 'macOS' { 'macOS' }
default { $os }
}
}
try {
$p = $DaParams
$platFilter = $p['PlatformFilter']
$ownerFilter = $p['OwnershipFilter']
DLog "Fetching all managed devices from Intune..." 'Action'
$DaQueue.Enqueue(@{ Type='progress'; Msg="Step 1/3: Fetching all managed devices..."; Detail='' })
$allDevices = Get-GraphAll "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$select=id,deviceName,operatingSystem,osVersion,managedDeviceOwnerType,azureADDeviceId,userPrincipalName&`$top=999"
DLog "Step 1 complete: $($allDevices.Count) managed device(s) retrieved" 'Info'
if ($allDevices.Count -eq 0) {
$DaQueue.Enqueue(@{ Type='done'; Rows=@(); Error='No managed devices found in Intune.' })
return
}
$deviceRecords = [System.Collections.Generic.List[hashtable]]::new()
foreach ($iDev in $allDevices) {
$deviceRecords.Add(@{
IntuneId = [string]$iDev['id']
DeviceName = [string]$iDev['deviceName']
EntraDeviceId = [string]$iDev['azureADDeviceId']
OS = [string]$iDev['operatingSystem']
OSVersion = [string]$iDev['osVersion']
Ownership = [string]$iDev['managedDeviceOwnerType']
UPN = [string]$iDev['userPrincipalName']
})
}
$DaQueue.Enqueue(@{ Type='progress'; Msg="Step 2/3: Applying filters..."; Detail='' })
$filtered = [System.Collections.Generic.List[hashtable]]::new()
foreach ($rec in $deviceRecords) {
$osNorm = Normalize-OS $rec['OS']
if ($platFilter.Count -gt 0 -and $osNorm -notin $platFilter) { continue }
if ($ownerFilter -ne 'All' -and $rec['Ownership'] -ne $ownerFilter) { continue }
$filtered.Add($rec)
}
DLog "Step 2 complete: $($filtered.Count) device(s) after filtering (from $($deviceRecords.Count))" 'Info'
if ($filtered.Count -eq 0) {
$DaQueue.Enqueue(@{ Type='done'; Rows=@(); Error='No devices matched the platform/ownership filters.' })
return
}
$allRows = [System.Collections.Generic.List[PSCustomObject]]::new()
$devIdx = 0
foreach ($rec in $filtered) {
if ($DaStop['Stop']) { DLog 'Stop requested - halting.' 'Warning'; break }
$devIdx++
$DaQueue.Enqueue(@{ Type='progress'; Msg="Step 3/3: Fetching apps for device $devIdx of $($filtered.Count)..."; Detail=$rec['DeviceName'] })
DLog "[$devIdx] Fetching apps for: $($rec['DeviceName'])" 'Info'
try {
$appsUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($rec['IntuneId'])/detectedApps?`$select=displayName,version,platform,publisher"
$apps = Get-GraphAll $appsUri
DLog " $($apps.Count) app(s) found" 'Info'
$collectTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
foreach ($app in $apps) {
$allRows.Add([PSCustomObject]@{
DeviceName = [string]$rec['DeviceName']
EntraDeviceId = [string]$rec['EntraDeviceId']
PrimaryUserUPN = [string]$rec['UPN']
OSVersion = [string]$rec['OSVersion']
DiscoveredApp = [string]$app['displayName']
AppVersion = [string]$app['version']
AppPublisher = [string]$app['publisher']
CollectedAt = $collectTime
})
}
} catch {
DLog " App fetch failed for $($rec['DeviceName']): $($_.Exception.Message)" 'Warning'
}
}
DLog "Completed: $($allRows.Count) app record(s) across $($filtered.Count) device(s)." 'Success'
$DaQueue.Enqueue(@{ Type='done'; Rows=$allRows; Error=$null })
} catch {
$DaQueue.Enqueue(@{ Type='done'; Rows=@(); Error=$_.Exception.Message })
}
})
$script:DaHandle = $script:DaPs.BeginInvoke()
# Timer to drain DaQueue on the UI thread
$script:DaTimer = [System.Windows.Threading.DispatcherTimer]::new()
$script:DaTimer.Interval = [TimeSpan]::FromMilliseconds(300)
$script:DaTimer.Add_Tick({
$entry = $null
while ($script:DaQueue.TryDequeue([ref]$entry)) {
switch ($entry['Type']) {
'log' {
$lvl = $entry['Level']
$col = switch ($lvl) {
'Success' { $DevConfig.LogColorSuccess }
'Warning' { $DevConfig.LogColorWarning }
'Error' { $DevConfig.LogColorError }
'Action' { $DevConfig.LogColorAction }
default { $DevConfig.LogColorInfo }
}
try {
$para = [System.Windows.Documents.Paragraph]::new()
$run = [System.Windows.Documents.Run]::new($entry['Line'])
$run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString($col))
$para.Margin = [System.Windows.Thickness]::new(0)
$para.Inlines.Add($run)
$script:RtbLog.Document.Blocks.Add($para)
$script:RtbLog.ScrollToEnd()
} catch {}
}
'progress' {
$script:Window.FindName('PnlDAProgress').Visibility = 'Visible'
$script:Window.FindName('TxtDAProgressMsg').Text = $entry['Msg']
$script:Window.FindName('TxtDAProgressDetail').Text = $entry['Detail']
Update-StatusBar -Text "Get Discovered Apps (All): $($entry['Msg'])"
}
'done' {
$script:DaTimer.Stop()
try { $script:DaPs.EndInvoke($script:DaHandle) } catch {}
try { $script:DaRs.Close() } catch {}
try { $script:DaPs.Dispose() } catch {}
$script:DaPs = $null; $script:DaRs = $null
$script:Window.FindName('BtnStop').Visibility = 'Collapsed'
$script:Window.FindName('BtnStop').IsEnabled = $true
$script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
$script:Window.FindName('BtnDARun').IsEnabled = $true
$script:Window.FindName('BtnDARunAll').IsEnabled = $true
$script:Window.FindName('PnlDAProgress').Visibility = 'Collapsed'
$errMsg = $entry['Error']
if ($errMsg) {
Write-VerboseLog "Get Discovered Apps (All) error: $errMsg" -Level Error
Show-Notification "Error: $errMsg" -BgColor '#F8D7DA' -FgColor '#721C24'
Update-StatusBar -Text 'Connected'
return
}
$rows = @($entry['Rows'])
if ($rows.Count -eq 0) {
Write-VerboseLog 'Get Discovered Apps (All): no results.' -Level Warning
$script:Window.FindName('TxtDANoResults').Visibility = 'Visible'
Show-Notification 'No discovered apps matched the filters.' -BgColor '#FFF3CD' -FgColor '#7A4800'
Update-StatusBar -Text 'Connected'
return
}
$dg = $script:Window.FindName('DgDAResults')
$dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($rows)
$script:Window.FindName('TxtDACount').Text = "$($rows.Count) app record(s) found (all devices)"
$script:Window.FindName('PnlDAResults').Visibility = 'Visible'
$script:Window.FindName('BtnDACopyAll').IsEnabled = $true
$script:Window.FindName('TxtDAFilter').IsEnabled = $true
$script:Window.FindName('BtnDAFilter').IsEnabled = $true
$script:Window.FindName('BtnDAFilterClear').IsEnabled = $true
$script:Window.FindName('BtnDAExportXlsx').IsEnabled = $true
Write-VerboseLog "Get Discovered Apps (All): $($rows.Count) record(s) loaded." -Level Success
Show-Notification "$($rows.Count) app record(s) ready (all devices)." -BgColor '#D4EDDA' -FgColor '#155724'
Update-StatusBar -Text 'Connected'
}
}
}
})
$script:DaTimer.Start()
})
# ── DgDAResults: cell selection ──
$script:Window.FindName('DgDAResults').Add_SelectedCellsChanged({
$dg = $script:Window.FindName('DgDAResults')
$selCells = $dg.SelectedCells.Count
$script:Window.FindName('BtnDACopyValue').IsEnabled = ($selCells -ge 1)
$script:Window.FindName('BtnDACopyRow').IsEnabled = ($selCells -gt 0)
})
# ── BtnDACopyValue ──
# ── BtnDAFilter ──
$script:Window.FindName('BtnDAFilter').Add_Click({
$dg = $script:Window.FindName('DgDAResults')
$keyword = $script:Window.FindName('TxtDAFilter').Text.Trim()
if ($null -eq $script:DAAllData) {
$script:DAAllData = @($dg.ItemsSource)
}
if ($keyword -eq '') {
$dg.ItemsSource = $script:DAAllData
Show-Notification "Filter cleared - $($script:DAAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$filtered = @($script:DAAllData | Where-Object {
$row = $_
$match = $false
foreach ($prop in $row.PSObject.Properties) {
if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
}
$match
})
# ── BtnDAFilterClear ──
$script:Window.FindName('BtnDAFilterClear').Add_Click({
$script:Window.FindName('TxtDAFilter').Text = ''
$dg = $script:Window.FindName('DgDAResults')
if ($null -ne $script:DAAllData) {
$dg.ItemsSource = $script:DAAllData
Show-Notification "Filter cleared - $($script:DAAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
}
})
$dg.ItemsSource = $filtered
Show-Notification "$($filtered.Count) of $($script:DAAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
}
Write-VerboseLog "DA: Filter applied - keyword='$keyword'" -Level Info
})
$script:Window.FindName('BtnDACopyValue').Add_Click({
$dg = $script:Window.FindName('DgDAResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$vals = [System.Collections.Generic.List[string]]::new()
foreach ($cellInfo in @($dg.SelectedCells)) {
$col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
}
[System.Windows.Clipboard]::SetText($vals -join "`r`n")
Show-Notification '$($vals.Count) value(s) copied to clipboard.' -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog 'DA: Copied cell value to clipboard.' -Level Success
})
# ── BtnDACopyRow ──
$script:Window.FindName('BtnDACopyRow').Add_Click({
$dg = $script:Window.FindName('DgDAResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$seen = [System.Collections.Generic.HashSet[object]]::new()
$rowObjs = [System.Collections.Generic.List[object]]::new()
foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rowObjs.Add($cell.Item) } }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$txtLines = [System.Collections.Generic.List[string]]::new()
$txtLines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
foreach ($row in $rowObjs) {
$vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
$txtLines.Add($vals -join "`t")
}
[System.Windows.Clipboard]::SetText($txtLines -join "`n")
Show-Notification "$($rowObjs.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "DA: Copied $($rowObjs.Count) row(s) to clipboard." -Level Success
# Highlight full rows
$dg.UnselectAllCells()
foreach ($r in $rowObjs) { $null = $dg.SelectedItems.Add($r) }
})
# ── BtnDACopyAll ──
$script:Window.FindName('BtnDACopyAll').Add_Click({
$dg = $script:Window.FindName('DgDAResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) { return }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$txtLines = [System.Collections.Generic.List[string]]::new()
$txtLines.Add(($colDefs | ForEach-Object { $_.H }) -join "`t")
foreach ($row in $allItems) {
$vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
$txtLines.Add($vals -join "`t")
}
[System.Windows.Clipboard]::SetText($txtLines -join "`n")
Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "DA: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})
# ── BtnDAExportXlsx ──
$script:Window.FindName('BtnDAExportXlsx').Add_Click({
$dg = $script:Window.FindName('DgDAResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) {
Show-Notification 'No results to export.' -BgColor '#FFF3CD' -FgColor '#856404'
return
}
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$dlg = [Microsoft.Win32.SaveFileDialog]::new()
$dlg.Title = 'Export Discovered Apps Results'
$dlg.Filter = 'Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*'
$dlg.FileName = "DiscoveredApps_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
$dlg.InitialDirectory = [Environment]::GetFolderPath('Desktop')
if ($dlg.ShowDialog() -ne $true) { return }
$outPath = $dlg.FileName
try {
$folder = Split-Path $outPath -Parent
if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
$exportRows = $allItems | ForEach-Object {
$rowObj = $_; $ht = [ordered]@{}
foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$rowObj.($cd['P']) }
[PSCustomObject]$ht
}
if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
Import-Module ImportExcel -ErrorAction SilentlyContinue
$exportRows | Export-Excel -Path $outPath -WorksheetName 'DiscoveredApps' -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium6
Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
Show-Notification "ImportExcel not found - saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
}
Write-VerboseLog "DA: Exported $($allItems.Count) row(s) to: $outPath" -Level Success
} catch {
Write-VerboseLog "DA Export failed: $($_.Exception.Message)" -Level Error
Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
}
})
$script:Window.FindName('BtnOpCompareGroups').Add_Click({
$script:Window.FindName('PnlCGResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtCGNoResults').Visibility = 'Collapsed'
$script:Window.FindName('PnlCGProgress').Visibility = 'Collapsed'
Show-Panel 'PanelCompareGroups'
Hide-Notification
Write-VerboseLog 'Panel: Compare Groups' -Level Info
})
# ── GET DEVICE INFO - Run ────────────────────────────────────────────
$script:Window.FindName('BtnGDIRun').Add_Click({
Hide-Notification
$inputTxt = $script:Window.FindName('TxtGDIInputList').Text
$inputTypeSel = $script:Window.FindName('CmbGDIInputType').SelectedItem.Content
if ([string]::IsNullOrWhiteSpace($inputTxt)) {
Show-Notification 'Please enter at least one identifier.' -BgColor '#F8D7DA' -FgColor '#721C24'
return
}
# Collect filters on UI thread
$platFilter = [System.Collections.Generic.List[string]]::new()
if ($script:Window.FindName('ChkGDIWindows').IsChecked) { $platFilter.Add('Windows') }
if ($script:Window.FindName('ChkGDIAndroid').IsChecked) { $platFilter.Add('Android') }
if ($script:Window.FindName('ChkGDIiOS').IsChecked) { $platFilter.Add('iOS') }
if ($script:Window.FindName('ChkGDIMacOS').IsChecked) { $platFilter.Add('macOS') }
$ownershipSel = $script:Window.FindName('CmbGDIOwnership').SelectedItem.Content
$ownershipFilter = switch ($ownershipSel) {
'Company only' { 'Company' }
'Personal only' { 'Personal' }
default { 'All' }
}
$script:GDIParams = @{
InputTxt = $inputTxt
InputType = $inputTypeSel
PlatformFilter = $platFilter
OwnershipFilter = $ownershipFilter
}
# Reset table state
$script:Window.FindName('PnlGDIResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtGDINoResults').Visibility = 'Collapsed'
$script:Window.FindName('BtnGDICopyValue').IsEnabled = $false
$script:Window.FindName('BtnGDICopyRow').IsEnabled = $false
$script:Window.FindName('BtnGDICopyAll').IsEnabled = $false
$script:Window.FindName('TxtGDIFilter').IsEnabled = $false
$script:Window.FindName('BtnGDIFilter').IsEnabled = $false
$script:Window.FindName('BtnGDIFilterClear').IsEnabled = $false
$script:Window.FindName('TxtGDIFilter').Text = ''
$script:GDIAllData = $null
$script:Window.FindName('BtnGDIExportXlsx').IsEnabled = $false
$dg = $script:Window.FindName('DgGDIResults')
if ($dg) { $dg.ItemsSource = $null }
$script:Window.FindName('PnlGDIProgress').Visibility = 'Visible'
$script:Window.FindName('TxtGDIProgressMsg').Text = 'Resolving devices...'
$script:Window.FindName('TxtGDIProgressDetail').Text = ''
$script:Window.FindName('BtnGDIRun').IsEnabled = $false
$script:Window.FindName('BtnGDIRunAll').IsEnabled = $false
$script:Window.FindName('BtnStop').Visibility = 'Visible'
$script:Window.FindName('BtnStop').IsEnabled = $true
$script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
$script:BgStopped = $false
Write-VerboseLog '--- Get Device Info: starting ---' -Level Action
# ── Dedicated runspace - all state in script scope so DispatcherTimer
# tick can access it reliably (local closure capture is unreliable in WPF handlers)
$script:GdiQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
$script:GdiStop = [hashtable]::Synchronized(@{ Stop = $false })
$script:GdiRs = [runspacefactory]::CreateRunspace()
$script:GdiRs.ApartmentState = 'STA'
$script:GdiRs.ThreadOptions = 'ReuseThread'
$script:GdiRs.Open()
$script:GdiRs.SessionStateProxy.SetVariable('GdiQueue', $script:GdiQueue)
$script:GdiRs.SessionStateProxy.SetVariable('GdiStop', $script:GdiStop)
$script:GdiRs.SessionStateProxy.SetVariable('GdiParams', $script:GDIParams)
$script:GdiRs.SessionStateProxy.SetVariable('LogFile', $script:LogFile)
$script:GdiPs = [powershell]::Create()
$script:GdiPs.Runspace = $script:GdiRs
$null = $script:GdiPs.AddScript({
# ── Helper: enqueue a log message ──
function GLog {
param([string]$Msg, [string]$Level = 'Info')
$prefix = switch ($Level) {
"Success" { "$([char]0x2713) " } "Warning" { "[!] " } "Error" { "$([char]0x2717) " }
'Action' { '> ' } default { ' ' }
}
$line = "[$(Get-Date -Format 'HH:mm:ss')] $prefix$Msg"
$GdiQueue.Enqueue(@{ Type='log'; Line=$line; Level=$Level })
if ($LogFile) {
try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } catch {}
}
}
# ── OS normalisation (Entra raw → friendly) ──
function Normalize-OS([string]$os) {
switch -Wildcard ($os) {
'Windows*' { 'Windows' } 'Android*' { 'Android' }
'iPhone' { 'iOS' } 'iPadOS' { 'iOS' }
'iOS' { 'iOS' } 'MacMDM' { 'macOS' }
'Mac OS X' { 'macOS' } 'macOS' { 'macOS' }
default { $os }
}
}
# ── Windows release lookup ──
$WinVerMap = @(
@{ V='10.0.26200'; R='25H2' }
@{ V='10.0.28000'; R='26H1' }
@{ V='10.0.26100'; R='24H2' }
@{ V='10.0.22631'; R='23H2' }
@{ V='10.0.22621'; R='22H2 (Win11)' }
@{ V='10.0.19045'; R='22H2 (Win10)' }
@{ V='10.0.19044'; R='21H2 (LTSC)' }
@{ V='10.0.17763'; R='1809 (LTSC)' }
)
function Get-WinRelease([string]$ver) {
if ([string]::IsNullOrWhiteSpace($ver)) { return $null }
$v = $ver.Trim()
$m = $WinVerMap | Where-Object { $_.V -eq $v } | Select-Object -First 1
if ($m) { return $m.R }
# Try major.minor.build prefix only
if ($v -match '^\d+\.\d+\.\d+') {
$pfx = ($Matches[0])
$m = $WinVerMap | Where-Object { $_.V -eq $pfx } | Select-Object -First 1
if ($m) { return $m.R }
}
return $null
}
# ── Device type from name suffix ──
$DevTypeMap = @(
@{ S='-D'; T='Desktop' }
@{ S='-L'; T='Laptop' }
@{ S='-V'; T='Virtual' }
@{ S='-K'; T='Kiosk' }
@{ S='-F'; T='Teams Meeting Room' }
@{ S='-A'; T='Shared Device' }
)
function Get-DevType([string]$name) {
if ([string]::IsNullOrWhiteSpace($name)) { return $null }
$u = $name.Trim().ToUpperInvariant()
foreach ($m in $DevTypeMap) {
if ($u.EndsWith($m.S.ToUpperInvariant())) { return $m.T }
}
return $null
}
# ── Activity range label ──
# Tries RoundtripKind first (ISO 8601/UTC), then InvariantCulture general parse
# as fallback - same two-style approach as Format-Date to handle all Graph date formats.
function Get-ActivityRange([string]$dateStr) {
if ([string]::IsNullOrWhiteSpace($dateStr)) { return $null }
$d = $null
foreach ($style in @(
[System.Globalization.DateTimeStyles]::RoundtripKind,
[System.Globalization.DateTimeStyles]::None
)) {
try {
$d = [datetime]::Parse($dateStr,
[System.Globalization.CultureInfo]::InvariantCulture, $style)
break
} catch {}
}
if ($null -eq $d) { return $null }
$days = [int][Math]::Floor(([datetime]::UtcNow - $d.ToUniversalTime()).TotalDays)
if ($days -le 7) { return '0-7 days' }
elseif ($days -le 14) { return '8-14 days' }
elseif ($days -le 30) { return '15-30 days' }
elseif ($days -le 60) { return '30-60 days' }
elseif ($days -le 90) { return '60-90 days' }
elseif ($days -le 180) { return '90-180 days' }
elseif ($days -le 365) { return '180-365 days' }
else { return 'Older than 1 yr' }
}
# ── Page through a Graph URI ──
function Get-GraphAll([string]$Uri, [hashtable]$Headers = @{}) {
$items = [System.Collections.Generic.List[object]]::new()
$next = $Uri
do {
$r = Invoke-MgGraphRequest -Method GET -Uri $next -Headers $Headers
if ($r['value']) { foreach ($i in @($r['value'])) { $items.Add($i) } }
$next = if ($r.ContainsKey('@odata.nextLink')) { $r['@odata.nextLink'] } else { $null }
} while ($next)
return ,$items
}
# ── Fetch full Entra device record ──
function Get-EntraDevice([string]$Uri) {
try {
return Invoke-MgGraphRequest -Method GET -Uri $Uri
} catch { return $null }
}
# ── Fetch Intune device by azureADDeviceId ──
function Get-IntuneByEntraId([string]$EntraId, [hashtable]$Cache) {
if ([string]::IsNullOrWhiteSpace($EntraId)) { return $null }
if ($Cache.ContainsKey($EntraId)) { return $Cache[$EntraId] }
try {
$enc = [Uri]::EscapeDataString($EntraId)
$resp = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$enc'`&`$select=id,deviceName,operatingSystem,osVersion,lastSyncDateTime,manufacturer,model,serialNumber,managedDeviceOwnerType,enrolledDateTime,azureADDeviceId,userPrincipalName,complianceState`&`$top=1"
$dev = if ($resp['value'] -and $resp['value'].Count -gt 0) { $resp['value'][0] } else { $null }
$Cache[$EntraId] = $dev
return $dev
} catch { $Cache[$EntraId] = $null; return $null }
}
# ── Fetch user info ──
function Get-UserInfo([string]$UserId) {
try {
return Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($UserId))?`$select=id,userPrincipalName,displayName,jobTitle,accountEnabled,city,country,officeLocation"
} catch { return $null }
}
# ── Format any date string to MM-dd-yyyy, tolerating multiple input formats ──
function Format-Date([string]$raw) {
if ([string]::IsNullOrWhiteSpace($raw)) { return $null }
# Try ISO 8601 / RoundtripKind first, then general parse as fallback
foreach ($style in @(
[System.Globalization.DateTimeStyles]::RoundtripKind,
[System.Globalization.DateTimeStyles]::None
)) {
try {
return ([datetime]::Parse($raw, [System.Globalization.CultureInfo]::InvariantCulture, $style)).ToString('MM-dd-yyyy')
} catch {}
}
return $raw # return raw if all parsing fails
}
# ── Build Autopilot lookup dictionary ──────────────────────────────────────
# deploymentProfileAssignmentStatus is a NESTED OBJECT containing deploymentProfileId.
# Profile name is resolved by: $ap['deploymentProfileAssignmentStatus']['deploymentProfileId']
# → fetch profile display name from windowsAutopilotDeploymentProfiles/{id}.
# Step 1: load all profiles into id->name map.
# Step 2: load all identities, extract profileId from nested status object.
function Build-AutopilotDictionary {
$dict = @{}
$profileMap = @{}
# ── Step 1: all deployment profiles ──
try {
$nextUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles?$select=id,displayName&$top=100'
do {
$r = Invoke-MgGraphRequest -Method GET -Uri $nextUri
foreach ($p in @($r['value'])) { $profileMap[[string]$p['id']] = [string]$p['displayName'] }
$nextUri = if ($r.ContainsKey('@odata.nextLink')) { $r['@odata.nextLink'] } else { $null }
} while ($nextUri)
GLog "Autopilot: $($profileMap.Count) profile(s) loaded" 'Info'
} catch {
GLog "Autopilot profiles failed: $($_.Exception.Message)" 'Warning'
}
# ── Step 2: all device identities ──
try {
GLog 'Autopilot: loading device identities...' 'Info'
$nextUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?$top=500'
$total = 0
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $nextUri
$page = @($resp['value'])
$total += $page.Count
foreach ($ap in $page) {
$aadId = [string]$ap['azureActiveDirectoryDeviceId']
if ([string]::IsNullOrWhiteSpace($aadId)) { continue }
# deploymentProfileAssignmentStatus is a nested object -
# the profileId lives inside it, not as a top-level field
$statusObj = $ap['deploymentProfileAssignmentStatus']
$statusStr = ''
$profileId = $null
if ($statusObj -is [System.Collections.IDictionary]) {
$profileId = [string]$statusObj['deploymentProfileId']
$statusStr = [string]$statusObj['value'] # e.g. 'assigned'
if (-not $statusStr) { $statusStr = [string]$statusObj['status'] }
} else {
$statusStr = [string]$statusObj # simple string fallback
}
# Resolve profile name from the profiles map
$profileName = if ($profileId -and $profileMap.ContainsKey($profileId)) {
$profileMap[$profileId]
} else { $null }
$dict[$aadId.ToLower()] = @{
GroupTag = [string]$ap['groupTag']
ProfileStatus = $statusStr
}
}
$nextUri = if ($resp.ContainsKey('@odata.nextLink')) { $resp['@odata.nextLink'] } else { $null }
} while ($nextUri)
GLog "Autopilot: $total record(s), $($dict.Count) indexed by Entra Device ID" 'Success'
} catch {
GLog "Autopilot identities failed: $($_.Exception.Message)" 'Warning'
}
return ,$dict
}
# ── Build a result row from Entra + Intune data ──
function Build-Row([hashtable]$EntraDev, [hashtable]$IntuneDev, [hashtable]$UserInfo, [string]$OwnerUPN = '', [hashtable]$AutopilotInfo = $null) {
$NoRec = 'No Intune Record'
$devName = [string]$EntraDev['displayName']
# Entra fields
$entraDevId = [string]$EntraDev['deviceId'] # the GUID used as azureADDeviceId
$entraObjId = [string]$EntraDev['id']
$entraOS = [string]$EntraDev['operatingSystem']
$entraOSVer = [string]$EntraDev['operatingSystemVersion']
$entraJoin = switch ([string]$EntraDev['trustType']) {
'AzureAd' { 'Entra Joined' }
'ServerAd' { 'Hybrid Joined' }
'Workplace' { 'Registered' }
default { [string]$EntraDev['trustType'] }
}
$entraOwner = [string]$EntraDev['deviceOwnership']
$entraEnabled= [string]$EntraDev['accountEnabled']
$entraSignIn = [string]$EntraDev['approximateLastSignInDateTime']
$entraSignInFmt = Format-Date $entraSignIn
$entraActivity = Get-ActivityRange $entraSignIn
# Intune fields
if ($IntuneDev) {
$intuneId = [string]$IntuneDev['id']
$intuneOS = [string]$IntuneDev['operatingSystem']
$intuneOSVer = [string]$IntuneDev['osVersion']
$intuneSync = [string]$IntuneDev['lastSyncDateTime']
$intuneSyncFmt = if ($intuneSync) { Format-Date $intuneSync } else { $NoRec }
$intuneActivity = Get-ActivityRange $intuneSync
$intuneOEM = [string]$IntuneDev['manufacturer']
$intuneModel = [string]$IntuneDev['model']
$intuneSerial = [string]$IntuneDev['serialNumber']
$intuneOwner = [string]$IntuneDev['managedDeviceOwnerType']
$intuneEnrolled = Format-Date ([string]$IntuneDev['enrolledDateTime'])
$intuneUPN = [string]$IntuneDev['userPrincipalName']
$intuneCompliance = [string]$IntuneDev['complianceState']
} else {
$intuneId = $NoRec
$intuneOS = $NoRec
$intuneOSVer = $NoRec
$intuneSyncFmt = $NoRec
$intuneActivity = $NoRec
$intuneOEM = $NoRec
$intuneModel = $NoRec
$intuneSerial = $NoRec
$intuneOwner = $NoRec
$intuneEnrolled = $NoRec
$intuneUPN = $NoRec
$intuneCompliance = $NoRec
}
# Autopilot fields (from dedicated lookup - $null if not Autopilot registered)
$apGroupTag = if ($AutopilotInfo) { [string]$AutopilotInfo['GroupTag'] } else { $NoRec }
$apStatus = if ($AutopilotInfo) { [string]$AutopilotInfo['ProfileStatus'] } else { $NoRec }
# Prefer Intune OS version for Windows release mapping
$osVerForMap = if ($intuneOSVer -and $intuneOSVer -ne $NoRec) { $intuneOSVer } else { $entraOSVer }
$winRelease = Get-WinRelease $osVerForMap
$devType = Get-DevType $devName
# User fields - UserInfo from Entra; fall back to OwnerUPN (Entra) or Intune UPN
$resolvedUPN = if ($UserInfo) { [string]$UserInfo['userPrincipalName'] }
elseif ($OwnerUPN) { $OwnerUPN }
elseif ($intuneUPN -and $intuneUPN -ne $NoRec) { $intuneUPN }
else { $null }
$uTitle = if ($UserInfo) { [string]$UserInfo['jobTitle'] } else { $null }
$uEnabled = if ($UserInfo) { [string]$UserInfo['accountEnabled'] } else { $null }
$uCity = if ($UserInfo) { [string]$UserInfo['city'] } else { $null }
$uCountry = if ($UserInfo) { [string]$UserInfo['country'] } else { $null }
return [PSCustomObject]@{
'Entra_DeviceName' = $devName
'DeviceType' = $devType
'Entra_DeviceID' = $entraDevId
'Entra_ObjectID' = $entraObjId
'Intune_DeviceID' = $intuneId
'Entra_JoinType' = $entraJoin
'Entra_Ownership' = $entraOwner
'Intune_Ownership' = $intuneOwner
'Entra_OSPlatform' = $entraOS
'Intune_OSPlatform' = $intuneOS
'Entra_OSVersion' = $entraOSVer
'Intune_OSVersion' = $intuneOSVer
'Windows_Release' = $winRelease
'Entra_DeviceEnabled' = $entraEnabled
'Entra_LastSignIn' = $entraSignInFmt
'Entra_ActivityRange' = $entraActivity
'Intune_LastCheckIn' = $intuneSyncFmt
'Intune_ActivityRange' = $intuneActivity
'Intune_OEM' = $intuneOEM
'Intune_Model' = $intuneModel
'Intune_SerialNumber' = $intuneSerial
'Intune_EnrolledDate' = $intuneEnrolled
'Intune_ComplianceState' = $intuneCompliance
'Entra_DeviceOwner' = $OwnerUPN
'Entra_PrimaryUserUPN' = $resolvedUPN
'Entra_UserAccountEnabled' = $uEnabled
'Entra_UserCountry' = $uCountry
'Entra_UserCity' = $uCity
'Entra_UserJobTitle' = $uTitle
'Autopilot_GroupTag' = $apGroupTag
'Autopilot_ProfileStatus' = $apStatus
}
}
# ══════════════════════════════════════════════════════════════════
# MAIN WORK
# ══════════════════════════════════════════════════════════════════
try {
Import-Module Microsoft.Graph.Authentication -ErrorAction SilentlyContinue
$params = $GdiParams
$platFilter = @($params['PlatformFilter'])
$ownerFilter = $params['OwnershipFilter']
$allDevicesMode = $params['AllDevices'] -eq $true
$intuneCache = @{} # azureADDeviceId -> Intune device hashtable
$userCache = @{} # userId / UPN -> user hashtable
# Build Autopilot lookup once upfront - keyed by azureActiveDirectoryDeviceId (lowercase)
$autopilotDict = Build-AutopilotDictionary
$entraIdSet = [System.Collections.Generic.HashSet[string]]::new() # deduplicate Entra Object IDs
$deviceObjIds = [System.Collections.Generic.List[string]]::new()
$isGuidRx = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
if ($allDevicesMode) {
# ── All Devices mode: fetch every Entra device object ──
GLog 'All Devices mode - fetching all Entra device objects...' 'Action'
GLog "Platform filter : $(if ($platFilter.Count -eq 0) { 'All' } else { $platFilter -join ', ' })" 'Info'
GLog "Ownership filter: $ownerFilter" 'Info'
$allEntra = Get-GraphAll 'https://graph.microsoft.com/v1.0/devices?$select=id&$top=999'
GLog "Retrieved $($allEntra.Count) Entra device object(s)" 'Info'
foreach ($d in $allEntra) {
$null = $deviceObjIds.Add([string]$d['id'])
}
} else {
# ── Input-based mode: resolve entries to Entra device Object IDs ──
$inputType = $params['InputType']
$entries = $params['InputTxt'] -split "`n" |
ForEach-Object { $_.Trim() } |
Where-Object { $_ -ne '' }
GLog "Input type : $inputType" 'Info'
GLog "Entries : $($entries.Count)" 'Info'
GLog "Platform filter : $(if ($platFilter.Count -eq 0) { 'All' } else { $platFilter -join ', ' })" 'Info'
GLog "Ownership filter: $ownerFilter" 'Info'
foreach ($entry in $entries) {
if ($GdiStop['Stop']) { GLog 'Stop requested.' 'Warning'; break }
switch -Wildcard ($inputType) {
'User UPNs*' {
GLog "Resolving user devices: $entry" 'Info'
try {
$uid = [Uri]::EscapeDataString($entry)
$devs = Get-GraphAll "https://graph.microsoft.com/v1.0/users/$uid/ownedDevices?`$select=id,displayName,deviceId,operatingSystem,operatingSystemVersion,deviceOwnership,trustType,accountEnabled,approximateLastSignInDateTime`&`$top=999"
GLog " $($devs.Count) owned device(s) found" 'Info'
foreach ($d in $devs) { $null = $deviceObjIds.Add([string]$d['id']) }
} catch {
GLog " Could not resolve user $entry : $($_.Exception.Message)" 'Warning'
}
}
'Device names' {
GLog "Resolving device by name: $entry" 'Info'
try {
$safe = $entry -replace "'","''"
$r = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safe'`&`$select=id`&`$top=5"
$found = @($r['value'])
if ($found.Count -eq 0) { GLog " Not found: $entry" 'Warning' }
else { foreach ($d in $found) { $null = $deviceObjIds.Add([string]$d['id']) } }
} catch {
GLog " Error resolving $entry : $($_.Exception.Message)" 'Warning'
}
}
'Device serial numbers' {
GLog "Resolving serial number: $entry" 'Info'
# Serial numbers only exist in Intune - search there first, get azureADDeviceId, then Entra
try {
$safe = $entry -replace "'","''"
$r = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=serialNumber eq '$safe'`&`$select=id,azureADDeviceId,deviceName`&`$top=5"
$found = @($r['value'])
if ($found.Count -eq 0) { GLog " Serial not found in Intune: $entry" 'Warning' }
else {
foreach ($md in $found) {
$aadId = [string]$md['azureADDeviceId']
if ($aadId) {
# Find Entra Object ID from azureADDeviceId (deviceId field)
$er = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/devices?`$filter=deviceId eq '$aadId'`&`$select=id`&`$top=1"
if ($er['value'] -and $er['value'].Count -gt 0) {
$null = $deviceObjIds.Add([string]$er['value'][0]['id'])
}
}
}
}
} catch {
GLog " Error resolving serial $entry : $($_.Exception.Message)" 'Warning'
}
}
'Entra Device Object IDs' {
if ($entry -match $isGuidRx) {
$null = $deviceObjIds.Add($entry)
} else {
GLog " Skipped (not a GUID): $entry" 'Warning'
}
}
'Intune Device IDs' {
GLog "Resolving Intune device ID: $entry" 'Info'
try {
$md = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$entry`?`$select=id,azureADDeviceId,deviceName"
$aadId = [string]$md['azureADDeviceId']
if ($aadId) {
$er = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/devices?`$filter=deviceId eq '$aadId'`&`$select=id`&`$top=1"
if ($er['value'] -and $er['value'].Count -gt 0) {
$null = $deviceObjIds.Add([string]$er['value'][0]['id'])
}
}
} catch {
GLog " Could not resolve Intune ID $entry : $($_.Exception.Message)" 'Warning'
}
}
'Groups*' {
GLog "Resolving group: $entry" 'Info'
# Resolve group ID
$groupId = $null
if ($entry -match $isGuidRx) {
$groupId = $entry
} else {
try {
$safe = $entry -replace "'","''"
$gr = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe'`&`$select=id`&`$top=1"
if ($gr['value'] -and $gr['value'].Count -gt 0) {
$groupId = [string]$gr['value'][0]['id']
}
} catch {}
}
if (-not $groupId) { GLog " Group not found: $entry" 'Warning'; continue }
# Get all transitive members (users + devices)
GLog " Scanning group members (transitive)..." 'Info'
# $select with @odata.type or user-subtype properties causes 400 on
# transitiveMembers (directoryObject base type). Use $select=id only;
# @odata.type is always returned automatically.
$members = Get-GraphAll "https://graph.microsoft.com/v1.0/groups/$groupId/transitiveMembers?`$select=id`&`$top=999"
GLog " $($members.Count) member(s) found" 'Info'
foreach ($mem in $members) {
if ($GdiStop['Stop']) { break }
$odataType = [string]$mem['@odata.type']
if ($odataType -eq '#microsoft.graph.device') {
$null = $deviceObjIds.Add([string]$mem['id'])
} elseif ($odataType -eq '#microsoft.graph.user') {
# Resolve user's owned devices
$uid = [string]$mem['id']
try {
$udevs = Get-GraphAll "https://graph.microsoft.com/v1.0/users/$uid/ownedDevices?`$select=id`&`$top=999"
foreach ($ud in $udevs) { $null = $deviceObjIds.Add([string]$ud['id']) }
} catch {}
}
}
}
}
}
} # end else (input-based mode)
GLog "Total device IDs to process: $($deviceObjIds.Count)" 'Info'
if ($deviceObjIds.Count -eq 0) {
.Enqueue(@{ Type='done'; Rows=@(); Error='No devices resolved from the provided input.' })
return
}
# ── Process each Entra device ──
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
$processed = 0
foreach ($objId in $deviceObjIds) {
if ($GdiStop['Stop']) { GLog 'Stop requested - halting.' 'Warning'; break }
if ($entraIdSet.Contains($objId)) { continue } # deduplicate
$null = $entraIdSet.Add($objId)
$processed++
$GdiQueue.Enqueue(@{ Type='progress'; Current=$processed; Total=$deviceObjIds.Count })
# Fetch full Entra device record
$entraRaw = $null
try {
$entraRaw = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/devices/$objId`?`$select=id,deviceId,displayName,operatingSystem,operatingSystemVersion,deviceOwnership,trustType,accountEnabled,approximateLastSignInDateTime,registrationDateTime"
} catch {
GLog " Could not fetch Entra device $objId : $($_.Exception.Message)" 'Warning'
continue
}
$devName = [string]$entraRaw['displayName']
$entraDevId = [string]$entraRaw['deviceId'] # azureADDeviceId in Intune
$entraOS = Normalize-OS ([string]$entraRaw['operatingSystem'])
GLog "[$processed] $devName [$entraOS]" 'Info'
# ── Platform filter ──
if ($platFilter.Count -gt 0 -and $entraOS -notin $platFilter) {
GLog " Skipped (platform $entraOS not in filter)" 'Info'
continue
}
# ── Ownership filter ──
$rawOwner = [string]$entraRaw['deviceOwnership']
if ($ownerFilter -ne 'All' -and $rawOwner -ne $ownerFilter) {
GLog " Skipped (ownership $rawOwner not in filter)" 'Info'
continue
}
# ── Intune lookup ──
$intuneDev = Get-IntuneByEntraId -EntraId $entraDevId -Cache $intuneCache
if ($intuneDev) {
GLog " Intune: $([string]$intuneDev['id'])" 'Success'
} else {
GLog " Intune: No Intune Record" 'Warning'
}
# ── Registered owner (user) ──
# No $select on registeredOwners - $select is not supported on navigation
# endpoints and causes userPrincipalName to be missing from the response.
$userInfo = $null
$ownerUPN = $null
try {
$ownerResp = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/devices/$objId/registeredOwners?`$top=5"
$ownerList = @($ownerResp['value'])
$firstUser = $ownerList | Where-Object {
$t = [string]$_['@odata.type']
$t -eq '#microsoft.graph.user' -or $t -eq 'microsoft.graph.user'
} | Select-Object -First 1
if ($firstUser) {
# userPrincipalName lives in AdditionalProperties for some SDK versions,
# or directly on the hashtable when using Invoke-MgGraphRequest
$ownerUPN = if ($firstUser.ContainsKey('userPrincipalName')) {
[string]$firstUser['userPrincipalName']
} elseif ($firstUser.ContainsKey('AdditionalProperties')) {
[string]$firstUser['AdditionalProperties']['userPrincipalName']
} else { $null }
$uid = [string]$firstUser['id']
# If UPN still empty, fetch it from the user endpoint directly
if ([string]::IsNullOrWhiteSpace($ownerUPN) -and $uid) {
try {
$upnResp = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/users/$uid`?`$select=userPrincipalName"
$ownerUPN = [string]$upnResp['userPrincipalName']
} catch {}
}
GLog " Owner: $ownerUPN" 'Info'
if ($userCache.ContainsKey($uid)) {
$userInfo = $userCache[$uid]
} else {
$userInfo = Get-UserInfo -UserId $uid
$userCache[$uid] = $userInfo
}
} else {
GLog " No registered owner found" 'Info'
}
} catch {
GLog " Owner fetch error: $($_.Exception.Message)" 'Warning'
}
# ── Autopilot lookup - dictionary built upfront, keyed by entraDevId lowercase ──
$autopilotInfo = $null
$entraOSNorm = Normalize-OS ([string]$entraRaw['operatingSystem'])
if ($entraOSNorm -eq 'Windows' -and $entraDevId) {
$apKey = $entraDevId.ToLower()
if ($autopilotDict.ContainsKey($apKey)) {
$autopilotInfo = $autopilotDict[$apKey]
GLog " Autopilot: Tag=$($autopilotInfo['GroupTag']) | Status=$($autopilotInfo['ProfileStatus'])" 'Info'
} else {
GLog ' Autopilot: not registered' 'Info'
}
}
# ── Build row ──
$row = Build-Row -EntraDev $entraRaw -IntuneDev $intuneDev -UserInfo $userInfo -OwnerUPN $ownerUPN -AutopilotInfo $autopilotInfo
$results.Add($row)
}
GLog "Processed $($results.Count) device(s)." 'Success'
$GdiQueue.Enqueue(@{ Type='done'; Rows=$results; Error=$null })
} catch {
$GdiQueue.Enqueue(@{ Type='done'; Rows=@(); Error=$_.Exception.Message })
}
})
$script:GdiHandle = $script:GdiPs.BeginInvoke()
# ── Timer to drain GdiQueue on the UI thread ──
$script:GdiTimer = [System.Windows.Threading.DispatcherTimer]::new()
$script:GdiTimer.Interval = [TimeSpan]::FromMilliseconds(300)
$script:GdiTimer.Add_Tick({
$entry = $null
while ($script:GdiQueue.TryDequeue([ref]$entry)) {
switch ($entry['Type']) {
'log' {
$lvl = $entry['Level']
$col = switch ($lvl) {
'Success' { $DevConfig.LogColorSuccess }
'Warning' { $DevConfig.LogColorWarning }
'Error' { $DevConfig.LogColorError }
'Action' { $DevConfig.LogColorAction }
default { $DevConfig.LogColorInfo }
}
try {
$para = [System.Windows.Documents.Paragraph]::new()
$run = [System.Windows.Documents.Run]::new($entry['Line'])
$run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString($col))
$para.Margin = [System.Windows.Thickness]::new(0)
$para.Inlines.Add($run)
$script:RtbLog.Document.Blocks.Add($para)
$script:RtbLog.ScrollToEnd()
} catch {}
}
'progress' {
$cur = $entry['Current']; $tot = $entry['Total']
$script:Window.FindName('PnlGDIProgress').Visibility = 'Visible'
$script:Window.FindName('TxtGDIProgressMsg').Text = 'Fetching device details...'
$script:Window.FindName('TxtGDIProgressDetail').Text = "Processing device $cur of $tot..."
Update-StatusBar -Text "Get Device Info: $cur / $tot devices..."
}
'done' {
$script:GdiTimer.Stop()
try { $script:GdiPs.EndInvoke($script:GdiHandle) } catch {}
try { $script:GdiRs.Close() } catch {}
try { $script:GdiPs.Dispose() } catch {}
$script:GdiPs = $null; $script:GdiRs = $null
$script:Window.FindName('BtnStop').Visibility = 'Collapsed'
$script:Window.FindName('BtnStop').IsEnabled = $true
$script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
$script:Window.FindName('BtnGDIRun').IsEnabled = $true
$script:Window.FindName('BtnGDIRunAll').IsEnabled = $true
$script:Window.FindName('PnlGDIProgress').Visibility = 'Collapsed'
$errMsg = $entry['Error']
if ($errMsg) {
Write-VerboseLog "Get Device Info error: $errMsg" -Level Error
Show-Notification "Error: $errMsg" -BgColor '#F8D7DA' -FgColor '#721C24'
Update-StatusBar -Text "Connected as: $($script:TxtStatusBar.Text -replace 'Get Device Info.*','')"
return
}
$rows = @($entry['Rows'])
if ($rows.Count -eq 0) {
Write-VerboseLog 'Get Device Info: no rows - check filters or input.' -Level Warning
$script:Window.FindName('TxtGDINoResults').Visibility = 'Visible'
Show-Notification 'No devices matched the input and filters.' -BgColor '#FFF3CD' -FgColor '#7A4800'
Update-StatusBar -Text 'Connected'
return
}
# ── Populate DataGrid ──
$dg = $script:Window.FindName('DgGDIResults')
$dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($rows)
$script:Window.FindName('TxtGDICount').Text = "$($rows.Count) device(s) found"
$script:Window.FindName('PnlGDIResults').Visibility = 'Visible'
$script:Window.FindName('BtnGDICopyAll').IsEnabled = $true
$script:Window.FindName('TxtGDIFilter').IsEnabled = $true
$script:Window.FindName('BtnGDIFilter').IsEnabled = $true
$script:Window.FindName('BtnGDIFilterClear').IsEnabled = $true
$script:Window.FindName('BtnGDIExportXlsx').IsEnabled = $true
Write-VerboseLog "Get Device Info: $($rows.Count) device(s) loaded into table." -Level Success
Show-Notification "$($rows.Count) device(s) ready. Use Export XLSX to save." -BgColor '#D4EDDA' -FgColor '#155724'
Update-StatusBar -Text 'Connected'
}
}
}
})
$script:GdiTimer.Start()
})
# -- BtnGDIRunAll: Get All Device Info --
$script:Window.FindName('BtnGDIRunAll').Add_Click({
Hide-Notification
# Collect filters on UI thread (no input list needed)
$platFilter = [System.Collections.Generic.List[string]]::new()
if ($script:Window.FindName('ChkGDIWindows').IsChecked) { $platFilter.Add('Windows') }
if ($script:Window.FindName('ChkGDIAndroid').IsChecked) { $platFilter.Add('Android') }
if ($script:Window.FindName('ChkGDIiOS').IsChecked) { $platFilter.Add('iOS') }
if ($script:Window.FindName('ChkGDIMacOS').IsChecked) { $platFilter.Add('macOS') }
$ownershipSel = $script:Window.FindName('CmbGDIOwnership').SelectedItem.Content
$ownershipFilter = switch ($ownershipSel) {
'Company only' { 'Company' }
'Personal only' { 'Personal' }
default { 'All' }
}
$script:GDIParams = @{
AllDevices = $true
PlatformFilter = $platFilter
OwnershipFilter = $ownershipFilter
}
# Reset table state
$script:Window.FindName('PnlGDIResults').Visibility = 'Collapsed'
$script:Window.FindName('TxtGDINoResults').Visibility = 'Collapsed'
$script:Window.FindName('BtnGDICopyValue').IsEnabled = $false
$script:Window.FindName('BtnGDICopyRow').IsEnabled = $false
$script:Window.FindName('BtnGDICopyAll').IsEnabled = $false
$script:GDIAllData = $null
$script:Window.FindName('TxtGDIFilter').IsEnabled = $false
$script:Window.FindName('BtnGDIFilter').IsEnabled = $false
$script:Window.FindName('BtnGDIFilterClear').IsEnabled = $false
$script:Window.FindName('TxtGDIFilter').Text = ''
$script:Window.FindName('BtnGDIExportXlsx').IsEnabled = $false
$dg = $script:Window.FindName('DgGDIResults')
if ($dg) { $dg.ItemsSource = $null }
$script:Window.FindName('PnlGDIProgress').Visibility = 'Visible'
$script:Window.FindName('TxtGDIProgressMsg').Text = 'Fetching all Entra devices...'
$script:Window.FindName('TxtGDIProgressDetail').Text = ''
$script:Window.FindName('BtnGDIRun').IsEnabled = $false
$script:Window.FindName('BtnGDIRunAll').IsEnabled = $false
$script:Window.FindName('BtnStop').Visibility = 'Visible'
$script:Window.FindName('BtnStop').IsEnabled = $true
$script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
$script:BgStopped = $false
Write-VerboseLog '--- Get All Device Info: starting ---' -Level Action
$script:GdiQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new()
$script:GdiStop = [hashtable]::Synchronized(@{ Stop = $false })
$script:GdiRs = [runspacefactory]::CreateRunspace()
$script:GdiRs.ApartmentState = 'STA'
$script:GdiRs.ThreadOptions = 'ReuseThread'
$script:GdiRs.Open()
$script:GdiRs.SessionStateProxy.SetVariable('GdiQueue', $script:GdiQueue)
$script:GdiRs.SessionStateProxy.SetVariable('GdiStop', $script:GdiStop)
$script:GdiRs.SessionStateProxy.SetVariable('GdiParams', $script:GDIParams)
$script:GdiRs.SessionStateProxy.SetVariable('LogFile', $script:LogFile)
$script:GdiPs = [powershell]::Create()
$script:GdiPs.Runspace = $script:GdiRs
$null = $script:GdiPs.AddScript({
# ── Helper: enqueue a log message ──
function GLog {
param([string]$Msg, [string]$Level = 'Info')
$prefix = switch ($Level) {
"Success" { "$([char]0x2713) " } "Warning" { "[!] " } "Error" { "$([char]0x2717) " }
'Action' { '> ' } default { ' ' }
}
$line = "[$(Get-Date -Format 'HH:mm:ss')] $prefix$Msg"
$GdiQueue.Enqueue(@{ Type='log'; Line=$line; Level=$Level })
if ($LogFile) {
try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } catch {}
}
}
# ── OS normalisation (Entra raw → friendly) ──
function Normalize-OS([string]$os) {
switch -Wildcard ($os) {
'Windows*' { 'Windows' } 'Android*' { 'Android' }
'iPhone' { 'iOS' } 'iPadOS' { 'iOS' }
'iOS' { 'iOS' } 'MacMDM' { 'macOS' }
'Mac OS X' { 'macOS' } 'macOS' { 'macOS' }
default { $os }
}
}
# ── Windows release lookup ──
$WinVerMap = @(
@{ V='10.0.26200'; R='25H2' }
@{ V='10.0.28000'; R='26H1' }
@{ V='10.0.26100'; R='24H2' }
@{ V='10.0.22631'; R='23H2' }
@{ V='10.0.22621'; R='22H2 (Win11)' }
@{ V='10.0.19045'; R='22H2 (Win10)' }
@{ V='10.0.19044'; R='21H2 (LTSC)' }
@{ V='10.0.17763'; R='1809 (LTSC)' }
)
function Get-WinRelease([string]$ver) {
if ([string]::IsNullOrWhiteSpace($ver)) { return $null }
$v = $ver.Trim()
$m = $WinVerMap | Where-Object { $_.V -eq $v } | Select-Object -First 1
if ($m) { return $m.R }
# Try major.minor.build prefix only
if ($v -match '^\d+\.\d+\.\d+') {
$pfx = ($Matches[0])
$m = $WinVerMap | Where-Object { $_.V -eq $pfx } | Select-Object -First 1
if ($m) { return $m.R }
}
return $null
}
# ── Device type from name suffix ──
$DevTypeMap = @(
@{ S='-D'; T='Desktop' }
@{ S='-L'; T='Laptop' }
@{ S='-V'; T='Virtual' }
@{ S='-K'; T='Kiosk' }
@{ S='-F'; T='Teams Meeting Room' }
@{ S='-A'; T='Shared Device' }
)
function Get-DevType([string]$name) {
if ([string]::IsNullOrWhiteSpace($name)) { return $null }
$u = $name.Trim().ToUpperInvariant()
foreach ($m in $DevTypeMap) {
if ($u.EndsWith($m.S.ToUpperInvariant())) { return $m.T }
}
return $null
}
# ── Activity range label ──
# Tries RoundtripKind first (ISO 8601/UTC), then InvariantCulture general parse
# as fallback - same two-style approach as Format-Date to handle all Graph date formats.
function Get-ActivityRange([string]$dateStr) {
if ([string]::IsNullOrWhiteSpace($dateStr)) { return $null }
$d = $null
foreach ($style in @(
[System.Globalization.DateTimeStyles]::RoundtripKind,
[System.Globalization.DateTimeStyles]::None
)) {
try {
$d = [datetime]::Parse($dateStr,
[System.Globalization.CultureInfo]::InvariantCulture, $style)
break
} catch {}
}
if ($null -eq $d) { return $null }
$days = [int][Math]::Floor(([datetime]::UtcNow - $d.ToUniversalTime()).TotalDays)
if ($days -le 7) { return '0-7 days' }
elseif ($days -le 14) { return '8-14 days' }
elseif ($days -le 30) { return '15-30 days' }
elseif ($days -le 60) { return '30-60 days' }
elseif ($days -le 90) { return '60-90 days' }
elseif ($days -le 180) { return '90-180 days' }
elseif ($days -le 365) { return '180-365 days' }
else { return 'Older than 1 yr' }
}
# ── Page through a Graph URI ──
function Get-GraphAll([string]$Uri, [hashtable]$Headers = @{}) {
$items = [System.Collections.Generic.List[object]]::new()
$next = $Uri
do {
$r = Invoke-MgGraphRequest -Method GET -Uri $next -Headers $Headers
if ($r['value']) { foreach ($i in @($r['value'])) { $items.Add($i) } }
$next = if ($r.ContainsKey('@odata.nextLink')) { $r['@odata.nextLink'] } else { $null }
} while ($next)
return ,$items
}
# ── Fetch full Entra device record ──
function Get-EntraDevice([string]$Uri) {
try {
return Invoke-MgGraphRequest -Method GET -Uri $Uri
} catch { return $null }
}
# ── Fetch Intune device by azureADDeviceId ──
function Get-IntuneByEntraId([string]$EntraId, [hashtable]$Cache) {
if ([string]::IsNullOrWhiteSpace($EntraId)) { return $null }
if ($Cache.ContainsKey($EntraId)) { return $Cache[$EntraId] }
try {
$enc = [Uri]::EscapeDataString($EntraId)
$resp = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$enc'`&`$select=id,deviceName,operatingSystem,osVersion,lastSyncDateTime,manufacturer,model,serialNumber,managedDeviceOwnerType,enrolledDateTime,azureADDeviceId,userPrincipalName,complianceState`&`$top=1"
$dev = if ($resp['value'] -and $resp['value'].Count -gt 0) { $resp['value'][0] } else { $null }
$Cache[$EntraId] = $dev
return $dev
} catch { $Cache[$EntraId] = $null; return $null }
}
# ── Fetch user info ──
function Get-UserInfo([string]$UserId) {
try {
return Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/users/$([Uri]::EscapeDataString($UserId))?`$select=id,userPrincipalName,displayName,jobTitle,accountEnabled,city,country,officeLocation"
} catch { return $null }
}
# ── Format any date string to MM-dd-yyyy, tolerating multiple input formats ──
function Format-Date([string]$raw) {
if ([string]::IsNullOrWhiteSpace($raw)) { return $null }
# Try ISO 8601 / RoundtripKind first, then general parse as fallback
foreach ($style in @(
[System.Globalization.DateTimeStyles]::RoundtripKind,
[System.Globalization.DateTimeStyles]::None
)) {
try {
return ([datetime]::Parse($raw, [System.Globalization.CultureInfo]::InvariantCulture, $style)).ToString('MM-dd-yyyy')
} catch {}
}
return $raw # return raw if all parsing fails
}
# ── Build Autopilot lookup dictionary ──────────────────────────────────────
# deploymentProfileAssignmentStatus is a NESTED OBJECT containing deploymentProfileId.
# Profile name is resolved by: $ap['deploymentProfileAssignmentStatus']['deploymentProfileId']
# → fetch profile display name from windowsAutopilotDeploymentProfiles/{id}.
# Step 1: load all profiles into id->name map.
# Step 2: load all identities, extract profileId from nested status object.
function Build-AutopilotDictionary {
$dict = @{}
$profileMap = @{}
# ── Step 1: all deployment profiles ──
try {
$nextUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles?$select=id,displayName&$top=100'
do {
$r = Invoke-MgGraphRequest -Method GET -Uri $nextUri
foreach ($p in @($r['value'])) { $profileMap[[string]$p['id']] = [string]$p['displayName'] }
$nextUri = if ($r.ContainsKey('@odata.nextLink')) { $r['@odata.nextLink'] } else { $null }
} while ($nextUri)
GLog "Autopilot: $($profileMap.Count) profile(s) loaded" 'Info'
} catch {
GLog "Autopilot profiles failed: $($_.Exception.Message)" 'Warning'
}
# ── Step 2: all device identities ──
try {
GLog 'Autopilot: loading device identities...' 'Info'
$nextUri = 'https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?$top=500'
$total = 0
do {
$resp = Invoke-MgGraphRequest -Method GET -Uri $nextUri
$page = @($resp['value'])
$total += $page.Count
foreach ($ap in $page) {
$aadId = [string]$ap['azureActiveDirectoryDeviceId']
if ([string]::IsNullOrWhiteSpace($aadId)) { continue }
# deploymentProfileAssignmentStatus is a nested object -
# the profileId lives inside it, not as a top-level field
$statusObj = $ap['deploymentProfileAssignmentStatus']
$statusStr = ''
$profileId = $null
if ($statusObj -is [System.Collections.IDictionary]) {
$profileId = [string]$statusObj['deploymentProfileId']
$statusStr = [string]$statusObj['value'] # e.g. 'assigned'
if (-not $statusStr) { $statusStr = [string]$statusObj['status'] }
} else {
$statusStr = [string]$statusObj # simple string fallback
}
# Resolve profile name from the profiles map
$profileName = if ($profileId -and $profileMap.ContainsKey($profileId)) {
$profileMap[$profileId]
} else { $null }
$dict[$aadId.ToLower()] = @{
GroupTag = [string]$ap['groupTag']
ProfileStatus = $statusStr
}
}
$nextUri = if ($resp.ContainsKey('@odata.nextLink')) { $resp['@odata.nextLink'] } else { $null }
} while ($nextUri)
GLog "Autopilot: $total record(s), $($dict.Count) indexed by Entra Device ID" 'Success'
} catch {
GLog "Autopilot identities failed: $($_.Exception.Message)" 'Warning'
}
return ,$dict
}
# ── Build a result row from Entra + Intune data ──
function Build-Row([hashtable]$EntraDev, [hashtable]$IntuneDev, [hashtable]$UserInfo, [string]$OwnerUPN = '', [hashtable]$AutopilotInfo = $null) {
$NoRec = 'No Intune Record'
$devName = [string]$EntraDev['displayName']
# Entra fields
$entraDevId = [string]$EntraDev['deviceId'] # the GUID used as azureADDeviceId
$entraObjId = [string]$EntraDev['id']
$entraOS = [string]$EntraDev['operatingSystem']
$entraOSVer = [string]$EntraDev['operatingSystemVersion']
$entraJoin = switch ([string]$EntraDev['trustType']) {
'AzureAd' { 'Entra Joined' }
'ServerAd' { 'Hybrid Joined' }
'Workplace' { 'Registered' }
default { [string]$EntraDev['trustType'] }
}
$entraOwner = [string]$EntraDev['deviceOwnership']
$entraEnabled= [string]$EntraDev['accountEnabled']
$entraSignIn = [string]$EntraDev['approximateLastSignInDateTime']
$entraSignInFmt = Format-Date $entraSignIn
$entraActivity = Get-ActivityRange $entraSignIn
# Intune fields
if ($IntuneDev) {
$intuneId = [string]$IntuneDev['id']
$intuneOS = [string]$IntuneDev['operatingSystem']
$intuneOSVer = [string]$IntuneDev['osVersion']
$intuneSync = [string]$IntuneDev['lastSyncDateTime']
$intuneSyncFmt = if ($intuneSync) { Format-Date $intuneSync } else { $NoRec }
$intuneActivity = Get-ActivityRange $intuneSync
$intuneOEM = [string]$IntuneDev['manufacturer']
$intuneModel = [string]$IntuneDev['model']
$intuneSerial = [string]$IntuneDev['serialNumber']
$intuneOwner = [string]$IntuneDev['managedDeviceOwnerType']
$intuneEnrolled = Format-Date ([string]$IntuneDev['enrolledDateTime'])
$intuneUPN = [string]$IntuneDev['userPrincipalName']
$intuneCompliance = [string]$IntuneDev['complianceState']
} else {
$intuneId = $NoRec
$intuneOS = $NoRec
$intuneOSVer = $NoRec
$intuneSyncFmt = $NoRec
$intuneActivity = $NoRec
$intuneOEM = $NoRec
$intuneModel = $NoRec
$intuneSerial = $NoRec
$intuneOwner = $NoRec
$intuneEnrolled = $NoRec
$intuneUPN = $NoRec
$intuneCompliance = $NoRec
}
# Autopilot fields (from dedicated lookup - $null if not Autopilot registered)
$apGroupTag = if ($AutopilotInfo) { [string]$AutopilotInfo['GroupTag'] } else { $NoRec }
$apStatus = if ($AutopilotInfo) { [string]$AutopilotInfo['ProfileStatus'] } else { $NoRec }
# Prefer Intune OS version for Windows release mapping
$osVerForMap = if ($intuneOSVer -and $intuneOSVer -ne $NoRec) { $intuneOSVer } else { $entraOSVer }
$winRelease = Get-WinRelease $osVerForMap
$devType = Get-DevType $devName
# User fields - UserInfo from Entra; fall back to OwnerUPN (Entra) or Intune UPN
$resolvedUPN = if ($UserInfo) { [string]$UserInfo['userPrincipalName'] }
elseif ($OwnerUPN) { $OwnerUPN }
elseif ($intuneUPN -and $intuneUPN -ne $NoRec) { $intuneUPN }
else { $null }
$uTitle = if ($UserInfo) { [string]$UserInfo['jobTitle'] } else { $null }
$uEnabled = if ($UserInfo) { [string]$UserInfo['accountEnabled'] } else { $null }
$uCity = if ($UserInfo) { [string]$UserInfo['city'] } else { $null }
$uCountry = if ($UserInfo) { [string]$UserInfo['country'] } else { $null }
return [PSCustomObject]@{
'Entra_DeviceName' = $devName
'DeviceType' = $devType
'Entra_DeviceID' = $entraDevId
'Entra_ObjectID' = $entraObjId
'Intune_DeviceID' = $intuneId
'Entra_JoinType' = $entraJoin
'Entra_Ownership' = $entraOwner
'Intune_Ownership' = $intuneOwner
'Entra_OSPlatform' = $entraOS
'Intune_OSPlatform' = $intuneOS
'Entra_OSVersion' = $entraOSVer
'Intune_OSVersion' = $intuneOSVer
'Windows_Release' = $winRelease
'Entra_DeviceEnabled' = $entraEnabled
'Entra_LastSignIn' = $entraSignInFmt
'Entra_ActivityRange' = $entraActivity
'Intune_LastCheckIn' = $intuneSyncFmt
'Intune_ActivityRange' = $intuneActivity
'Intune_OEM' = $intuneOEM
'Intune_Model' = $intuneModel
'Intune_SerialNumber' = $intuneSerial
'Intune_EnrolledDate' = $intuneEnrolled
'Intune_ComplianceState' = $intuneCompliance
'Entra_DeviceOwner' = $OwnerUPN
'Entra_PrimaryUserUPN' = $resolvedUPN
'Entra_UserAccountEnabled' = $uEnabled
'Entra_UserCountry' = $uCountry
'Entra_UserCity' = $uCity
'Entra_UserJobTitle' = $uTitle
'Autopilot_GroupTag' = $apGroupTag
'Autopilot_ProfileStatus' = $apStatus
}
}
# ══════════════════════════════════════════════════════════════════
# MAIN WORK
# ══════════════════════════════════════════════════════════════════
try {
Import-Module Microsoft.Graph.Authentication -ErrorAction SilentlyContinue
$params = $GdiParams
$platFilter = @($params['PlatformFilter'])
$ownerFilter = $params['OwnershipFilter']
$allDevicesMode = $params['AllDevices'] -eq $true
$intuneCache = @{} # azureADDeviceId -> Intune device hashtable
$userCache = @{} # userId / UPN -> user hashtable
# Build Autopilot lookup once upfront - keyed by azureActiveDirectoryDeviceId (lowercase)
$autopilotDict = Build-AutopilotDictionary
$entraIdSet = [System.Collections.Generic.HashSet[string]]::new() # deduplicate Entra Object IDs
$deviceObjIds = [System.Collections.Generic.List[string]]::new()
$isGuidRx = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
if ($allDevicesMode) {
# ── All Devices mode: fetch every Entra device object ──
GLog 'All Devices mode - fetching all Entra device objects...' 'Action'
GLog "Platform filter : $(if ($platFilter.Count -eq 0) { 'All' } else { $platFilter -join ', ' })" 'Info'
GLog "Ownership filter: $ownerFilter" 'Info'
$allEntra = Get-GraphAll 'https://graph.microsoft.com/v1.0/devices?$select=id&$top=999'
GLog "Retrieved $($allEntra.Count) Entra device object(s)" 'Info'
foreach ($d in $allEntra) {
$null = $deviceObjIds.Add([string]$d['id'])
}
} else {
# ── Input-based mode: resolve entries to Entra device Object IDs ──
$inputType = $params['InputType']
$entries = $params['InputTxt'] -split "`n" |
ForEach-Object { $_.Trim() } |
Where-Object { $_ -ne '' }
GLog "Input type : $inputType" 'Info'
GLog "Entries : $($entries.Count)" 'Info'
GLog "Platform filter : $(if ($platFilter.Count -eq 0) { 'All' } else { $platFilter -join ', ' })" 'Info'
GLog "Ownership filter: $ownerFilter" 'Info'
foreach ($entry in $entries) {
if ($GdiStop['Stop']) { GLog 'Stop requested.' 'Warning'; break }
switch -Wildcard ($inputType) {
'User UPNs*' {
GLog "Resolving user devices: $entry" 'Info'
try {
$uid = [Uri]::EscapeDataString($entry)
$devs = Get-GraphAll "https://graph.microsoft.com/v1.0/users/$uid/ownedDevices?`$select=id,displayName,deviceId,operatingSystem,operatingSystemVersion,deviceOwnership,trustType,accountEnabled,approximateLastSignInDateTime`&`$top=999"
GLog " $($devs.Count) owned device(s) found" 'Info'
foreach ($d in $devs) { $null = $deviceObjIds.Add([string]$d['id']) }
} catch {
GLog " Could not resolve user $entry : $($_.Exception.Message)" 'Warning'
}
}
'Device names' {
GLog "Resolving device by name: $entry" 'Info'
try {
$safe = $entry -replace "'","''"
$r = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/devices?`$filter=displayName eq '$safe'`&`$select=id`&`$top=5"
$found = @($r['value'])
if ($found.Count -eq 0) { GLog " Not found: $entry" 'Warning' }
else { foreach ($d in $found) { $null = $deviceObjIds.Add([string]$d['id']) } }
} catch {
GLog " Error resolving $entry : $($_.Exception.Message)" 'Warning'
}
}
'Device serial numbers' {
GLog "Resolving serial number: $entry" 'Info'
# Serial numbers only exist in Intune - search there first, get azureADDeviceId, then Entra
try {
$safe = $entry -replace "'","''"
$r = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=serialNumber eq '$safe'`&`$select=id,azureADDeviceId,deviceName`&`$top=5"
$found = @($r['value'])
if ($found.Count -eq 0) { GLog " Serial not found in Intune: $entry" 'Warning' }
else {
foreach ($md in $found) {
$aadId = [string]$md['azureADDeviceId']
if ($aadId) {
# Find Entra Object ID from azureADDeviceId (deviceId field)
$er = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/devices?`$filter=deviceId eq '$aadId'`&`$select=id`&`$top=1"
if ($er['value'] -and $er['value'].Count -gt 0) {
$null = $deviceObjIds.Add([string]$er['value'][0]['id'])
}
}
}
}
} catch {
GLog " Error resolving serial $entry : $($_.Exception.Message)" 'Warning'
}
}
'Entra Device Object IDs' {
if ($entry -match $isGuidRx) {
$null = $deviceObjIds.Add($entry)
} else {
GLog " Skipped (not a GUID): $entry" 'Warning'
}
}
'Intune Device IDs' {
GLog "Resolving Intune device ID: $entry" 'Info'
try {
$md = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$entry`?`$select=id,azureADDeviceId,deviceName"
$aadId = [string]$md['azureADDeviceId']
if ($aadId) {
$er = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/devices?`$filter=deviceId eq '$aadId'`&`$select=id`&`$top=1"
if ($er['value'] -and $er['value'].Count -gt 0) {
$null = $deviceObjIds.Add([string]$er['value'][0]['id'])
}
}
} catch {
GLog " Could not resolve Intune ID $entry : $($_.Exception.Message)" 'Warning'
}
}
'Groups*' {
GLog "Resolving group: $entry" 'Info'
# Resolve group ID
$groupId = $null
if ($entry -match $isGuidRx) {
$groupId = $entry
} else {
try {
$safe = $entry -replace "'","''"
$gr = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe'`&`$select=id`&`$top=1"
if ($gr['value'] -and $gr['value'].Count -gt 0) {
$groupId = [string]$gr['value'][0]['id']
}
} catch {}
}
if (-not $groupId) { GLog " Group not found: $entry" 'Warning'; continue }
# Get all transitive members (users + devices)
GLog " Scanning group members (transitive)..." 'Info'
# $select with @odata.type or user-subtype properties causes 400 on
# transitiveMembers (directoryObject base type). Use $select=id only;
# @odata.type is always returned automatically.
$members = Get-GraphAll "https://graph.microsoft.com/v1.0/groups/$groupId/transitiveMembers?`$select=id`&`$top=999"
GLog " $($members.Count) member(s) found" 'Info'
foreach ($mem in $members) {
if ($GdiStop['Stop']) { break }
$odataType = [string]$mem['@odata.type']
if ($odataType -eq '#microsoft.graph.device') {
$null = $deviceObjIds.Add([string]$mem['id'])
} elseif ($odataType -eq '#microsoft.graph.user') {
# Resolve user's owned devices
$uid = [string]$mem['id']
try {
$udevs = Get-GraphAll "https://graph.microsoft.com/v1.0/users/$uid/ownedDevices?`$select=id`&`$top=999"
foreach ($ud in $udevs) { $null = $deviceObjIds.Add([string]$ud['id']) }
} catch {}
}
}
}
}
}
} # end else (input-based mode)
GLog "Total device IDs to process: $($deviceObjIds.Count)" 'Info'
if ($deviceObjIds.Count -eq 0) {
.Enqueue(@{ Type='done'; Rows=@(); Error='No devices resolved from the provided input.' })
return
}
# ── Process each Entra device ──
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
$processed = 0
foreach ($objId in $deviceObjIds) {
if ($GdiStop['Stop']) { GLog 'Stop requested - halting.' 'Warning'; break }
if ($entraIdSet.Contains($objId)) { continue } # deduplicate
$null = $entraIdSet.Add($objId)
$processed++
$GdiQueue.Enqueue(@{ Type='progress'; Current=$processed; Total=$deviceObjIds.Count })
# Fetch full Entra device record
$entraRaw = $null
try {
$entraRaw = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/devices/$objId`?`$select=id,deviceId,displayName,operatingSystem,operatingSystemVersion,deviceOwnership,trustType,accountEnabled,approximateLastSignInDateTime,registrationDateTime"
} catch {
GLog " Could not fetch Entra device $objId : $($_.Exception.Message)" 'Warning'
continue
}
$devName = [string]$entraRaw['displayName']
$entraDevId = [string]$entraRaw['deviceId'] # azureADDeviceId in Intune
$entraOS = Normalize-OS ([string]$entraRaw['operatingSystem'])
GLog "[$processed] $devName [$entraOS]" 'Info'
# ── Platform filter ──
if ($platFilter.Count -gt 0 -and $entraOS -notin $platFilter) {
GLog " Skipped (platform $entraOS not in filter)" 'Info'
continue
}
# ── Ownership filter ──
$rawOwner = [string]$entraRaw['deviceOwnership']
if ($ownerFilter -ne 'All' -and $rawOwner -ne $ownerFilter) {
GLog " Skipped (ownership $rawOwner not in filter)" 'Info'
continue
}
# ── Intune lookup ──
$intuneDev = Get-IntuneByEntraId -EntraId $entraDevId -Cache $intuneCache
if ($intuneDev) {
GLog " Intune: $([string]$intuneDev['id'])" 'Success'
} else {
GLog " Intune: No Intune Record" 'Warning'
}
# ── Registered owner (user) ──
# No $select on registeredOwners - $select is not supported on navigation
# endpoints and causes userPrincipalName to be missing from the response.
$userInfo = $null
$ownerUPN = $null
try {
$ownerResp = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/devices/$objId/registeredOwners?`$top=5"
$ownerList = @($ownerResp['value'])
$firstUser = $ownerList | Where-Object {
$t = [string]$_['@odata.type']
$t -eq '#microsoft.graph.user' -or $t -eq 'microsoft.graph.user'
} | Select-Object -First 1
if ($firstUser) {
# userPrincipalName lives in AdditionalProperties for some SDK versions,
# or directly on the hashtable when using Invoke-MgGraphRequest
$ownerUPN = if ($firstUser.ContainsKey('userPrincipalName')) {
[string]$firstUser['userPrincipalName']
} elseif ($firstUser.ContainsKey('AdditionalProperties')) {
[string]$firstUser['AdditionalProperties']['userPrincipalName']
} else { $null }
$uid = [string]$firstUser['id']
# If UPN still empty, fetch it from the user endpoint directly
if ([string]::IsNullOrWhiteSpace($ownerUPN) -and $uid) {
try {
$upnResp = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/users/$uid`?`$select=userPrincipalName"
$ownerUPN = [string]$upnResp['userPrincipalName']
} catch {}
}
GLog " Owner: $ownerUPN" 'Info'
if ($userCache.ContainsKey($uid)) {
$userInfo = $userCache[$uid]
} else {
$userInfo = Get-UserInfo -UserId $uid
$userCache[$uid] = $userInfo
}
} else {
GLog " No registered owner found" 'Info'
}
} catch {
GLog " Owner fetch error: $($_.Exception.Message)" 'Warning'
}
# ── Autopilot lookup - dictionary built upfront, keyed by entraDevId lowercase ──
$autopilotInfo = $null
$entraOSNorm = Normalize-OS ([string]$entraRaw['operatingSystem'])
if ($entraOSNorm -eq 'Windows' -and $entraDevId) {
$apKey = $entraDevId.ToLower()
if ($autopilotDict.ContainsKey($apKey)) {
$autopilotInfo = $autopilotDict[$apKey]
GLog " Autopilot: Tag=$($autopilotInfo['GroupTag']) | Status=$($autopilotInfo['ProfileStatus'])" 'Info'
} else {
GLog ' Autopilot: not registered' 'Info'
}
}
# ── Build row ──
$row = Build-Row -EntraDev $entraRaw -IntuneDev $intuneDev -UserInfo $userInfo -OwnerUPN $ownerUPN -AutopilotInfo $autopilotInfo
$results.Add($row)
}
GLog "Processed $($results.Count) device(s)." 'Success'
$GdiQueue.Enqueue(@{ Type='done'; Rows=$results; Error=$null })
} catch {
$GdiQueue.Enqueue(@{ Type='done'; Rows=@(); Error=$_.Exception.Message })
}
})
$script:GdiHandle = $script:GdiPs.BeginInvoke()
# Timer to drain GdiQueue on UI thread
$script:GdiTimer = [System.Windows.Threading.DispatcherTimer]::new()
$script:GdiTimer.Interval = [TimeSpan]::FromMilliseconds(300)
$script:GdiTimer.Add_Tick({
$entry = $null
while ($script:GdiQueue.TryDequeue([ref]$entry)) {
switch ($entry['Type']) {
'log' {
$lvl = $entry['Level']
$col = switch ($lvl) {
'Success' { $DevConfig.LogColorSuccess }
'Warning' { $DevConfig.LogColorWarning }
'Error' { $DevConfig.LogColorError }
'Action' { $DevConfig.LogColorAction }
default { $DevConfig.LogColorInfo }
}
try {
$para = [System.Windows.Documents.Paragraph]::new()
$run = [System.Windows.Documents.Run]::new($entry['Line'])
$run.Foreground = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.ColorConverter]::ConvertFromString($col))
$para.Margin = [System.Windows.Thickness]::new(0)
$para.Inlines.Add($run)
$script:RtbLog.Document.Blocks.Add($para)
$script:RtbLog.ScrollToEnd()
} catch {}
}
'progress' {
$cur = $entry['Current']; $tot = $entry['Total']
$script:Window.FindName('PnlGDIProgress').Visibility = 'Visible'
$script:Window.FindName('TxtGDIProgressMsg').Text = 'Fetching device details...'
$script:Window.FindName('TxtGDIProgressDetail').Text = "Processing device $cur of $tot..."
Update-StatusBar -Text "Get Device Info: $cur / $tot devices..."
}
'done' {
$script:GdiTimer.Stop()
try { $script:GdiPs.EndInvoke($script:GdiHandle) } catch {}
try { $script:GdiRs.Close() } catch {}
try { $script:GdiPs.Dispose() } catch {}
$script:GdiPs = $null; $script:GdiRs = $null
$script:Window.FindName('BtnStop').Visibility = 'Collapsed'
$script:Window.FindName('BtnStop').IsEnabled = $true
$script:Window.FindName('BtnStop').Content = (New-StopBtnContent 'Stop')
$script:Window.FindName('BtnGDIRun').IsEnabled = $true
$script:Window.FindName('BtnGDIRunAll').IsEnabled = $true
$script:Window.FindName('PnlGDIProgress').Visibility = 'Collapsed'
$errMsg = $entry['Error']
if ($errMsg) {
Write-VerboseLog "Get Device Info error: $errMsg" -Level Error
Show-Notification "Error: $errMsg" -BgColor '#F8D7DA' -FgColor '#721C24'
Update-StatusBar -Text "Connected as: $($script:TxtStatusBar.Text -replace 'Get Device Info.*','')"
return
}
$rows = @($entry['Rows'])
if ($rows.Count -eq 0) {
Write-VerboseLog 'Get Device Info: no rows - check filters or input.' -Level Warning
$script:Window.FindName('TxtGDINoResults').Visibility = 'Visible'
Show-Notification 'No devices matched the input and filters.' -BgColor '#FFF3CD' -FgColor '#7A4800'
Update-StatusBar -Text 'Connected'
return
}
# ── Populate DataGrid ──
$dg = $script:Window.FindName('DgGDIResults')
$dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($rows)
$script:Window.FindName('TxtGDICount').Text = "$($rows.Count) device(s) found"
$script:Window.FindName('PnlGDIResults').Visibility = 'Visible'
$script:Window.FindName('BtnGDICopyAll').IsEnabled = $true
$script:Window.FindName('TxtGDIFilter').IsEnabled = $true
$script:Window.FindName('BtnGDIFilter').IsEnabled = $true
$script:Window.FindName('BtnGDIFilterClear').IsEnabled = $true
$script:Window.FindName('BtnGDIExportXlsx').IsEnabled = $true
Write-VerboseLog "Get Device Info: $($rows.Count) device(s) loaded into table." -Level Success
Show-Notification "$($rows.Count) device(s) ready. Use Export XLSX to save." -BgColor '#D4EDDA' -FgColor '#155724'
Update-StatusBar -Text 'Connected'
}
}
}
})
$script:GdiTimer.Start()
})
# ── DgGDIResults: cell selection ─────────────────────────────────────────────
$script:Window.FindName('DgGDIResults').Add_SelectedCellsChanged({
$dg = $script:Window.FindName('DgGDIResults')
$selCells = $dg.SelectedCells.Count
$script:Window.FindName('BtnGDICopyValue').IsEnabled = ($selCells -ge 1)
$script:Window.FindName('BtnGDICopyRow').IsEnabled = ($selCells -gt 0)
})
# ── BtnGDICopyValue ──────────────────────────────────────────────────────────
# ── BtnGDIFilter ──
$script:Window.FindName('BtnGDIFilter').Add_Click({
$dg = $script:Window.FindName('DgGDIResults')
$keyword = $script:Window.FindName('TxtGDIFilter').Text.Trim()
if ($null -eq $script:GDIAllData) {
$script:GDIAllData = @($dg.ItemsSource)
}
if ($keyword -eq '') {
$dg.ItemsSource = $script:GDIAllData
Show-Notification "Filter cleared - $($script:GDIAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$filtered = @($script:GDIAllData | Where-Object {
$row = $_
$match = $false
foreach ($prop in $row.PSObject.Properties) {
if ([string]$prop.Value -like "*$keyword*") { $match = $true; break }
}
$match
})
# ── BtnGDIFilterClear ──
$script:Window.FindName('BtnGDIFilterClear').Add_Click({
$script:Window.FindName('TxtGDIFilter').Text = ''
$dg = $script:Window.FindName('DgGDIResults')
if ($null -ne $script:GDIAllData) {
$dg.ItemsSource = $script:GDIAllData
Show-Notification "Filter cleared - $($script:GDIAllData.Count) result(s) shown." -BgColor '#D4EDDA' -FgColor '#155724'
}
})
$dg.ItemsSource = $filtered
Show-Notification "$($filtered.Count) of $($script:GDIAllData.Count) result(s) shown after filter." -BgColor '#D4EDDA' -FgColor '#155724'
}
Write-VerboseLog "GDI: Filter applied - keyword='$keyword'" -Level Info
})
$script:Window.FindName('BtnGDICopyValue').Add_Click({
$dg = $script:Window.FindName('DgGDIResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$vals = [System.Collections.Generic.List[string]]::new()
foreach ($cellInfo in @($dg.SelectedCells)) {
$col = $cellInfo.Column -as [System.Windows.Controls.DataGridBoundColumn]
if ($col) { $vals.Add(([string]$cellInfo.Item.($col.Binding.Path.Path)).Trim()) }
}
[System.Windows.Clipboard]::SetText($vals -join "`r`n")
Show-Notification '$($vals.Count) value(s) copied to clipboard.' -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog 'GDI: Copied cell value to clipboard.' -Level Success
})
# ── BtnGDICopyRow ────────────────────────────────────────────────────────────
$script:Window.FindName('BtnGDICopyRow').Add_Click({
$dg = $script:Window.FindName('DgGDIResults')
if ($dg.SelectedCells.Count -eq 0) { return }
$seen = [System.Collections.Generic.HashSet[object]]::new()
$rowObjs = [System.Collections.Generic.List[object]]::new()
foreach ($cell in @($dg.SelectedCells)) { if ($seen.Add($cell.Item)) { $rowObjs.Add($cell.Item) } }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$txtLines = [System.Collections.Generic.List[string]]::new()
$txtLines.Add(($colDefs | ForEach-Object { $_.H }) -join " ")
foreach ($row in $rowObjs) {
$vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
$txtLines.Add($vals -join " ")
}
[System.Windows.Clipboard]::SetText($txtLines -join "
")
Show-Notification "$($rowObjs.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "GDI: Copied $($rowObjs.Count) row(s) to clipboard." -Level Success
# Highlight full rows
$dg.UnselectAllCells()
foreach ($r in $rowObjs) { $null = $dg.SelectedItems.Add($r) }
})
# ── BtnGDICopyAll ────────────────────────────────────────────────────────────
$script:Window.FindName('BtnGDICopyAll').Add_Click({
$dg = $script:Window.FindName('DgGDIResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) { return }
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$txtLines = [System.Collections.Generic.List[string]]::new()
$txtLines.Add(($colDefs | ForEach-Object { $_.H }) -join " ")
foreach ($row in $allItems) {
$vals = $colDefs | ForEach-Object { [string]$row.($_.P) }
$txtLines.Add($vals -join " ")
}
[System.Windows.Clipboard]::SetText($txtLines -join "
")
Show-Notification "$($allItems.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
Write-VerboseLog "GDI: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
})
# ── BtnGDIExportXlsx ─────────────────────────────────────────────────────────
$script:Window.FindName('BtnGDIExportXlsx').Add_Click({
$dg = $script:Window.FindName('DgGDIResults')
$allItems = @($dg.ItemsSource)
if ($allItems.Count -eq 0) {
Show-Notification 'No results to export.' -BgColor '#FFF3CD' -FgColor '#856404'
return
}
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
foreach ($col in @($dg.Columns)) {
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
$bcPath = ''
if ($bc) { $bcPath = $bc.Binding.Path.Path }
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
}
$dlg = [Microsoft.Win32.SaveFileDialog]::new()
$dlg.Title = 'Export Get Device Info Results'
$dlg.Filter = 'Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*'
$dlg.FileName = "DeviceInfo_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"
$dlg.InitialDirectory = [Environment]::GetFolderPath('Desktop')
if ($dlg.ShowDialog() -ne $true) { return }
$outPath = $dlg.FileName
try {
$folder = Split-Path $outPath -Parent
if ($folder -and -not (Test-Path $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
$exportRows = $allItems | ForEach-Object {
$rowObj = $_; $ht = [ordered]@{}
foreach ($cd in $colDefs) { $ht[$cd['H']] = [string]$rowObj.($cd['P']) }
[PSCustomObject]$ht
}
if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
Import-Module ImportExcel -ErrorAction SilentlyContinue
$exportRows | Export-Excel -Path $outPath -WorksheetName 'DeviceInfo' -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium6
Show-Notification "Exported $($allItems.Count) rows: $outPath" -BgColor '#D4EDDA' -FgColor '#155724'
} else {
$exportRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8
Show-Notification "ImportExcel not found — saved as CSV: $(Split-Path $outPath -Leaf)" -BgColor '#FFF3CD' -FgColor '#856404'
}
Write-VerboseLog "GDI: Exported $($allItems.Count) row(s) to: $outPath" -Level Success
} catch {
Write-VerboseLog "GDI Export failed: $($_.Exception.Message)" -Level Error
Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
}
})
#endregion
# ============================================================
#region LAUNCH
# ============================================================
Write-VerboseLog "========================================" -Level Info
Write-VerboseLog "$($DevConfig.WindowTitle)" -Level Info
Write-VerboseLog "Author: Satish Singhi" -Level Info
Write-VerboseLog "Session: $script:SessionStamp" -Level Info
if ($script:LogFile) { Write-VerboseLog "Log file: $script:LogFile" -Level Info }
Write-VerboseLog "========================================" -Level Info
Write-VerboseLog "Click 'Connect' to authenticate to Microsoft Graph." -Level Info
$script:Window.Add_Closed({
Stop-PimAutoRefresh
try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {}
Write-VerboseLog "Session ended. Disconnected." -Level Info
})
$null = $script:Window.ShowDialog()
#endregion
#========== END_GUI_TOOL_CONTENT ===========
__PS7_PAYLOAD_END__#>