mirror of
https://github.com/satishsinghi-gh/MDM-ODA.git
synced 2026-07-26 11:38:13 +00:00
20516 lines
1.1 MiB
Plaintext
20516 lines
1.1 MiB
Plaintext
# -- param must be first statement for PS5 compatibility --
|
|
param([switch]$SkipUpgrade)
|
|
|
|
# ============================================================
|
|
# MDM-ODA - 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.
|
|
# Validates executables via runtime check (handles both MSI and MSIX installs).
|
|
function Test-RealPwsh {
|
|
param([string]$Path)
|
|
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
|
|
if (-not (Test-Path $Path -ErrorAction SilentlyContinue)) { return $false }
|
|
try {
|
|
$verOut = & $Path --version 2>&1
|
|
if ($verOut -match "^PowerShell\s+\d+\.\d+") {
|
|
Write-Host " Validated: $Path ($($verOut.Trim()))" -ForegroundColor DarkGray
|
|
return $true
|
|
}
|
|
} catch { }
|
|
Write-Host " Skipping invalid: $Path" -ForegroundColor DarkGray
|
|
return $false
|
|
}
|
|
|
|
# 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\WindowsApps\Microsoft.PowerShell_8wekyb3d8bbwe\pwsh.exe')) # MSIX user-scope
|
|
$probePaths.Add((Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\pwsh.exe')) # generic alias
|
|
$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) }
|
|
}
|
|
}
|
|
|
|
$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 ("MDMODA_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:MDMODA_LogFolder = $OrcConfig.LogPath
|
|
$env:MDMODA_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:MDMODA_LogFolder) { $env:MDMODA_LogFolder } else { "" }
|
|
$TranscriptPath = if ($env:MDMODA_Transcript) { $env:MDMODA_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.8
|
|
# 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.8"
|
|
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 ("MDMODA_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' }
|
|
)
|
|
$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 →"
|
|
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 ("MDMODA_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",
|
|
"MDM-ODA - 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) ⚡"
|
|
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=" ⚡" 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="" 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="" 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="🗒" 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="" 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=" 🔗" 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="🔑 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="↻"
|
|
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="▼ 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="🗂" 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="−"
|
|
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="🔍" 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="📋" 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="🔗" 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="👤" 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="⊕" 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="⊕" 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 · Groups · 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="⊖" 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 · Groups · 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="↑" 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="⚡" 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="✏" 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="BtnOpGetGroupOwner" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
|
|
<StackPanel>
|
|
<StackPanel Orientation="Horizontal">
|
|
<TextBlock Text="👤" FontSize="13" Foreground="#10B981" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
|
<TextBlock Text="Get Group Owner" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
|
|
</StackPanel>
|
|
<TextBlock Text="Query owner details for groups" 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="⊙" 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="▣" 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 · Platform · 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="∩" 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="⊖" 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="⇔" 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="🗑" 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>
|
|
|
|
<Button x:Name="BtnOpUserDirectReports" Style="{StaticResource OpTileBtn}" Margin="0,0,0,1">
|
|
<StackPanel>
|
|
<StackPanel Orientation="Horizontal">
|
|
<TextBlock Text="👥" FontSize="13" Foreground="#2563EB" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
|
<TextBlock Text="User Direct Reports" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
|
|
</StackPanel>
|
|
<TextBlock Text="List direct reports for users" 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="💻" 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="−"
|
|
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="📋" FontSize="13" Foreground="#7C3AED" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
|
<TextBlock Text="Get Policy Info" 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="ℹ" 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="📦" FontSize="13" Foreground="#7C3AED" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
|
<TextBlock Text="Get App Info" 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>
|
|
<Button x:Name="BtnOpMissingBitlocker" Style="{StaticResource OpTileBtn}" Margin="0,1,0,0">
|
|
<StackPanel>
|
|
<StackPanel Orientation="Horizontal">
|
|
<TextBlock Text="🔒" FontSize="13" Foreground="#DC2626" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
|
<TextBlock Text="Missing BitLocker Keys" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
|
|
</StackPanel>
|
|
<TextBlock Text="Devices missing OS drive recovery key" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
|
|
</StackPanel>
|
|
</Button>
|
|
<Button x:Name="BtnOpPolicyExport" Style="{StaticResource OpTileBtn}" Margin="0,1,0,0">
|
|
<StackPanel>
|
|
<StackPanel Orientation="Horizontal">
|
|
<TextBlock Text="📤" FontSize="13" Foreground="#0EA5E9" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
|
<TextBlock Text="Policy Export" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
|
|
</StackPanel>
|
|
<TextBlock Text="Export policies + settings to JSON" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
|
|
</StackPanel>
|
|
</Button>
|
|
<Button x:Name="BtnOpPolicyImport" Style="{StaticResource OpTileBtn}" Margin="0,1,0,0">
|
|
<StackPanel>
|
|
<StackPanel Orientation="Horizontal">
|
|
<TextBlock Text="📥" FontSize="13" Foreground="#16A34A" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
|
<TextBlock Text="Policy Import" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
|
|
</StackPanel>
|
|
<TextBlock Text="Recreate policies from JSON export" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
|
|
</StackPanel>
|
|
</Button>
|
|
<Button x:Name="BtnOpDupReview" Style="{StaticResource OpTileBtn}" Margin="0,1,0,0">
|
|
<StackPanel>
|
|
<StackPanel Orientation="Horizontal">
|
|
<TextBlock Text="🔍" FontSize="13" Foreground="#9333EA" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
|
<TextBlock Text="Duplicate Policy Review" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
|
|
</StackPanel>
|
|
<TextBlock Text="Find policies with identical settings" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
|
|
</StackPanel>
|
|
</Button>
|
|
<Button x:Name="BtnOpPolicyCleanup" Style="{StaticResource OpTileBtn}" Margin="0,1,0,0">
|
|
<StackPanel>
|
|
<StackPanel Orientation="Horizontal">
|
|
<TextBlock Text="🗑" FontSize="13" Foreground="#DC2626" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
|
<TextBlock Text="Policy Cleanup" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
|
|
</StackPanel>
|
|
<TextBlock Text="Backup & delete policies by name" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
|
|
</StackPanel>
|
|
</Button>
|
|
<Button x:Name="BtnOpRunRemediation" Style="{StaticResource OpTileBtn}" Margin="0,1,0,0">
|
|
<StackPanel>
|
|
<StackPanel Orientation="Horizontal">
|
|
<TextBlock Text="🔧" FontSize="13" Foreground="#EA580C" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
|
<TextBlock Text="Run Remediation" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
|
|
</StackPanel>
|
|
<TextBlock Text="On-demand remediation (corp Windows)" FontSize="10" Foreground="#14532D" Margin="20,2,0,0"/>
|
|
</StackPanel>
|
|
</Button>
|
|
<Button x:Name="BtnOpBulkSync" Style="{StaticResource OpTileBtn}" Margin="0,1,0,0">
|
|
<StackPanel>
|
|
<StackPanel Orientation="Horizontal">
|
|
<TextBlock Text="🔄" FontSize="13" Foreground="#0891B2" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
|
<TextBlock Text="Bulk Sync" FontWeight="SemiBold" FontSize="12" Foreground="#374151"/>
|
|
</StackPanel>
|
|
<TextBlock Text="Send sync to devices (all platforms)" 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>
|
|
|
|
<!-- === POLICY EXPORT panel === -->
|
|
<Border x:Name="PanelPolicyExport" Style="{StaticResource Card}" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<TextBlock Text="Policy Export" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
|
|
Foreground="$($DevConfig.ColorPrimary)"/>
|
|
<TextBlock Text="Export Intune policies and their underlying settings to portable JSON files (no assignments). Use filters for Export Selected, or Export All to capture every policy of every type. Files are always saved locally; Blob options also upload to an Azure Storage container via a SAS URL you provide."
|
|
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>
|
|
|
|
<Grid Margin="0,0,0,14">
|
|
<Grid.ColumnDefinitions>
|
|
<ColumnDefinition Width="*"/>
|
|
<ColumnDefinition Width="16"/>
|
|
<ColumnDefinition Width="Auto"/>
|
|
</Grid.ColumnDefinitions>
|
|
|
|
<StackPanel Grid.Column="0">
|
|
<StackPanel Margin="0,0,0,10">
|
|
<TextBlock Text="POLICY NAME (comma-separated keywords — 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="TxtPEXNameFilter"
|
|
Background="Transparent" Foreground="#374151" CaretBrush="#22C55E"
|
|
BorderThickness="0" Padding="10,0" FontSize="12"
|
|
VerticalContentAlignment="Center"/>
|
|
</Border>
|
|
</StackPanel>
|
|
|
|
<StackPanel Margin="0,0,0,10">
|
|
<TextBlock Text="POLICY TYPE" Style="{StaticResource FieldLabel}"/>
|
|
<ComboBox x:Name="CmbPEXType" 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 Profile"/>
|
|
<ComboBoxItem Content="Platform Script (Windows PowerShell)"/>
|
|
<ComboBoxItem Content="Detection/Remediation Script"/>
|
|
<ComboBoxItem Content="Administrative Templates"/>
|
|
<ComboBoxItem Content="Compliance Policy"/>
|
|
</ComboBox>
|
|
</StackPanel>
|
|
|
|
<StackPanel Margin="0,0,0,10">
|
|
<TextBlock Text="POLICY SUB-TYPE" Style="{StaticResource FieldLabel}"/>
|
|
<ComboBox x:Name="CmbPEXSubType" 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="Autopilot"/>
|
|
<ComboBoxItem Content="Attack Surface Reduction Rules"/>
|
|
<ComboBoxItem Content="BitLocker"/>
|
|
<ComboBoxItem Content="Built-in"/>
|
|
<ComboBoxItem Content="Certificate"/>
|
|
<ComboBoxItem Content="Custom"/>
|
|
<ComboBoxItem Content="Device Features"/>
|
|
<ComboBoxItem Content="Endpoint Protection"/>
|
|
<ComboBoxItem Content="General Device"/>
|
|
<ComboBoxItem Content="Identity Protection"/>
|
|
<ComboBoxItem Content="Kiosk"/>
|
|
<ComboBoxItem Content="Microsoft Defender Antivirus"/>
|
|
<ComboBoxItem Content="PowerShell"/>
|
|
<ComboBoxItem Content="Remediation"/>
|
|
<ComboBoxItem Content="Settings Catalog"/>
|
|
<ComboBoxItem Content="VPN"/>
|
|
<ComboBoxItem Content="Wi-Fi"/>
|
|
<ComboBoxItem Content="Windows Firewall"/>
|
|
</ComboBox>
|
|
</StackPanel>
|
|
|
|
<StackPanel>
|
|
<TextBlock Text="OS PLATFORM" Style="{StaticResource FieldLabel}"/>
|
|
<ComboBox x:Name="CmbPEXPlatform" 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>
|
|
|
|
<StackPanel Grid.Column="2" VerticalAlignment="Center">
|
|
<Button x:Name="BtnPEXSelLocal" Content="▶ Export Selected (Local)"
|
|
Style="{StaticResource PrimaryBtn}" Height="32" FontSize="12" Padding="14,4"/>
|
|
<Button x:Name="BtnPEXAllLocal" Content="▶ Export All (Local)"
|
|
Style="{StaticResource PrimaryBtn}" Height="32" FontSize="12" Padding="14,4" Margin="0,8,0,0"/>
|
|
<Button x:Name="BtnPEXSelBlob" Content="☁ Export Selected (Blob)"
|
|
Style="{StaticResource PrimaryBtn}" Height="32" FontSize="12" Padding="14,4" Margin="0,8,0,0"/>
|
|
<Button x:Name="BtnPEXAllBlob" Content="☁ Export All (Blob)"
|
|
Style="{StaticResource PrimaryBtn}" Height="32" FontSize="12" Padding="14,4" Margin="0,8,0,0"/>
|
|
</StackPanel>
|
|
</Grid>
|
|
|
|
<Border x:Name="PnlPEXProgress" Visibility="Collapsed"
|
|
Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
|
|
<StackPanel>
|
|
<TextBlock x:Name="TxtPEXProgressMsg" Text="Exporting..."
|
|
Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
|
<ProgressBar x:Name="PbPEXProgress" Height="6" IsIndeterminate="True"
|
|
Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
|
|
<TextBlock x:Name="TxtPEXProgressDetail" Text=""
|
|
Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<TextBlock x:Name="TxtPEXNoResults" Visibility="Collapsed"
|
|
Text="No policies matched the selected filters."
|
|
FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
|
|
|
|
<Border x:Name="PnlPEXResults" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<Grid Margin="0,0,0,8">
|
|
<Grid.ColumnDefinitions>
|
|
<ColumnDefinition Width="*"/>
|
|
<ColumnDefinition Width="Auto"/>
|
|
</Grid.ColumnDefinitions>
|
|
<TextBlock x:Name="TxtPEXCount" Grid.Column="0"
|
|
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
|
|
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
|
<Button x:Name="BtnPEXOpenFolder" Content="Open Folder"
|
|
Style="{StaticResource PrimaryBtn}"
|
|
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
|
|
Padding="12,4" Margin="0,0,6,0"/>
|
|
<Button x:Name="BtnPEXCopyAll" Content="Copy All"
|
|
Style="{StaticResource PrimaryBtn}"
|
|
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
|
|
Padding="12,4"/>
|
|
</StackPanel>
|
|
</Grid>
|
|
<DataGrid x:Name="DgPEXResults"
|
|
AutoGenerateColumns="False" IsReadOnly="True"
|
|
SelectionMode="Extended" SelectionUnit="FullRow"
|
|
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"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<DataGrid.Columns>
|
|
<DataGridTextColumn Header="Policy Name" Binding="{Binding DisplayName}" Width="220"/>
|
|
<DataGridTextColumn Header="Type" Binding="{Binding PolicyType}" Width="160"/>
|
|
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="90"/>
|
|
<DataGridTextColumn Header="File" Binding="{Binding FileName}" Width="240"/>
|
|
<DataGridTextColumn Header="Warnings" Binding="{Binding Warnings}" Width="300"/>
|
|
</DataGrid.Columns>
|
|
</DataGrid>
|
|
</StackPanel>
|
|
</Border>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!-- === POLICY IMPORT panel === -->
|
|
<Border x:Name="PanelPolicyImport" Style="{StaticResource Card}" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<TextBlock Text="Policy Import" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
|
|
Foreground="$($DevConfig.ColorPrimary)"/>
|
|
<TextBlock Text="Recreate policies from JSON export files into the signed-in tenant. Every import previews the planned changes first, then creates each policy as a NEW object with an “(Imported)” suffix — existing policies are never modified. Assignments are never imported."
|
|
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>
|
|
|
|
<TextBlock Text="OPTIONAL FILTERS (narrow which parsed policies get imported — applied to both Import Selected and Import All)"
|
|
FontSize="11" FontWeight="SemiBold" Foreground="#166534" Margin="0,0,0,6"/>
|
|
<Grid Margin="0,0,0,12">
|
|
<Grid.ColumnDefinitions>
|
|
<ColumnDefinition Width="*"/>
|
|
<ColumnDefinition Width="14"/>
|
|
<ColumnDefinition Width="*"/>
|
|
</Grid.ColumnDefinitions>
|
|
<StackPanel Grid.Column="0">
|
|
<StackPanel Margin="0,0,0,8">
|
|
<TextBlock Text="POLICY NAME (comma-separated keywords — 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="TxtPIMNameFilter" Background="Transparent" Foreground="#374151" CaretBrush="#22C55E"
|
|
BorderThickness="0" Padding="10,0" FontSize="12" VerticalContentAlignment="Center"/>
|
|
</Border>
|
|
</StackPanel>
|
|
<StackPanel>
|
|
<TextBlock Text="POLICY TYPE" Style="{StaticResource FieldLabel}"/>
|
|
<ComboBox x:Name="CmbPIMType" 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 Profile"/>
|
|
<ComboBoxItem Content="Platform Script (Windows PowerShell)"/>
|
|
<ComboBoxItem Content="Detection/Remediation Script"/>
|
|
<ComboBoxItem Content="Administrative Templates"/>
|
|
<ComboBoxItem Content="Compliance Policy"/>
|
|
</ComboBox>
|
|
</StackPanel>
|
|
</StackPanel>
|
|
<StackPanel Grid.Column="2">
|
|
<StackPanel Margin="0,0,0,8">
|
|
<TextBlock Text="POLICY SUB-TYPE" Style="{StaticResource FieldLabel}"/>
|
|
<ComboBox x:Name="CmbPIMSubType" 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="Autopilot"/>
|
|
<ComboBoxItem Content="Attack Surface Reduction Rules"/>
|
|
<ComboBoxItem Content="BitLocker"/>
|
|
<ComboBoxItem Content="Built-in"/>
|
|
<ComboBoxItem Content="Certificate"/>
|
|
<ComboBoxItem Content="Custom"/>
|
|
<ComboBoxItem Content="Device Features"/>
|
|
<ComboBoxItem Content="Endpoint Protection"/>
|
|
<ComboBoxItem Content="General Device"/>
|
|
<ComboBoxItem Content="Identity Protection"/>
|
|
<ComboBoxItem Content="Kiosk"/>
|
|
<ComboBoxItem Content="Microsoft Defender Antivirus"/>
|
|
<ComboBoxItem Content="PowerShell"/>
|
|
<ComboBoxItem Content="Remediation"/>
|
|
<ComboBoxItem Content="Settings Catalog"/>
|
|
<ComboBoxItem Content="VPN"/>
|
|
<ComboBoxItem Content="Wi-Fi"/>
|
|
<ComboBoxItem Content="Windows Firewall"/>
|
|
</ComboBox>
|
|
</StackPanel>
|
|
<StackPanel>
|
|
<TextBlock Text="OS PLATFORM" Style="{StaticResource FieldLabel}"/>
|
|
<ComboBox x:Name="CmbPIMPlatform" 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>
|
|
</Grid>
|
|
|
|
<StackPanel HorizontalAlignment="Left" Margin="0,0,0,14">
|
|
<StackPanel Orientation="Horizontal">
|
|
<Button x:Name="BtnPIMSelLocal" Content="▶ Import Selected (Local)"
|
|
Style="{StaticResource PrimaryBtn}" Height="32" FontSize="12" Padding="14,4"/>
|
|
<Button x:Name="BtnPIMAllLocal" Content="▶ Import All (Local)"
|
|
Style="{StaticResource PrimaryBtn}" Height="32" FontSize="12" Padding="14,4" Margin="10,0,0,0"/>
|
|
</StackPanel>
|
|
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
|
|
<Button x:Name="BtnPIMSelBlob" Content="☁ Import Selected (Blob)"
|
|
Style="{StaticResource PrimaryBtn}" Height="32" FontSize="12" Padding="14,4"/>
|
|
<Button x:Name="BtnPIMAllBlob" Content="☁ Import All (Blob)"
|
|
Style="{StaticResource PrimaryBtn}" Height="32" FontSize="12" Padding="14,4" Margin="10,0,0,0"/>
|
|
</StackPanel>
|
|
</StackPanel>
|
|
|
|
<Border x:Name="PnlPIMProgress" Visibility="Collapsed"
|
|
Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
|
|
<StackPanel>
|
|
<TextBlock x:Name="TxtPIMProgressMsg" Text="Importing..."
|
|
Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
|
<ProgressBar x:Name="PbPIMProgress" Height="6" IsIndeterminate="True"
|
|
Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
|
|
<TextBlock x:Name="TxtPIMProgressDetail" Text=""
|
|
Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<TextBlock x:Name="TxtPIMNoResults" Visibility="Collapsed"
|
|
Text="No policies were imported."
|
|
FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
|
|
|
|
<Border x:Name="PnlPIMResults" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<Grid Margin="0,0,0,8">
|
|
<Grid.ColumnDefinitions>
|
|
<ColumnDefinition Width="*"/>
|
|
<ColumnDefinition Width="Auto"/>
|
|
</Grid.ColumnDefinitions>
|
|
<TextBlock x:Name="TxtPIMCount" Grid.Column="0"
|
|
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
|
|
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
|
<Button x:Name="BtnPIMCopyAll" Content="Copy All"
|
|
Style="{StaticResource PrimaryBtn}"
|
|
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
|
|
Padding="12,4"/>
|
|
</StackPanel>
|
|
</Grid>
|
|
<DataGrid x:Name="DgPIMResults"
|
|
AutoGenerateColumns="False" IsReadOnly="True"
|
|
SelectionMode="Extended" SelectionUnit="FullRow"
|
|
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"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<DataGrid.Columns>
|
|
<DataGridTextColumn Header="Source Name" Binding="{Binding DisplayName}" Width="200"/>
|
|
<DataGridTextColumn Header="Type" Binding="{Binding PolicyType}" Width="150"/>
|
|
<DataGridTextColumn Header="Created As" Binding="{Binding PlannedName}" Width="200"/>
|
|
<DataGridTextColumn Header="Result" Binding="{Binding Result}" Width="110"/>
|
|
<DataGridTextColumn Header="Warnings" Binding="{Binding Warnings}" Width="300"/>
|
|
</DataGrid.Columns>
|
|
</DataGrid>
|
|
</StackPanel>
|
|
</Border>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
|
|
<!-- === DUPLICATE POLICY REVIEW panel === -->
|
|
<Border x:Name="PanelDupReview" Style="{StaticResource Card}" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<TextBlock Text="Duplicate Policy Review" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4" Foreground="$($DevConfig.ColorPrimary)"/>
|
|
<TextBlock Text="Scans all policy types and groups policies whose underlying settings are byte-for-byte identical (compared by content, not name). Use the filters to narrow the scan." FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>
|
|
<Grid Margin="0,0,0,14">
|
|
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="16"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
|
|
<StackPanel Grid.Column="0">
|
|
<StackPanel Margin="0,0,0,10">
|
|
<TextBlock Text="POLICY NAME (comma-separated keywords — 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="TxtDRNameFilter" Background="Transparent" Foreground="#374151" CaretBrush="#22C55E" BorderThickness="0" Padding="10,0" FontSize="12" VerticalContentAlignment="Center"/>
|
|
</Border>
|
|
</StackPanel>
|
|
<StackPanel>
|
|
<TextBlock Text="OS PLATFORM" Style="{StaticResource FieldLabel}"/>
|
|
<ComboBox x:Name="CmbDRPlatform" Style="{StaticResource StyledCombo}" Margin="0,4,0,0">
|
|
<ComboBoxItem Content="(All platforms)" IsSelected="True"/>
|
|
<ComboBoxItem Content="Windows"/>
|
|
<ComboBoxItem Content="Android"/>
|
|
<ComboBoxItem Content="macOS"/>
|
|
<ComboBoxItem Content="iOS/iPadOS"/>
|
|
</ComboBox>
|
|
</StackPanel>
|
|
</StackPanel>
|
|
<StackPanel Grid.Column="2" VerticalAlignment="Center">
|
|
<Button x:Name="BtnDRScan" Content="🔍 Find Duplicates" Style="{StaticResource PrimaryBtn}" Height="32" FontSize="12" Padding="14,4"/>
|
|
</StackPanel>
|
|
</Grid>
|
|
<Border x:Name="PnlDRProgress" Visibility="Collapsed" Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
|
|
<StackPanel>
|
|
<TextBlock x:Name="TxtDRProgressMsg" Text="Scanning..." Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
|
<ProgressBar x:Name="PbDRProgress" Height="6" IsIndeterminate="True" Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
|
|
</StackPanel>
|
|
</Border>
|
|
<TextBlock x:Name="TxtDRNoResults" Visibility="Collapsed" Text="No duplicate policies found for the selected filters." FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
|
|
<Border x:Name="PnlDRResults" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<Grid Margin="0,0,0,8">
|
|
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
|
|
<TextBlock x:Name="TxtDRCount" Grid.Column="0" FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
|
|
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
|
<Button x:Name="BtnDRExport" Content="Export CSV" Style="{StaticResource PrimaryBtn}" IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold" Padding="12,4" Margin="0,0,6,0"/>
|
|
<Button x:Name="BtnDRCopyAll" Content="Copy All" Style="{StaticResource PrimaryBtn}" IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold" Padding="12,4"/>
|
|
</StackPanel>
|
|
</Grid>
|
|
<DataGrid x:Name="DgDRResults" AutoGenerateColumns="False" IsReadOnly="True" SelectionMode="Extended" SelectionUnit="FullRow" HeadersVisibility="Column" GridLinesVisibility="All" VerticalGridLinesBrush="#555555" HorizontalGridLinesBrush="#D0D0D0" RowBackground="White" AlternatingRowBackground="#F5F5FF" BorderBrush="#B0B0D0" BorderThickness="1" CanUserResizeColumns="True" CanUserSortColumns="True" MaxHeight="420" FontSize="12" ScrollViewer.HorizontalScrollBarVisibility="Auto">
|
|
<DataGrid.Columns>
|
|
<DataGridTextColumn Header="Set" Binding="{Binding Grp}" Width="45"/>
|
|
<DataGridTextColumn Header="Policy Name" Binding="{Binding DisplayName}" Width="230"/>
|
|
<DataGridTextColumn Header="Platform" Binding="{Binding Platform}" Width="85"/>
|
|
<DataGridTextColumn Header="Policy Type" Binding="{Binding PolicyType}" Width="155"/>
|
|
<DataGridTextColumn Header="Last Modified" Binding="{Binding LastModified}" Width="95"/>
|
|
<DataGridTextColumn Header="Assignment Groups" Binding="{Binding Assignments}" Width="260"/>
|
|
</DataGrid.Columns>
|
|
</DataGrid>
|
|
</StackPanel>
|
|
</Border>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!-- === POLICY CLEANUP panel === -->
|
|
<Border x:Name="PanelPolicyCleanup" Style="{StaticResource Card}" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<TextBlock Text="Policy Cleanup" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4" Foreground="$($DevConfig.ColorPrimary)"/>
|
|
<TextBlock Text="Paste policy names (one per line), resolve them against the tenant, then back up and permanently delete. A backup (Local folder or Blob container) is REQUIRED and captures each policy's settings plus its assignment groups and filters. If any backup fails, nothing is deleted." FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>
|
|
<Grid Margin="0,0,0,12">
|
|
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="16"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
|
|
<StackPanel Grid.Column="0">
|
|
<TextBlock Text="POLICY NAMES (one per line)" Style="{StaticResource FieldLabel}"/>
|
|
<Border Background="#FFFFFF" BorderBrush="#B0D0B0" BorderThickness="1" CornerRadius="8" Height="120" Margin="0,4,0,10">
|
|
<TextBox x:Name="TxtPCNames" Background="Transparent" Foreground="#374151" CaretBrush="#22C55E" BorderThickness="0" Padding="10,6" FontSize="12" AcceptsReturn="True" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" VerticalContentAlignment="Top"/>
|
|
</Border>
|
|
<TextBlock Text="OS PLATFORM" Style="{StaticResource FieldLabel}"/>
|
|
<ComboBox x:Name="CmbPCPlatform" Style="{StaticResource StyledCombo}" Margin="0,4,0,0">
|
|
<ComboBoxItem Content="(All platforms)" IsSelected="True"/>
|
|
<ComboBoxItem Content="Windows"/>
|
|
<ComboBoxItem Content="Android"/>
|
|
<ComboBoxItem Content="macOS"/>
|
|
<ComboBoxItem Content="iOS/iPadOS"/>
|
|
</ComboBox>
|
|
</StackPanel>
|
|
<StackPanel Grid.Column="2" VerticalAlignment="Top">
|
|
<Button x:Name="BtnPCResolve" Content="✔ Resolve & Validate" Style="{StaticResource PrimaryBtn}" Height="32" FontSize="12" Padding="14,4"/>
|
|
<TextBlock Text="BACKUP TARGET" Style="{StaticResource FieldLabel}" Margin="0,14,0,0"/>
|
|
<ComboBox x:Name="CmbPCBackup" Style="{StaticResource StyledCombo}" Margin="0,4,0,0" Width="200">
|
|
<ComboBoxItem Content="Local folder" IsSelected="True"/>
|
|
<ComboBoxItem Content="Azure Blob (SAS URL)"/>
|
|
</ComboBox>
|
|
<Button x:Name="BtnPCDelete" Content="🗑 Backup & Delete" Style="{StaticResource PrimaryBtn}" Background="#DC2626" Foreground="White" FontWeight="SemiBold" IsEnabled="False" Height="32" FontSize="12" Padding="14,4" Margin="0,10,0,0"/>
|
|
</StackPanel>
|
|
</Grid>
|
|
<Border x:Name="PnlPCProgress" Visibility="Collapsed" Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
|
|
<StackPanel>
|
|
<TextBlock x:Name="TxtPCProgressMsg" Text="Working..." Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
|
<ProgressBar x:Name="PbPCProgress" Height="6" IsIndeterminate="True" Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
|
|
</StackPanel>
|
|
</Border>
|
|
<TextBlock x:Name="TxtPCNoResults" Visibility="Collapsed" Text="No policies resolved." FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
|
|
<Border x:Name="PnlPCResults" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<TextBlock x:Name="TxtPCCount" FontSize="11" Foreground="#6B7280" Margin="0,0,0,8"/>
|
|
<DataGrid x:Name="DgPCResults" AutoGenerateColumns="False" IsReadOnly="True" SelectionUnit="FullRow" HeadersVisibility="Column" GridLinesVisibility="All" RowBackground="White" AlternatingRowBackground="#F5F5FF" BorderBrush="#B0B0D0" BorderThickness="1" CanUserResizeColumns="True" MaxHeight="360" FontSize="12">
|
|
<DataGrid.Columns>
|
|
<DataGridTextColumn Header="Input Name" Binding="{Binding InputName}" Width="220"/>
|
|
<DataGridTextColumn Header="Resolved Policy" Binding="{Binding DisplayName}" Width="220"/>
|
|
<DataGridTextColumn Header="Platform" Binding="{Binding Platform}" Width="85"/>
|
|
<DataGridTextColumn Header="Policy Type" Binding="{Binding PolicyType}" Width="150"/>
|
|
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="120"/>
|
|
</DataGrid.Columns>
|
|
</DataGrid>
|
|
</StackPanel>
|
|
</Border>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!-- === RUN REMEDIATION panel === -->
|
|
<Border x:Name="PanelRunRemediation" Style="{StaticResource Card}" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<TextBlock Text="Run Remediation" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4" Foreground="$($DevConfig.ColorPrimary)"/>
|
|
<TextBlock Text="Runs a detection/remediation script on-demand against the devices you list. For safety this operation targets COMPANY-owned WINDOWS devices only — personal or non-Windows devices are resolved but automatically skipped, and shown in the confirmation summary." FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>
|
|
|
|
<TextBlock Text="SELECT YOUR SCRIPT" Style="{StaticResource FieldLabel}"/>
|
|
<Grid Margin="0,4,0,4">
|
|
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
|
|
<Border Grid.Column="0" Background="#FFFFFF" BorderBrush="#B0D0B0" BorderThickness="1" CornerRadius="8" Height="32">
|
|
<TextBox x:Name="TxtRRScriptFilter" Background="Transparent" Foreground="#374151" CaretBrush="#22C55E" BorderThickness="0" Padding="10,0" FontSize="12" VerticalContentAlignment="Center"/>
|
|
</Border>
|
|
<Button x:Name="BtnRRReload" Grid.Column="1" Content="Reload" Style="{StaticResource PrimaryBtn}" Height="30" FontSize="11" Padding="12,4" Margin="8,0,0,0"/>
|
|
</Grid>
|
|
<TextBlock Text="Type a keyword above to filter, then pick a script below (one at a time)." FontSize="10" Foreground="#6B7280" Margin="2,0,0,4"/>
|
|
<ComboBox x:Name="CmbRRScript" Style="{StaticResource StyledCombo}" Margin="0,0,0,12"/>
|
|
|
|
<Grid Margin="0,0,0,12">
|
|
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="16"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
|
|
<StackPanel Grid.Column="0">
|
|
<TextBlock Text="INPUT TYPE" Style="{StaticResource FieldLabel}"/>
|
|
<ComboBox x:Name="CmbRRInputType" Style="{StaticResource StyledCombo}" Margin="0,4,0,10">
|
|
<ComboBoxItem Content="UPN (resolve user's devices)" IsSelected="True"/>
|
|
<ComboBoxItem Content="Device Name"/>
|
|
<ComboBoxItem Content="Entra Device ID"/>
|
|
<ComboBoxItem Content="Intune Device ID"/>
|
|
<ComboBoxItem Content="Serial Number"/>
|
|
<ComboBoxItem Content="Group (User or Device)"/>
|
|
</ComboBox>
|
|
<TextBlock x:Name="TxtRRInputLabel" Text="UPNs — one per line" Style="{StaticResource FieldLabel}"/>
|
|
<Border Background="#FFFFFF" BorderBrush="#B0D0B0" BorderThickness="1" CornerRadius="8" Height="130" Margin="0,4,0,0">
|
|
<TextBox x:Name="TxtRRInputList" Background="Transparent" Foreground="#374151" CaretBrush="#22C55E" BorderThickness="0" Padding="10,6" FontSize="12" AcceptsReturn="True" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" VerticalContentAlignment="Top"/>
|
|
</Border>
|
|
</StackPanel>
|
|
<StackPanel Grid.Column="2" VerticalAlignment="Top" Width="190">
|
|
<Button x:Name="BtnRRRun" Content="▶ Run Remediation" Style="{StaticResource PrimaryBtn}" Height="34" FontSize="12" Padding="14,4"/>
|
|
<Border Background="#FFF7ED" BorderBrush="#FDBA74" BorderThickness="1" CornerRadius="6" Padding="8,6" Margin="0,10,0,0">
|
|
<TextBlock Text="Safeguard: company-owned Windows devices only. Others are skipped." FontSize="10" Foreground="#B45309" TextWrapping="Wrap"/>
|
|
</Border>
|
|
</StackPanel>
|
|
</Grid>
|
|
|
|
<Border x:Name="PnlRRProgress" Visibility="Collapsed" Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
|
|
<StackPanel>
|
|
<TextBlock x:Name="TxtRRProgressMsg" Text="Working..." Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
|
<ProgressBar x:Name="PbRRProgress" Height="6" IsIndeterminate="True" Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<Border x:Name="PnlRRResults" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<TextBlock x:Name="TxtRRCount" FontSize="11" Foreground="#6B7280" Margin="0,0,0,8"/>
|
|
<DataGrid x:Name="DgRRResults" AutoGenerateColumns="False" IsReadOnly="True" SelectionUnit="FullRow" HeadersVisibility="Column" GridLinesVisibility="All" RowBackground="White" AlternatingRowBackground="#F5F5FF" BorderBrush="#B0B0D0" BorderThickness="1" CanUserResizeColumns="True" MaxHeight="360" FontSize="12" ScrollViewer.HorizontalScrollBarVisibility="Auto">
|
|
<DataGrid.Columns>
|
|
<DataGridTextColumn Header="Device" Binding="{Binding DeviceName}" Width="200"/>
|
|
<DataGridTextColumn Header="OS" Binding="{Binding OS}" Width="90"/>
|
|
<DataGridTextColumn Header="Ownership" Binding="{Binding Ownership}" Width="90"/>
|
|
<DataGridTextColumn Header="User" Binding="{Binding UPN}" Width="200"/>
|
|
<DataGridTextColumn Header="Result" Binding="{Binding Result}" Width="240"/>
|
|
</DataGrid.Columns>
|
|
</DataGrid>
|
|
</StackPanel>
|
|
</Border>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!-- === BULK SYNC panel === -->
|
|
<Border x:Name="PanelBulkSync" Style="{StaticResource Card}" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<TextBlock Text="Bulk Sync" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4" Foreground="$($DevConfig.ColorPrimary)"/>
|
|
<TextBlock Text="Sends a sync (check-in) command to the devices you list, so they pull the latest Intune policies and pending actions. Works across all platforms and both corporate and personal devices. Use the platform filter to narrow the scope." FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>
|
|
|
|
<Grid Margin="0,0,0,12">
|
|
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="16"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
|
|
<StackPanel Grid.Column="0">
|
|
<TextBlock Text="INPUT TYPE" Style="{StaticResource FieldLabel}"/>
|
|
<ComboBox x:Name="CmbBSInputType" Style="{StaticResource StyledCombo}" Margin="0,4,0,10">
|
|
<ComboBoxItem Content="UPN (resolve user's devices)" IsSelected="True"/>
|
|
<ComboBoxItem Content="Device Name"/>
|
|
<ComboBoxItem Content="Entra Device ID"/>
|
|
<ComboBoxItem Content="Intune Device ID"/>
|
|
<ComboBoxItem Content="Serial Number"/>
|
|
<ComboBoxItem Content="Group (User or Device)"/>
|
|
</ComboBox>
|
|
<TextBlock x:Name="TxtBSInputLabel" Text="UPNs — one per line" Style="{StaticResource FieldLabel}"/>
|
|
<Border Background="#FFFFFF" BorderBrush="#B0D0B0" BorderThickness="1" CornerRadius="8" Height="130" Margin="0,4,0,0">
|
|
<TextBox x:Name="TxtBSInputList" Background="Transparent" Foreground="#374151" CaretBrush="#22C55E" BorderThickness="0" Padding="10,6" FontSize="12" AcceptsReturn="True" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" VerticalContentAlignment="Top"/>
|
|
</Border>
|
|
</StackPanel>
|
|
<StackPanel Grid.Column="2" VerticalAlignment="Top" Width="200">
|
|
<TextBlock Text="PLATFORM FILTER" Style="{StaticResource FieldLabel}"/>
|
|
<ComboBox x:Name="CmbBSPlatform" Style="{StaticResource StyledCombo}" Margin="0,4,0,12">
|
|
<ComboBoxItem Content="(All platforms)" IsSelected="True"/>
|
|
<ComboBoxItem Content="Windows"/>
|
|
<ComboBoxItem Content="Android"/>
|
|
<ComboBoxItem Content="iOS/iPadOS"/>
|
|
<ComboBoxItem Content="macOS"/>
|
|
</ComboBox>
|
|
<Button x:Name="BtnBSRun" Content="🔄 Bulk Sync" Style="{StaticResource PrimaryBtn}" Height="34" FontSize="12" Padding="14,4"/>
|
|
</StackPanel>
|
|
</Grid>
|
|
|
|
<Border x:Name="PnlBSProgress" Visibility="Collapsed" Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
|
|
<StackPanel>
|
|
<TextBlock x:Name="TxtBSProgressMsg" Text="Working..." Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
|
<ProgressBar x:Name="PbBSProgress" Height="6" IsIndeterminate="True" Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<Border x:Name="PnlBSResults" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<TextBlock x:Name="TxtBSCount" FontSize="11" Foreground="#6B7280" Margin="0,0,0,8"/>
|
|
<DataGrid x:Name="DgBSResults" AutoGenerateColumns="False" IsReadOnly="True" SelectionUnit="FullRow" HeadersVisibility="Column" GridLinesVisibility="All" RowBackground="White" AlternatingRowBackground="#F5F5FF" BorderBrush="#B0B0D0" BorderThickness="1" CanUserResizeColumns="True" MaxHeight="360" FontSize="12" ScrollViewer.HorizontalScrollBarVisibility="Auto">
|
|
<DataGrid.Columns>
|
|
<DataGridTextColumn Header="Device" Binding="{Binding DeviceName}" Width="200"/>
|
|
<DataGridTextColumn Header="OS" Binding="{Binding OS}" Width="90"/>
|
|
<DataGridTextColumn Header="Ownership" Binding="{Binding Ownership}" Width="90"/>
|
|
<DataGridTextColumn Header="User" Binding="{Binding UPN}" Width="200"/>
|
|
<DataGridTextColumn Header="Result" Binding="{Binding Result}" Width="240"/>
|
|
</DataGrid.Columns>
|
|
</DataGrid>
|
|
</StackPanel>
|
|
</Border>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!-- === 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. Accepts bulk input: UPN, Device Name, Device ID, Group Name, or Group ID - one per line."
|
|
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,16" TextWrapping="Wrap"/>
|
|
<StackPanel Margin="0,0,0,12">
|
|
<TextBlock Text="SEARCH INPUT - one entry 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="TxtSearchKeyword" Grid.Column="0" Style="{StaticResource InputBox}"
|
|
Height="110" TextWrapping="Wrap" AcceptsReturn="True"
|
|
VerticalScrollBarVisibility="Auto" Tag="One entry per line: UPN, Device Name, Device ID, Group Name, or Group ID"/>
|
|
<Button x:Name="BtnEntraSearch" Grid.Column="2"
|
|
Content="🔍 Search"
|
|
Style="{StaticResource PrimaryBtn}" Width="110" VerticalAlignment="Top"/>
|
|
</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="ChkSrchGetManager" Content="Get Manager" IsChecked="False" Margin="0,0,20,4" FontSize="12" ToolTip="When checked, adds Manager UPN column for User objects"/>
|
|
<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="✕"
|
|
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="🔍 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" CanUserResizeColumns="True"
|
|
CanUserSortColumns="True" MaxHeight="420" FontSize="12"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<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 a1b2c3d4-0000-0000-0000-000000000000 IT-Devices"/>
|
|
<Button x:Name="BtnLMQuery" Grid.Column="2"
|
|
Content="📋 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 & 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="✕"
|
|
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="🔍 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" CanUserResizeColumns="True"
|
|
CanUserSortColumns="True" MaxHeight="420" FontSize="12"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<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="▶ 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="✕"
|
|
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="🔍 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" CanUserResizeColumns="True"
|
|
CanUserSortColumns="True" MaxHeight="420" FontSize="12"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<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="▶ 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="✕"
|
|
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="🔍 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" CanUserResizeColumns="True"
|
|
CanUserSortColumns="True" MaxHeight="420" FontSize="12"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<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 groups found matching the criteria."
|
|
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 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 GroupName or GroupObjectID 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>
|
|
|
|
<!-- Administrative Unit (optional) -->
|
|
<StackPanel Margin="0,0,0,12">
|
|
<TextBlock Text="ADMINISTRATIVE UNIT (optional)" Style="{StaticResource FieldLabel}"/>
|
|
<ComboBox x:Name="CmbCreateAU" Style="{StaticResource StyledCombo}"
|
|
HorizontalAlignment="Left" Width="360">
|
|
<ComboBoxItem Content="(None)" IsSelected="True" Tag=""/>
|
|
</ComboBox>
|
|
<TextBlock x:Name="TxtAULoading" Text="Loading Administrative Units..."
|
|
FontSize="10" Foreground="Gray" Margin="0,2,0,0" Visibility="Collapsed"/>
|
|
</StackPanel>
|
|
|
|
<Button Margin="4,4,0,0" x:Name="BtnCreateValidate" Content="Validate & Preview →"
|
|
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 GroupName or GroupObjectID DeviceName or DeviceObjectID"/>
|
|
</StackPanel>
|
|
|
|
<Button Margin="4,4,0,0" x:Name="BtnMemberValidate" Content="Validate & Preview →"
|
|
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…"
|
|
Style="{StaticResource OutlineBtn}" Width="80"/>
|
|
</Grid>
|
|
</StackPanel>
|
|
|
|
<Button Margin="4,4,0,0" x:Name="BtnRunExport" Content="Export Members →"
|
|
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="⚠ 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 & Preview →"
|
|
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 & Preview →"
|
|
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 Group-Finance-EMEA 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 owner2@domain.com"/>
|
|
</StackPanel>
|
|
|
|
<Button Margin="4,4,0,0" x:Name="BtnOwnerValidate" Content="Validate & Preview →"
|
|
Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="180"/>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!-- === GET GROUP OWNER panel === -->
|
|
<Border x:Name="PanelGetGroupOwner" Style="{StaticResource Card}" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<TextBlock Text="Get 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="TxtGGOGroupList" Style="{StaticResource InputBox}"
|
|
Height="110" TextWrapping="Wrap" AcceptsReturn="True"
|
|
VerticalScrollBarVisibility="Auto"
|
|
Tag="Group-Finance-APAC Group-Finance-EMEA b3f1a2c0-..."/>
|
|
</StackPanel>
|
|
|
|
<Button Margin="4,4,0,0" x:Name="BtnGGORun" Content="Get Owners →"
|
|
Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="140"/>
|
|
|
|
<!-- Results DataGrid -->
|
|
<Border x:Name="PnlGGOResults" Visibility="Collapsed">
|
|
<StackPanel Margin="0,12,0,0">
|
|
<Grid Margin="0,0,0,8">
|
|
<Grid.ColumnDefinitions>
|
|
<ColumnDefinition Width="*"/>
|
|
<ColumnDefinition Width="Auto"/>
|
|
</Grid.ColumnDefinitions>
|
|
<TextBlock x:Name="TxtGGOCount" Grid.Column="0"
|
|
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
|
|
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
|
<Button x:Name="BtnGGOFilterClear" Content="✕"
|
|
Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
|
|
IsEnabled="False" Height="30" Width="30" FontSize="12"
|
|
Padding="0" Margin="0,0,4,0"/>
|
|
<TextBox x:Name="TxtGGOFilter" Width="150" Height="30" FontSize="12"
|
|
VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
|
|
IsEnabled="False" Tag="keyword filter..."/>
|
|
<Button x:Name="BtnGGOFilter" Content="🔍 Filter"
|
|
Style="{StaticResource PrimaryBtn}"
|
|
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
|
|
Padding="12,4" Margin="0,0,12,0"/>
|
|
<Button x:Name="BtnGGOCopyValue" 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="BtnGGOCopyRow" 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="BtnGGOCopyAll" 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="BtnGGOExportXlsx" Content="Export XLSX"
|
|
Style="{StaticResource PrimaryBtn}"
|
|
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
|
|
Padding="12,4"/>
|
|
</StackPanel>
|
|
</Grid>
|
|
<DataGrid x:Name="DgGGOResults"
|
|
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" CanUserResizeColumns="True"
|
|
CanUserSortColumns="True" MaxHeight="420" FontSize="12"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<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*"/>
|
|
<DataGridTextColumn Header="Owner UPN" Binding="{Binding OwnerUPN}" Width="2*"/>
|
|
<DataGridTextColumn Header="Job Title" Binding="{Binding JobTitle}" Width="*"/>
|
|
<DataGridTextColumn Header="Country" Binding="{Binding Country}" Width="*"/>
|
|
<DataGridTextColumn Header="Manager" Binding="{Binding Manager}" Width="*"/>
|
|
</DataGrid.Columns>
|
|
</DataGrid>
|
|
</StackPanel>
|
|
</Border>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
|
|
<!-- === USER DIRECT REPORTS panel === -->
|
|
<Border x:Name="PanelUserDirectReports" Style="{StaticResource Card}" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<TextBlock Text="User Direct Reports" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,12"
|
|
Foreground="$($DevConfig.ColorPrimary)"/>
|
|
<StackPanel Margin="0,0,0,12">
|
|
<TextBlock Text="USER UPNs / OBJECT IDs - one per line"
|
|
Style="{StaticResource FieldLabel}"/>
|
|
<TextBox x:Name="TxtUDRUserList" Style="{StaticResource InputBox}"
|
|
Height="110" TextWrapping="Wrap" AcceptsReturn="True"
|
|
VerticalScrollBarVisibility="Auto"
|
|
Tag="user1@domain.com user2@domain.com 00000000-0000-..."/>
|
|
</StackPanel>
|
|
<Button Margin="4,4,0,0" x:Name="BtnUDRRun" Content="Get Direct Reports →"
|
|
Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left" Width="180"/>
|
|
<Border x:Name="PnlUDRResults" Visibility="Collapsed">
|
|
<StackPanel Margin="0,12,0,0">
|
|
<Grid Margin="0,0,0,8">
|
|
<Grid.ColumnDefinitions>
|
|
<ColumnDefinition Width="*"/>
|
|
<ColumnDefinition Width="Auto"/>
|
|
</Grid.ColumnDefinitions>
|
|
<TextBlock x:Name="TxtUDRCount" Grid.Column="0"
|
|
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
|
|
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
|
<Button x:Name="BtnUDRFilterClear" Content="✕"
|
|
Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
|
|
IsEnabled="False" Height="30" Width="30" FontSize="12"
|
|
Padding="0" Margin="0,0,4,0"/>
|
|
<TextBox x:Name="TxtUDRFilter" Width="150" Height="30" FontSize="12"
|
|
VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
|
|
IsEnabled="False" Tag="keyword filter..."/>
|
|
<Button x:Name="BtnUDRFilter" Content="🔍 Filter"
|
|
Style="{StaticResource PrimaryBtn}"
|
|
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
|
|
Padding="12,4" Margin="0,0,12,0"/>
|
|
<Button x:Name="BtnUDRCopyValue" 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="BtnUDRCopyRow" 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="BtnUDRCopyAll" 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="BtnUDRExportXlsx" Content="Export XLSX"
|
|
Style="{StaticResource PrimaryBtn}"
|
|
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
|
|
Padding="12,4"/>
|
|
</StackPanel>
|
|
</Grid>
|
|
<DataGrid x:Name="DgUDRResults"
|
|
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" CanUserResizeColumns="True"
|
|
CanUserSortColumns="True" MaxHeight="420" FontSize="12"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<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 User UPN" Binding="{Binding InputUserUPN}" Width="2*"/>
|
|
<DataGridTextColumn Header="Input Object ID" Binding="{Binding InputObjectId}" Width="2*"/>
|
|
<DataGridTextColumn Header="Direct Report UPN" Binding="{Binding DirectReportUPN}" Width="2*"/>
|
|
<DataGridTextColumn Header="Direct Report Object ID" Binding="{Binding DirectReportObjectId}" Width="2*"/>
|
|
</DataGrid.Columns>
|
|
</DataGrid>
|
|
</StackPanel>
|
|
</Border>
|
|
</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 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 & Preview →"
|
|
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="🔍 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="✕"
|
|
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="🔍 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" CanUserResizeColumns="True"
|
|
CanUserSortColumns="True" MaxHeight="420" FontSize="12"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<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="🔍 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="✕"
|
|
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="🔍 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" CanUserResizeColumns="True"
|
|
CanUserSortColumns="True" MaxHeight="420" FontSize="12"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<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 + Hardware + Compliance filters -->
|
|
<StackPanel Margin="0,0,0,12">
|
|
<TextBlock Text="FILTERS" Style="{StaticResource FieldLabel}"/>
|
|
<WrapPanel Margin="0,4,0,0" Orientation="Horizontal">
|
|
<CheckBox x:Name="ChkGDIWindows" Content="Windows" Margin="0,0,12,6" FontSize="12" VerticalAlignment="Center"/>
|
|
<CheckBox x:Name="ChkGDIAndroid" Content="Android" Margin="0,0,12,6" FontSize="12" VerticalAlignment="Center"/>
|
|
<CheckBox x:Name="ChkGDIiOS" Content="iOS" Margin="0,0,12,6" FontSize="12" VerticalAlignment="Center"/>
|
|
<CheckBox x:Name="ChkGDIMacOS" Content="macOS" Margin="0,0,20,6" FontSize="12" VerticalAlignment="Center"/>
|
|
<Border Width="1" Height="22" Background="#D0D0D0" Margin="0,0,12,6" VerticalAlignment="Center"/>
|
|
<TextBlock Text="Manufacturer:" FontSize="11" VerticalAlignment="Center" Foreground="#9CA3AF" Margin="0,0,4,6"/>
|
|
<TextBox x:Name="TxtGDIManufacturer" Width="130" Height="26" FontSize="12"
|
|
Background="White" Foreground="#374151" BorderBrush="#B0D0B0" BorderThickness="1"
|
|
Padding="6,3" VerticalContentAlignment="Center" Margin="0,0,12,6"/>
|
|
<TextBlock Text="Model:" FontSize="11" VerticalAlignment="Center" Foreground="#9CA3AF" Margin="0,0,4,6"/>
|
|
<TextBox x:Name="TxtGDIModel" Width="130" Height="26" FontSize="12"
|
|
Background="White" Foreground="#374151" BorderBrush="#B0D0B0" BorderThickness="1"
|
|
Padding="6,3" VerticalContentAlignment="Center" Margin="0,0,12,6"/>
|
|
<Border Width="1" Height="22" Background="#D0D0D0" Margin="0,0,12,6" VerticalAlignment="Center"/>
|
|
<TextBlock Text="Compliance:" FontSize="11" VerticalAlignment="Center" Foreground="#9CA3AF" Margin="0,0,4,6"/>
|
|
<ComboBox x:Name="CmbGDICompliance" Style="{StaticResource StyledCombo}"
|
|
Width="160" Height="30" VerticalAlignment="Center" Margin="0,0,0,6">
|
|
<ComboBoxItem Content="All" IsSelected="True"/>
|
|
<ComboBoxItem Content="Compliant"/>
|
|
<ComboBoxItem Content="Non-Compliant"/>
|
|
</ComboBox>
|
|
</WrapPanel>
|
|
<TextBlock Text="Platform: leave all unchecked for all | HW filters: optional case-insensitive match"
|
|
FontSize="10" Foreground="#9CA3AF" Margin="0,2,0,0"/>
|
|
</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="▶ Get All Device Info"
|
|
Style="{StaticResource PrimaryBtn}" Width="200" HorizontalAlignment="Left" Margin="0,4,0,0"/>
|
|
<Button x:Name="BtnGDIRun" Content="▶ 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,8"/>
|
|
<Grid Margin="0,0,0,8">
|
|
<Grid.ColumnDefinitions>
|
|
<ColumnDefinition Width="Auto"/>
|
|
<ColumnDefinition Width="*"/>
|
|
</Grid.ColumnDefinitions>
|
|
<Grid.RowDefinitions>
|
|
<RowDefinition Height="Auto"/>
|
|
<RowDefinition Height="Auto"/>
|
|
<RowDefinition Height="Auto"/>
|
|
<RowDefinition Height="Auto"/>
|
|
</Grid.RowDefinitions>
|
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Total Devices:" Foreground="#6BB88B" FontSize="11" Margin="0,1,16,1"/>
|
|
<TextBlock Grid.Row="0" Grid.Column="1" x:Name="TxtGDIMetricTotal" Text="-" Foreground="#C7F0DB" FontSize="11" FontWeight="SemiBold" Margin="0,1,0,1"/>
|
|
<TextBlock Grid.Row="1" Grid.Column="0" Text="Devices Processed:" Foreground="#6BB88B" FontSize="11" Margin="0,1,16,1"/>
|
|
<TextBlock Grid.Row="1" Grid.Column="1" x:Name="TxtGDIMetricProcessed" Text="0" Foreground="#C7F0DB" FontSize="11" FontWeight="SemiBold" Margin="0,1,0,1"/>
|
|
<TextBlock Grid.Row="2" Grid.Column="0" Text="Devices Remaining:" Foreground="#6BB88B" FontSize="11" Margin="0,1,16,1"/>
|
|
<TextBlock Grid.Row="2" Grid.Column="1" x:Name="TxtGDIMetricRemaining" Text="-" Foreground="#C7F0DB" FontSize="11" FontWeight="SemiBold" Margin="0,1,0,1"/>
|
|
<TextBlock Grid.Row="3" Grid.Column="0" Text="Estimated Time Remaining:" Foreground="#6BB88B" FontSize="11" Margin="0,1,16,1"/>
|
|
<TextBlock Grid.Row="3" Grid.Column="1" x:Name="TxtGDIMetricETA" Text="Calculating..." Foreground="#C7F0DB" FontSize="11" FontWeight="SemiBold" Margin="0,1,0,1"/>
|
|
</Grid>
|
|
<ProgressBar x:Name="PbGDIProgress" Height="6" Minimum="0" Maximum="100" Value="0"
|
|
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="✕"
|
|
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="🔍 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" CanUserResizeColumns="True"
|
|
CanUserSortColumns="True" MaxHeight="420" FontSize="12"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- === MISSING BITLOCKER KEYS panel === -->
|
|
<Border x:Name="PanelMissingBitlocker" Style="{StaticResource Card}" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<TextBlock Text="Missing BitLocker Keys" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
|
|
Foreground="$($DevConfig.ColorPrimary)"/>
|
|
<TextBlock Text="Identifies Windows devices missing an OS drive BitLocker recovery key in Entra. Does NOT retrieve or expose any actual recovery keys."
|
|
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>
|
|
|
|
<!-- Ownership filter -->
|
|
<StackPanel Margin="0,0,0,12">
|
|
<TextBlock Text="OWNERSHIP FILTER" Style="{StaticResource FieldLabel}"/>
|
|
<ComboBox x:Name="CmbMBKOwnership" Style="{StaticResource StyledCombo}"
|
|
Width="200" HorizontalAlignment="Left" Margin="0,4,0,0" Height="30">
|
|
<ComboBoxItem Content="All ownership types" IsSelected="True"/>
|
|
<ComboBoxItem Content="Corporate only"/>
|
|
<ComboBoxItem Content="Personal only"/>
|
|
</ComboBox>
|
|
</StackPanel>
|
|
|
|
<!-- Exclude filter -->
|
|
<StackPanel Margin="0,0,0,12">
|
|
<TextBlock Text="EXCLUDE" Style="{StaticResource FieldLabel}"/>
|
|
<ComboBox x:Name="CmbMBKExclude" Style="{StaticResource StyledCombo}"
|
|
Width="200" HorizontalAlignment="Left" Margin="0,4,0,0" Height="30">
|
|
<ComboBoxItem Content="None" IsSelected="True"/>
|
|
<ComboBoxItem Content="AVDs"/>
|
|
<ComboBoxItem Content="Physical Devices"/>
|
|
</ComboBox>
|
|
</StackPanel>
|
|
|
|
<!-- Enrollment filter -->
|
|
<StackPanel Margin="0,0,0,12">
|
|
<TextBlock Text="INTUNE ENROLLMENT" Style="{StaticResource FieldLabel}"/>
|
|
<ComboBox x:Name="CmbMBKEnrollment" Style="{StaticResource StyledCombo}"
|
|
Width="240" HorizontalAlignment="Left" Margin="0,4,0,0" Height="30">
|
|
<ComboBoxItem Content="Both" IsSelected="True"/>
|
|
<ComboBoxItem Content="Intune enrolled only"/>
|
|
<ComboBoxItem Content="Not enrolled only"/>
|
|
</ComboBox>
|
|
</StackPanel>
|
|
|
|
<!-- Run buttons row: missing-keys (left) + older-than-N-days (right) -->
|
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,16" VerticalAlignment="Bottom">
|
|
<Button x:Name="BtnMBKRun" Content="▶ Get Devices with Missing BitLocker Keys in Entra"
|
|
Style="{StaticResource PrimaryBtn}" Width="380" HorizontalAlignment="Left" Margin="0,4,0,0"
|
|
Background="#166534" Foreground="White" FontWeight="SemiBold"
|
|
VerticalAlignment="Bottom"/>
|
|
<StackPanel Orientation="Vertical" Margin="20,4,0,0" VerticalAlignment="Bottom">
|
|
<TextBlock Text="DAYS (0-60)" Style="{StaticResource FieldLabel}"/>
|
|
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
|
|
<TextBox x:Name="TxtMBKOlderDays" Width="80" Height="30" FontSize="12"
|
|
VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,8,0"
|
|
Tag="0-60" MaxLength="2"/>
|
|
<Button x:Name="BtnMBKRunOlder" Content="▶ Get Devices with BitLocker Key older than"
|
|
Style="{StaticResource PrimaryBtn}" HorizontalAlignment="Left"
|
|
Background="#166534" Foreground="White" FontWeight="SemiBold"
|
|
Padding="14,4"/>
|
|
</StackPanel>
|
|
</StackPanel>
|
|
</StackPanel>
|
|
|
|
<!-- Progress -->
|
|
<Border x:Name="PnlMBKProgress" Visibility="Collapsed"
|
|
Background="#1A2E1A" CornerRadius="6" Padding="16,12" Margin="0,0,0,12">
|
|
<StackPanel>
|
|
<TextBlock x:Name="TxtMBKProgressMsg" Text="Starting..."
|
|
Foreground="#A3E4C0" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
|
<ProgressBar x:Name="PbMBKProgress" Height="6" Minimum="0" Maximum="100" Value="0"
|
|
IsIndeterminate="True"
|
|
Background="#2A4A2A" Foreground="#22C55E" BorderThickness="0"/>
|
|
<TextBlock x:Name="TxtMBKProgressDetail" Text=""
|
|
Foreground="#6BB88B" FontSize="11" Margin="0,5,0,0"/>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!-- Results -->
|
|
<Border x:Name="PnlMBKResults" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<Grid Margin="0,0,0,8">
|
|
<Grid.ColumnDefinitions>
|
|
<ColumnDefinition Width="*"/>
|
|
<ColumnDefinition Width="Auto"/>
|
|
</Grid.ColumnDefinitions>
|
|
<TextBlock x:Name="TxtMBKCount" Grid.Column="0"
|
|
FontSize="11" Foreground="#6B7280" VerticalAlignment="Center"/>
|
|
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
|
<Button x:Name="BtnMBKFilterClear" Content="✕"
|
|
Style="{StaticResource PrimaryBtn}" ToolTip="Clear filter"
|
|
IsEnabled="False" Height="30" Width="30" FontSize="12"
|
|
Padding="0" Margin="0,0,4,0"/>
|
|
<TextBox x:Name="TxtMBKFilter" Width="150" Height="30" FontSize="12"
|
|
VerticalContentAlignment="Center" Padding="6,0" Margin="0,0,6,0"
|
|
IsEnabled="False" Tag="keyword filter..."/>
|
|
<Button x:Name="BtnMBKFilter" Content="🔍 Filter"
|
|
Style="{StaticResource PrimaryBtn}"
|
|
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
|
|
Padding="12,4" Margin="0,0,12,0"/>
|
|
<Button x:Name="BtnMBKCopyValue" 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="BtnMBKCopyRow" 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="BtnMBKCopyAll" 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="BtnMBKExportXlsx" Content="Export XLSX"
|
|
Style="{StaticResource PrimaryBtn}"
|
|
IsEnabled="False" Height="30" FontSize="12" FontWeight="SemiBold"
|
|
Padding="12,4"/>
|
|
</StackPanel>
|
|
</Grid>
|
|
<DataGrid x:Name="DgMBKResults"
|
|
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"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<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="Key Created" Binding="{Binding Key_CreatedDate}" Width="*" MinWidth="100"/>
|
|
<DataGridTextColumn Header="Key Age (days)" Binding="{Binding Key_AgeDays}" Width="*" MinWidth="80"/>
|
|
<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="TxtMBKNoResults" Visibility="Collapsed"
|
|
Text="All devices have OS drive BitLocker recovery keys in Entra."
|
|
FontSize="12" Foreground="#6B7280" Margin="0,12,0,0" TextWrapping="Wrap"/>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!-- === GET APP ASSIGNMENTS panel === -->
|
|
<Border x:Name="PanelGetDiscoveredApps" Style="{StaticResource Card}" Visibility="Collapsed">
|
|
<StackPanel>
|
|
<TextBlock Text="Get App Info" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4"
|
|
Foreground="$($DevConfig.ColorPrimary)"/>
|
|
<TextBlock Text="Reports discovered and managed apps from Intune. Supports device-level discovery and tenant-wide managed app queries with assignment details."
|
|
FontSize="11" Foreground="$($DevConfig.ColorTextMuted)" Margin="0,0,0,14" TextWrapping="Wrap"/>
|
|
|
|
|
|
<StackPanel x:Name="PnlDAInputArea">
|
|
<!-- 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>
|
|
</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 x:Name="PnlDAOwnership" 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>
|
|
|
|
<!-- App Source filter -->
|
|
<StackPanel Margin="0,0,0,12">
|
|
<TextBlock Text="APP SOURCE (select one or both)" Style="{StaticResource FieldLabel}"/>
|
|
<WrapPanel Margin="0,4,0,0">
|
|
<CheckBox x:Name="ChkDADiscoveredApps" Content="Discovered Apps" IsChecked="True" Margin="0,0,16,4" FontSize="12"/>
|
|
<CheckBox x:Name="ChkDAManagedApps" Content="Managed Apps" IsChecked="True" Margin="0,0,0,4" FontSize="12"/>
|
|
</WrapPanel>
|
|
</StackPanel>
|
|
|
|
<!-- Action buttons -->
|
|
<WrapPanel Margin="0,0,0,16">
|
|
<Button x:Name="BtnDARun" Content="▶ Get Selected Apps"
|
|
Style="{StaticResource PrimaryBtn}"
|
|
Height="34" FontSize="13" FontWeight="Bold" Padding="18,6" Margin="0,0,10,0"/>
|
|
<Button x:Name="BtnDARunAll" Content="▶ All Discovered Apps - All Devices"
|
|
Style="{StaticResource PrimaryBtn}"
|
|
Height="34" FontSize="13" FontWeight="Bold" Padding="18,6" Margin="0,0,10,0"/>
|
|
<Button x:Name="BtnDAGetManagedAssignments" Content="▶ All Managed App Assignments"
|
|
Style="{StaticResource PrimaryBtn}"
|
|
Height="34" FontSize="13" FontWeight="Bold" Padding="18,6"/>
|
|
</WrapPanel>
|
|
|
|
|
|
<!-- 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="✕"
|
|
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="🔍 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" CanUserResizeColumns="True"
|
|
CanUserSortColumns="True" MaxHeight="420" FontSize="12"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<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>
|
|
</StackPanel>
|
|
</Border>
|
|
<TextBlock x:Name="TxtDANoResults" Visibility="Collapsed"
|
|
Text="No 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 Info" 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 — 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 Profile"/>
|
|
<ComboBoxItem Content="Platform Script (Windows PowerShell)"/>
|
|
<ComboBoxItem Content="Detection/Remediation Script"/>
|
|
<ComboBoxItem Content="Administrative Templates"/>
|
|
<ComboBoxItem Content="Compliance Policy"/>
|
|
</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="Autopilot"/>
|
|
<ComboBoxItem Content="Autopilot (Device Prep)"/>
|
|
<ComboBoxItem Content="Autopilot ESP"/>
|
|
<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="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="▶ Get Selected Assignments"
|
|
Style="{StaticResource PrimaryBtn}" Height="32" FontSize="12" Padding="14,4"/>
|
|
<Button x:Name="BtnGPAGetAll" Content="▶ 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="✕"
|
|
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="🔍 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"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<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"/>
|
|
<DataGridTextColumn Header="Description" Binding="{Binding Description}" Width="200" MaxWidth="250">
|
|
<DataGridTextColumn.ElementStyle>
|
|
<Style TargetType="TextBlock">
|
|
<Setter Property="TextWrapping" Value="NoWrap"/>
|
|
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
|
|
</Style>
|
|
</DataGridTextColumn.ElementStyle>
|
|
</DataGridTextColumn>
|
|
<DataGridTextColumn Header="Deployment Mode" Binding="{Binding DeploymentMode}" Width="*" MinWidth="110"/>
|
|
<DataGridTextColumn Header="Priority" Binding="{Binding Priority}" Width="80" MinWidth="60"/>
|
|
</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 — 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 SG-Sales-All 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="✕"
|
|
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="🔍 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" CanUserResizeColumns="True"
|
|
CanUserSortColumns="True" MaxHeight="420" FontSize="12"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<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="⚠ 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 — one per line" Style="{StaticResource FieldLabel}"/>
|
|
<TextBox x:Name="TxtRGGroupList" Style="{StaticResource InputBox}"
|
|
Height="100" TextWrapping="Wrap" AcceptsReturn="True"
|
|
VerticalScrollBarVisibility="Auto"
|
|
Tag="GroupDisplayName 00000000-0000-0000-0000-000000000001 AnotherGroup"/>
|
|
</StackPanel>
|
|
|
|
<!-- Validate button -->
|
|
<Button x:Name="BtnRGValidate" Content="✔ 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="⚠" 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="🗑 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" CanUserResizeColumns="True"
|
|
CanUserSortColumns="True" MaxHeight="280" FontSize="12"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<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" CanUserResizeColumns="True"
|
|
CanUserSortColumns="True" MaxHeight="280" FontSize="12"
|
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
|
EnableRowVirtualization="True"
|
|
VirtualizingStackPanel.IsVirtualizing="True"
|
|
VirtualizingStackPanel.VirtualizationMode="Recycling">
|
|
<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="🗒" 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="✕" 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…"/>
|
|
<!-- 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','PanelGetGroupOwner','PanelUserDirectReports','PanelMissingBitlocker','PanelPolicyExport','PanelPolicyImport','PanelDupReview','PanelPolicyCleanup','PanelRunRemediation','PanelBulkSync')
|
|
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="✓ Confirm & 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
|
|
}
|
|
|
|
# OData filter encoding is inlined at each call site (no separate function)
|
|
|
|
|
|
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 }
|
|
}
|
|
|
|
# Strategy 1: $filter with OData eq (handles most names)
|
|
try {
|
|
$safe_trimmed = $trimmed -replace "'","''"
|
|
$safe_trimmed = $safe_trimmed -replace '%','%25'
|
|
$safe_trimmed = $safe_trimmed -replace '\+','%2B'
|
|
$safe_trimmed = $safe_trimmed -replace '#','%23'
|
|
$safe_trimmed = $safe_trimmed -replace '&','%26'
|
|
$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 {}
|
|
|
|
# Strategy 2: $search with ConsistencyLevel:eventual (handles / and other special chars)
|
|
# $search uses tokenized matching so we do a client-side exact match afterwards
|
|
try {
|
|
$searchEncoded = [Uri]::EscapeDataString($trimmed)
|
|
$searchUri = 'https://graph.microsoft.com/v1.0/groups?$search=%22displayName:' + $searchEncoded + '%22&$select=id,displayName,groupTypes,membershipRule,mailEnabled,securityEnabled&$top=50&$count=true'
|
|
$searchHeaders = @{ 'ConsistencyLevel' = 'eventual' }
|
|
Write-VerboseLog ('Search fallback for: ' + $trimmed) -Level Info
|
|
$resp2 = Invoke-MgGraphRequest -Method GET -Uri $searchUri -Headers $searchHeaders
|
|
Write-VerboseLog ('Search returned ' + $resp2['value'].Count + ' candidates') -Level Info
|
|
if ($resp2['value']) {
|
|
foreach ($candidate in $resp2['value']) {
|
|
if ($candidate['displayName'].Trim() -eq $trimmed.Trim()) {
|
|
return [PSCustomObject]@{ Id = $candidate['id']; DisplayName = $candidate['displayName']; GroupTypes = $candidate['groupTypes']; MembershipRule = $candidate['membershipRule'] }
|
|
}
|
|
}
|
|
Write-VerboseLog ('No exact match among ' + $resp2['value'].Count + ' candidates') -Level Warning
|
|
}
|
|
} catch {
|
|
Write-VerboseLog ('Search fallback error: ' + $_.Exception.Message) -Level Error
|
|
}
|
|
return $null
|
|
}
|
|
|
|
function Search-Groups {
|
|
param([string]$Query)
|
|
if ([string]::IsNullOrWhiteSpace($Query)) { return @() }
|
|
$enc = ($Query.Trim()) -replace "'","''"
|
|
$enc = $enc -replace '%','%25'
|
|
$enc = $enc -replace '\+','%2B'
|
|
$enc = $enc -replace '#','%23'
|
|
$enc = $enc -replace '&','%26'
|
|
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 "'","''"
|
|
$safeEntry = $safeEntry -replace '%','%25'
|
|
$safeEntry = $safeEntry -replace '\+','%2B'
|
|
$safeEntry = $safeEntry -replace '#','%23'
|
|
$safeEntry = $safeEntry -replace '&','%26'
|
|
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 {}
|
|
# $search fallback for groups with special chars like /
|
|
try {
|
|
$sEnc = [Uri]::EscapeDataString($trimmed)
|
|
$sUri = 'https://graph.microsoft.com/v1.0/groups?$search=%22displayName:' + $sEnc + '%22&$select=id,displayName&$top=50&$count=true'
|
|
$sHdr = @{ 'ConsistencyLevel' = 'eventual' }
|
|
$sResp = Invoke-MgGraphRequest -Method GET -Uri $sUri -Headers $sHdr
|
|
if ($sResp['value']) {
|
|
foreach ($sc in $sResp['value']) {
|
|
if ($sc['displayName'].Trim() -eq $trimmed.Trim()) {
|
|
return [PSCustomObject]@{ Id = $sc['id']; DisplayName = $sc['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:AdminUnits = @() # Administrative Units fetched from Graph
|
|
$script:CreateSelectedAU = $null # Selected AU for group creation (hashtable with Id, DisplayName)
|
|
$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:PEXParams = $null
|
|
$script:PEXResult = $null
|
|
$script:PEXOutFolder = $null
|
|
$script:PIMParams = $null
|
|
$script:PIMConfirmed = $null
|
|
$script:PIMPrepResult = $null
|
|
$script:PIMExecResult = $null
|
|
$script:PIMDialogResult = $null
|
|
$script:DRParams = $null
|
|
$script:DRResult = $null
|
|
$script:PCParams = $null
|
|
$script:PCResolved = $null
|
|
$script:PCResolveResult = $null
|
|
$script:PCDeleteResult = $null
|
|
$script:RRParams = $null
|
|
$script:RRResolve = $null
|
|
$script:RRExec = $null
|
|
$script:BSParams = $null
|
|
$script:BSResolve = $null
|
|
$script:BSExec = $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:FGONoOwnerMode = $false
|
|
$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:SearchKeyword = ''
|
|
$script:SearchEntries = @()
|
|
$script:SearchExactMatch = $false
|
|
$script:SearchGetManager = $false
|
|
$script:SearchTypes = $null
|
|
$script:SearchResults = $null
|
|
$script:SearchAllData = $null
|
|
$script:LMAllData = $null
|
|
$script:OMAllData = $null
|
|
$script:FCAllData = $null
|
|
$script:FDAllData = $null
|
|
# ── MBK (Missing BitLocker Keys) state - Patches 1.5 / 2 / 3 ─────────────────
|
|
$script:MBKAllData = $null # List[PSCustomObject] of report rows
|
|
$script:mbkRS = $null # background runspace
|
|
$script:mbkAsync = $null # IAsyncResult handle
|
|
$script:mbkQueue = $null # ConcurrentQueue between work block and timer
|
|
$script:mbkState = $null # Synchronized hashtable for Phase/Total/Processed
|
|
$script:mbkActiveBtn = $null # which Run button launched the current job
|
|
$script:mbkLastMode = $null # 'MissingOnly' | 'OlderThanDays' (for completion text)
|
|
$script:mbkLastDays = $null # threshold remembered for completion text
|
|
$script:mbkPendingMode = $null # set by BtnMBKRunOlder to dispatch into BtnMBKRun
|
|
$script:mbkPendingDays = $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
|
|
$script:FGOStop = $null
|
|
$script:FGOQueue = $null
|
|
$script:FGORs = $null
|
|
$script:FGOPs = $null
|
|
$script:FGOHandle = $null
|
|
$script:FGOTimer = $null
|
|
$script:FGOAllData = $null
|
|
$script:GGOInputTxt = ''
|
|
$script:GGOResults = $null
|
|
$script:UDRInputTxt = ''
|
|
$script:UDRResults = $null
|
|
$script:GGOAllData = $null
|
|
$script:UDRAllData = $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')
|
|
'PanelPolicyExport' = @('TxtPEXNameFilter')
|
|
'PanelRemoveGroups' = @('TxtRGGroupList')
|
|
'PanelUserDirectReports' = @('TxtUDRUserList')
|
|
'PanelMissingBitlocker' = @()
|
|
}
|
|
$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','ChkSrchGetManager','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 App Info panel
|
|
if ($panelName -eq 'PanelGetDiscoveredApps') {
|
|
$dg = $script:Window.FindName('DgDAResults')
|
|
if ($dg) { $dg.ItemsSource = $null; $dg.Columns.Clear() }
|
|
$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 }
|
|
}
|
|
$script:Window.FindName('ChkDADiscoveredApps').IsChecked = $true
|
|
$script:Window.FindName('ChkDAManagedApps').IsChecked = $true
|
|
$script:Window.FindName('BtnDARun').IsEnabled = $true
|
|
$script:Window.FindName('BtnDARunAll').IsEnabled = $true
|
|
$script:Window.FindName('BtnDAGetManagedAssignments').IsEnabled = $true
|
|
}
|
|
# 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 Info 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','BtnOpUserDirectReports','BtnOpMissingBitlocker','BtnOpPolicyExport','BtnOpPolicyImport','BtnOpDupReview','BtnOpPolicyCleanup','BtnOpRunRemediation','BtnOpBulkSync')) {
|
|
$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','BtnOpUserDirectReports','BtnOpMissingBitlocker','BtnOpPolicyExport','BtnOpPolicyImport','BtnOpDupReview','BtnOpPolicyCleanup','BtnOpRunRemediation','BtnOpBulkSync')) {
|
|
$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'])" }
|
|
|
|
# Track role names for deduplication between queries
|
|
$builtInNames = @{}
|
|
$customCount = 0
|
|
|
|
# ── 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='log'; Msg='PIM: No built-in directory roles found via transitiveMemberOf' }
|
|
} else {
|
|
foreach ($role in $roles) {
|
|
$rName = $role['displayName']
|
|
$builtInNames[$rName] = $true
|
|
Send-RoleResult -RoleName $rName -AssignType 'Active'
|
|
}
|
|
}
|
|
} catch {
|
|
$pimErr = $_.Exception.Message
|
|
if ($pimErr -match '403|Forbidden|401|Unauthorized') {
|
|
PQ @{ Type='log'; Msg='PIM: transitiveMemberOf returned 403 - skipping built-in role check' }
|
|
} else {
|
|
PQ @{ Type='error'; Msg=$pimErr }
|
|
}
|
|
}
|
|
|
|
# ── roleAssignments: custom + direct role assignments (includes custom roles) ──
|
|
# Uses Directory.Read.All (already required). Catches custom Entra roles not visible
|
|
# via transitiveMemberOf which only returns built-in directoryRole objects.
|
|
try {
|
|
$raUri = "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments" +
|
|
"?`$filter=principalId eq '$userId'" +
|
|
"&`$expand=roleDefinition(`$select=displayName,isBuiltIn)"
|
|
$raResp = Invoke-MgGraphRequest -Method GET -Uri $raUri
|
|
$assignments = $raResp['value']
|
|
if ($assignments -and $assignments.Count -gt 0) {
|
|
foreach ($ra in $assignments) {
|
|
$rd = $ra['roleDefinition']
|
|
if (-not $rd) { continue }
|
|
$rdName = $rd['displayName']
|
|
$isBuiltIn = $rd['isBuiltIn']
|
|
# Skip built-in roles already reported by transitiveMemberOf
|
|
if ($isBuiltIn -and $builtInNames.ContainsKey($rdName)) { continue }
|
|
if (-not $isBuiltIn) {
|
|
$customCount++
|
|
Send-RoleResult -RoleName $rdName -AssignType 'Custom Role'
|
|
} else {
|
|
# Built-in role found via direct assignment but not transitiveMemberOf
|
|
$builtInNames[$rdName] = $true
|
|
Send-RoleResult -RoleName $rdName -AssignType 'Active'
|
|
}
|
|
}
|
|
}
|
|
PQ @{ Type='log'; Msg="PIM: roleAssignments ($($assignments.Count) total, $customCount custom role(s))" }
|
|
} catch {
|
|
$raErr = $_.Exception.Message
|
|
PQ @{ Type='log'; Msg="PIM: Could not query roleAssignments for custom roles: $raErr" }
|
|
}
|
|
|
|
# If no roles found from either query, report none
|
|
if ($builtInNames.Count -eq 0 -and $customCount -eq 0) {
|
|
PQ @{ Type='none' }
|
|
}
|
|
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)
|
|
$isCustom = $m['AssignType'] -eq 'Custom Role'
|
|
$badgeBg = if ($isCustom) { '#3B3080' } else { '#1A5E3A' }
|
|
$badge.Background = [System.Windows.Media.SolidColorBrush]::new(
|
|
[System.Windows.Media.ColorConverter]::ConvertFromString($badgeBg))
|
|
$badge.ToolTip = "$($m['RoleName']) ($($m['AssignType']))"
|
|
|
|
$badgeTb = [System.Windows.Controls.TextBlock]::new()
|
|
$badgeTb.FontSize = 10.5
|
|
$badgeTb.FontWeight = 'SemiBold'
|
|
$badgeFg = if ($isCustom) { '#C4B5FD' } else { '#90EEC0' }
|
|
$badgeTb.Foreground = [System.Windows.Media.SolidColorBrush]::new(
|
|
[System.Windows.Media.ColorConverter]::ConvertFromString($badgeFg))
|
|
$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','BtnOpUserDirectReports','BtnOpMissingBitlocker','BtnOpPolicyExport','BtnOpPolicyImport','BtnOpDupReview','BtnOpPolicyCleanup','BtnOpRunRemediation','BtnOpBulkSync')) {
|
|
$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
|
|
|
|
# ── Fetch Administrative Units on wizard open ──
|
|
$cmbAU = $script:Window.FindName('CmbCreateAU')
|
|
$txtAULoad = $script:Window.FindName('TxtAULoading')
|
|
|
|
# Reset AU ComboBox to (None) + show loading indicator
|
|
$cmbAU.Items.Clear()
|
|
$noneItem = [System.Windows.Controls.ComboBoxItem]::new()
|
|
$noneItem.Content = '(None)'
|
|
$noneItem.Tag = ''
|
|
$noneItem.IsSelected = $true
|
|
$cmbAU.Items.Add($noneItem) | Out-Null
|
|
$cmbAU.SelectedIndex = 0
|
|
$txtAULoad.Visibility = 'Visible'
|
|
|
|
Invoke-OnBackground -DisableButton $null -BusyMessage $null -Work {
|
|
try {
|
|
$auList = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
$uri = "https://graph.microsoft.com/v1.0/directory/administrativeUnits?`$select=id,displayName,description&`$top=999"
|
|
while ($uri) {
|
|
$resp = Invoke-MgGraphRequest -Method GET -Uri $uri
|
|
foreach ($au in $resp['value']) {
|
|
$auList.Add([PSCustomObject]@{
|
|
Id = $au['id']
|
|
DisplayName = $au['displayName']
|
|
Description = if ($au['description']) { $au['description'] } else { '' }
|
|
})
|
|
}
|
|
$uri = $resp['@odata.nextLink']
|
|
}
|
|
$script:AdminUnits = @($auList | Sort-Object DisplayName)
|
|
Write-VerboseLog "Fetched $($script:AdminUnits.Count) Administrative Unit(s)" -Level Info
|
|
} catch {
|
|
$script:AdminUnits = @()
|
|
Write-VerboseLog "Could not fetch Administrative Units: $($_.Exception.Message)" -Level Warning
|
|
}
|
|
} -Done {
|
|
$cmbAU = $script:Window.FindName('CmbCreateAU')
|
|
$txtAULoad = $script:Window.FindName('TxtAULoading')
|
|
$txtAULoad.Visibility = 'Collapsed'
|
|
|
|
foreach ($au in $script:AdminUnits) {
|
|
$item = [System.Windows.Controls.ComboBoxItem]::new()
|
|
$item.Content = $au.DisplayName
|
|
$item.Tag = $au.Id
|
|
$item.ToolTip = if ($au.Description) { $au.Description } else { $au.Id }
|
|
$cmbAU.Items.Add($item) | Out-Null
|
|
}
|
|
if ($script:AdminUnits.Count -eq 0) {
|
|
Write-VerboseLog "No Administrative Units found (or permission denied)." -Level Warning
|
|
}
|
|
}
|
|
})
|
|
|
|
$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('BtnOpGetGroupOwner').Add_Click({
|
|
$script:Window.FindName('TxtGGOGroupList').Text = ""
|
|
$dg = $script:Window.FindName('DgGGOResults')
|
|
if ($dg) { $dg.ItemsSource = $null }
|
|
$script:Window.FindName('PnlGGOResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('BtnGGOCopyValue').IsEnabled = $false
|
|
$script:Window.FindName('BtnGGOCopyRow').IsEnabled = $false
|
|
$script:Window.FindName('BtnGGOCopyAll').IsEnabled = $false
|
|
$script:Window.FindName('BtnGGOExportXlsx').IsEnabled = $false
|
|
$script:Window.FindName('BtnGGOFilter').IsEnabled = $false
|
|
$script:Window.FindName('BtnGGOFilterClear').IsEnabled = $false
|
|
$script:Window.FindName('TxtGGOFilter').IsEnabled = $false
|
|
$script:Window.FindName('TxtGGOFilter').Text = ""
|
|
$script:GGOAllData = $null
|
|
$script:GGOInputTxt = ''
|
|
$script:GGOResults = $null
|
|
Show-Panel 'PanelGetGroupOwner'
|
|
Hide-Notification
|
|
Write-VerboseLog "Panel: Get Group Owner" -Level Info
|
|
})
|
|
|
|
$script:Window.FindName('BtnOpUserDirectReports').Add_Click({
|
|
$script:Window.FindName('TxtUDRUserList').Text = ""
|
|
$dg = $script:Window.FindName('DgUDRResults')
|
|
if ($dg) { $dg.ItemsSource = $null }
|
|
$script:Window.FindName('PnlUDRResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('BtnUDRCopyValue').IsEnabled = $false
|
|
$script:Window.FindName('BtnUDRCopyRow').IsEnabled = $false
|
|
$script:Window.FindName('BtnUDRCopyAll').IsEnabled = $false
|
|
$script:Window.FindName('BtnUDRExportXlsx').IsEnabled = $false
|
|
$script:Window.FindName('BtnUDRFilter').IsEnabled = $false
|
|
$script:Window.FindName('BtnUDRFilterClear').IsEnabled = $false
|
|
$script:Window.FindName('TxtUDRFilter').IsEnabled = $false
|
|
$script:Window.FindName('TxtUDRFilter').Text = ""
|
|
$script:UDRAllData = $null
|
|
$script:UDRInputTxt = ''
|
|
$script:UDRResults = $null
|
|
Show-Panel 'PanelUserDirectReports'
|
|
Hide-Notification
|
|
Write-VerboseLog "Panel: User Direct Reports" -Level Info
|
|
})
|
|
|
|
# -- USER DIRECT REPORTS - Run ----
|
|
$script:Window.FindName('BtnUDRRun').Add_Click({
|
|
Hide-Notification
|
|
$inputTxt = $script:Window.FindName('TxtUDRUserList').Text
|
|
if ([string]::IsNullOrWhiteSpace($inputTxt)) {
|
|
Show-Notification "Please enter at least one UPN or Object ID." -BgColor '#F8D7DA' -FgColor '#721C24'
|
|
return
|
|
}
|
|
$script:UDRInputTxt = $inputTxt
|
|
$btn = $script:Window.FindName('BtnUDRRun')
|
|
|
|
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- User Direct Reports: Querying ---" -Work {
|
|
$userLines = $script:UDRInputTxt -split "`n" | Where-Object { $_.Trim() -ne '' }
|
|
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
|
|
foreach ($line in $userLines) {
|
|
$entry = $line.Trim()
|
|
if ([string]::IsNullOrWhiteSpace($entry)) { continue }
|
|
|
|
Write-VerboseLog "Looking up user: $entry" -Level Info
|
|
|
|
# Resolve user - UPN or Object ID
|
|
$userId = $null
|
|
$userUpn = $null
|
|
$userObjId = $null
|
|
|
|
try {
|
|
$userUri = 'https://graph.microsoft.com/v1.0/users/' + [Uri]::EscapeDataString($entry) + '?$select=id,userPrincipalName'
|
|
$userObj = Invoke-MgGraphRequest -Method GET -Uri $userUri
|
|
if ($userObj) {
|
|
$userId = $userObj['id']
|
|
$userUpn = $userObj['userPrincipalName']
|
|
$userObjId = $userObj['id']
|
|
}
|
|
} catch {
|
|
Write-VerboseLog " User not found: $entry ($($_.Exception.Message))" -Level Warning
|
|
continue
|
|
}
|
|
|
|
if (-not $userId) {
|
|
Write-VerboseLog " User not found: $entry" -Level Warning
|
|
continue
|
|
}
|
|
|
|
Write-VerboseLog " Found: $userUpn ($userObjId)" -Level Success
|
|
|
|
# Get direct reports
|
|
try {
|
|
$drUri = 'https://graph.microsoft.com/v1.0/users/' + $userId + '/directReports?$select=id,userPrincipalName'
|
|
$drResp = Invoke-MgGraphRequest -Method GET -Uri $drUri
|
|
$reports = $drResp['value']
|
|
if ($reports -and $reports.Count -gt 0) {
|
|
foreach ($dr in $reports) {
|
|
$results.Add([PSCustomObject]@{
|
|
InputUserUPN = $userUpn
|
|
InputObjectId = $userObjId
|
|
DirectReportUPN = if ($dr['userPrincipalName']) { $dr['userPrincipalName'] } else { '(N/A)' }
|
|
DirectReportObjectId = $dr['id']
|
|
})
|
|
}
|
|
Write-VerboseLog " Found $($reports.Count) direct report(s)" -Level Success
|
|
} else {
|
|
$results.Add([PSCustomObject]@{
|
|
InputUserUPN = $userUpn
|
|
InputObjectId = $userObjId
|
|
DirectReportUPN = '(No direct reports)'
|
|
DirectReportObjectId = ''
|
|
})
|
|
Write-VerboseLog " No direct reports found" -Level Info
|
|
}
|
|
} catch {
|
|
Write-VerboseLog " Error fetching direct reports: $($_.Exception.Message)" -Level Error
|
|
}
|
|
}
|
|
|
|
$script:UDRResults = $results
|
|
|
|
} -Done {
|
|
$results = $script:UDRResults
|
|
$dg = $script:Window.FindName('DgUDRResults')
|
|
if ($dg) {
|
|
$dg.ItemsSource = $results
|
|
}
|
|
$script:Window.FindName('PnlUDRResults').Visibility = 'Visible'
|
|
$script:Window.FindName('TxtUDRCount').Text = "$($results.Count) result(s)"
|
|
$script:Window.FindName('BtnUDRCopyAll').IsEnabled = $true
|
|
$script:Window.FindName('BtnUDRExportXlsx').IsEnabled = $true
|
|
$script:Window.FindName('BtnUDRFilter').IsEnabled = $true
|
|
$script:Window.FindName('BtnUDRFilterClear').IsEnabled = $true
|
|
$script:Window.FindName('TxtUDRFilter').IsEnabled = $true
|
|
$count = if ($results) { $results.Count } else { 0 }
|
|
$msg = "User Direct Reports complete. Found $count result(s)."
|
|
Write-VerboseLog $msg -Level Success
|
|
Show-Notification $msg -BgColor '#D4EDDA' -FgColor '#155724'
|
|
}
|
|
})
|
|
|
|
# UDR - Cell selection
|
|
$script:Window.FindName('DgUDRResults').Add_SelectedCellsChanged({
|
|
$dg = $script:Window.FindName('DgUDRResults')
|
|
$selCells = $dg.SelectedCells.Count
|
|
$script:Window.FindName('BtnUDRCopyValue').IsEnabled = ($selCells -ge 1)
|
|
$script:Window.FindName('BtnUDRCopyRow').IsEnabled = ($selCells -gt 0)
|
|
})
|
|
|
|
# UDR - Filter
|
|
$script:Window.FindName('BtnUDRFilter').Add_Click({
|
|
$dg = $script:Window.FindName('DgUDRResults')
|
|
$keyword = $script:Window.FindName('TxtUDRFilter').Text.Trim()
|
|
if ($null -eq $script:UDRAllData) {
|
|
$script:UDRAllData = @($dg.ItemsSource)
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($keyword)) {
|
|
$dg.ItemsSource = $script:UDRAllData
|
|
$script:Window.FindName('TxtUDRCount').Text = "$($script:UDRAllData.Count) result(s)"
|
|
} else {
|
|
$filtered = @($script:UDRAllData | Where-Object {
|
|
$_.InputUserUPN -match [regex]::Escape($keyword) -or
|
|
$_.InputObjectId -match [regex]::Escape($keyword) -or
|
|
$_.DirectReportUPN -match [regex]::Escape($keyword) -or
|
|
$_.DirectReportObjectId -match [regex]::Escape($keyword)
|
|
})
|
|
$dg.ItemsSource = $filtered
|
|
$script:Window.FindName('TxtUDRCount').Text = "$($filtered.Count) of $($script:UDRAllData.Count) result(s) (filtered)"
|
|
}
|
|
})
|
|
|
|
# UDR - Filter Clear
|
|
$script:Window.FindName('BtnUDRFilterClear').Add_Click({
|
|
$script:Window.FindName('TxtUDRFilter').Text = ""
|
|
if ($null -ne $script:UDRAllData) {
|
|
$dg = $script:Window.FindName('DgUDRResults')
|
|
$dg.ItemsSource = $script:UDRAllData
|
|
$script:Window.FindName('TxtUDRCount').Text = "$($script:UDRAllData.Count) result(s)"
|
|
}
|
|
})
|
|
|
|
# UDR - Copy Value
|
|
$script:Window.FindName('BtnUDRCopyValue').Add_Click({
|
|
$dg = $script:Window.FindName('DgUDRResults')
|
|
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 "
|
|
")
|
|
Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
|
|
})
|
|
|
|
# UDR - Copy Row
|
|
$script:Window.FindName('BtnUDRCopyRow').Add_Click({
|
|
$dg = $script:Window.FindName('DgUDRResults')
|
|
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 " "
|
|
$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 " ")
|
|
}
|
|
[System.Windows.Clipboard]::SetText($lines -join "
|
|
")
|
|
Show-Notification "$($rows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
|
|
$dg.UnselectAllCells()
|
|
foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
|
|
})
|
|
|
|
# UDR - Copy All
|
|
$script:Window.FindName('BtnUDRCopyAll').Add_Click({
|
|
$dg = $script:Window.FindName('DgUDRResults')
|
|
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 " "
|
|
$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 " ")
|
|
}
|
|
[System.Windows.Clipboard]::SetText($lines -join "
|
|
")
|
|
Show-Notification "$($allRows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
|
|
})
|
|
|
|
# UDR - Export XLSX
|
|
$script:Window.FindName('BtnUDRExportXlsx').Add_Click({
|
|
$dg = $script:Window.FindName('DgUDRResults')
|
|
if ($null -eq $dg.ItemsSource) { return }
|
|
$allRows = @($dg.ItemsSource)
|
|
if ($allRows.Count -eq 0) { return }
|
|
$dlg = [Microsoft.Win32.SaveFileDialog]::new()
|
|
$dlg.Title = 'Save User Direct Reports XLSX'
|
|
$dlg.Filter = 'Excel files (*.xlsx)|*.xlsx'
|
|
$dlg.DefaultExt = '.xlsx'
|
|
$dlg.FileName = "UserDirectReports_$(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 })
|
|
}
|
|
$exportRows = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
foreach ($r in $allRows) {
|
|
$obj = [ordered]@{}
|
|
foreach ($cd in $colDefs) { $obj[$cd.H] = if ($cd.P -ne '') { [string]$r.($cd.P) } else { '' } }
|
|
$exportRows.Add([PSCustomObject]$obj)
|
|
}
|
|
try {
|
|
$exportRows | Export-Excel -Path $dlg.FileName -AutoSize -FreezeTopRow -BoldTopRow
|
|
Show-Notification "Exported $($allRows.Count) row(s) to XLSX." -BgColor '#D4EDDA' -FgColor '#155724'
|
|
Write-VerboseLog "UDR: Exported $($allRows.Count) rows to $($dlg.FileName)" -Level Success
|
|
} catch {
|
|
Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
|
|
}
|
|
})
|
|
|
|
|
|
# -- GET GROUP OWNER - Run ----
|
|
$script:Window.FindName('BtnGGORun').Add_Click({
|
|
Hide-Notification
|
|
$inputTxt = $script:Window.FindName('TxtGGOGroupList').Text
|
|
if ([string]::IsNullOrWhiteSpace($inputTxt)) {
|
|
Show-Notification "Please enter at least one group name or ID." -BgColor '#F8D7DA' -FgColor '#721C24'
|
|
return
|
|
}
|
|
$script:GGOInputTxt = $inputTxt
|
|
$btn = $script:Window.FindName('BtnGGORun')
|
|
|
|
Invoke-OnBackground -DisableButton $btn -BusyMessage "--- Get Group Owner: Resolving and fetching owner details ---" -Work {
|
|
$groupLines = $script:GGOInputTxt -split "
|
|
" | Where-Object { $_.Trim() -ne '' }
|
|
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
$failCount = 0
|
|
|
|
foreach ($line in $groupLines) {
|
|
$entry = $line.Trim()
|
|
if ([string]::IsNullOrWhiteSpace($entry)) { continue }
|
|
|
|
Write-VerboseLog "Resolving group: $entry" -Level Info
|
|
$group = Resolve-GroupByNameOrId -GroupEntry $entry
|
|
if (-not $group) {
|
|
Write-VerboseLog " $([char]0x2717) Group not found: $entry" -Level Warning
|
|
$failCount++
|
|
continue
|
|
}
|
|
Write-VerboseLog " $([char]0x2713) Found: $($group.DisplayName) ($($group.Id))" -Level Success
|
|
|
|
try {
|
|
$ownerUri = "https://graph.microsoft.com/v1.0/groups/$($group.Id)/owners?`$select=id,displayName,userPrincipalName,jobTitle,country"
|
|
Write-VerboseLog "Fetching owners for $($group.DisplayName)..." -Level Info
|
|
$owners = Invoke-GraphGet -Uri $ownerUri
|
|
|
|
if ($owners -and $owners.Count -gt 0) {
|
|
foreach ($owner in $owners) {
|
|
$ownerId = $owner['id']
|
|
$ownerUpn = $owner['userPrincipalName']
|
|
$jobTitle = $owner['jobTitle']
|
|
$country = $owner['country']
|
|
|
|
$manager = ''
|
|
try {
|
|
$mgrUri = "https://graph.microsoft.com/v1.0/users/$ownerId/manager?`$select=displayName,userPrincipalName"
|
|
$mgrObj = Invoke-GraphGet -Uri $mgrUri
|
|
if ($mgrObj) {
|
|
$manager = $mgrObj['displayName']
|
|
}
|
|
} catch {
|
|
$manager = ''
|
|
}
|
|
|
|
$results.Add([PSCustomObject]@{
|
|
GroupName = $group.DisplayName
|
|
OwnerUPN = $ownerUpn
|
|
JobTitle = if ([string]::IsNullOrWhiteSpace($jobTitle)) { '(none)' } else { $jobTitle }
|
|
Country = if ([string]::IsNullOrWhiteSpace($country)) { '(none)' } else { $country }
|
|
Manager = if ([string]::IsNullOrWhiteSpace($manager)) { '(none)' } else { $manager }
|
|
})
|
|
Write-VerboseLog " Owner: $ownerUpn | Title: $jobTitle | Manager: $manager" -Level Success
|
|
}
|
|
} else {
|
|
Write-VerboseLog " $([char]0x2717) No owners found for $($group.DisplayName)" -Level Warning
|
|
}
|
|
} catch {
|
|
Write-VerboseLog " $([char]0x2717) Error fetching owners: $($_.Exception.Message)" -Level Error
|
|
$failCount++
|
|
}
|
|
}
|
|
|
|
$script:GGOResults = $results
|
|
|
|
} -Done {
|
|
$results = $script:GGOResults
|
|
$dg = $script:Window.FindName('DgGGOResults')
|
|
if ($dg) {
|
|
$dg.ItemsSource = $results
|
|
}
|
|
$script:Window.FindName('PnlGGOResults').Visibility = 'Visible'
|
|
$script:Window.FindName('TxtGGOCount').Text = "$($results.Count) result(s)"
|
|
$script:Window.FindName('BtnGGOCopyAll').IsEnabled = $true
|
|
$script:Window.FindName('BtnGGOExportXlsx').IsEnabled = $true
|
|
$script:Window.FindName('BtnGGOFilter').IsEnabled = $true
|
|
$script:Window.FindName('BtnGGOFilterClear').IsEnabled = $true
|
|
$script:Window.FindName('TxtGGOFilter').IsEnabled = $true
|
|
$count = if ($results) { $results.Count } else { 0 }
|
|
$msg = "Get Group Owner complete. Found $count owner(s)."
|
|
Write-VerboseLog $msg -Level Success
|
|
Show-Notification $msg -BgColor '#D4EDDA' -FgColor '#155724'
|
|
}
|
|
})
|
|
|
|
# GGO - Cell selection
|
|
$script:Window.FindName('DgGGOResults').Add_SelectedCellsChanged({
|
|
$dg = $script:Window.FindName('DgGGOResults')
|
|
$selCells = $dg.SelectedCells.Count
|
|
$script:Window.FindName('BtnGGOCopyValue').IsEnabled = ($selCells -ge 1)
|
|
$script:Window.FindName('BtnGGOCopyRow').IsEnabled = ($selCells -gt 0)
|
|
})
|
|
|
|
# GGO - Filter
|
|
$script:Window.FindName('BtnGGOFilter').Add_Click({
|
|
$dg = $script:Window.FindName('DgGGOResults')
|
|
$keyword = $script:Window.FindName('TxtGGOFilter').Text.Trim()
|
|
if ($null -eq $script:GGOAllData) {
|
|
$script:GGOAllData = @($dg.ItemsSource)
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($keyword)) {
|
|
$dg.ItemsSource = $script:GGOAllData
|
|
$script:Window.FindName('TxtGGOCount').Text = "$($script:GGOAllData.Count) result(s)"
|
|
} else {
|
|
$filtered = @($script:GGOAllData | Where-Object {
|
|
$_.GroupName -match [regex]::Escape($keyword) -or
|
|
$_.OwnerUPN -match [regex]::Escape($keyword) -or
|
|
$_.JobTitle -match [regex]::Escape($keyword) -or
|
|
$_.Country -match [regex]::Escape($keyword) -or
|
|
$_.Manager -match [regex]::Escape($keyword)
|
|
})
|
|
$dg.ItemsSource = $filtered
|
|
$script:Window.FindName('TxtGGOCount').Text = "$($filtered.Count) of $($script:GGOAllData.Count) result(s) (filtered)"
|
|
}
|
|
})
|
|
|
|
# GGO - Filter Clear
|
|
$script:Window.FindName('BtnGGOFilterClear').Add_Click({
|
|
$script:Window.FindName('TxtGGOFilter').Text = ""
|
|
if ($null -ne $script:GGOAllData) {
|
|
$dg = $script:Window.FindName('DgGGOResults')
|
|
$dg.ItemsSource = $script:GGOAllData
|
|
$script:Window.FindName('TxtGGOCount').Text = "$($script:GGOAllData.Count) result(s)"
|
|
}
|
|
})
|
|
|
|
# GGO - Copy Value
|
|
$script:Window.FindName('BtnGGOCopyValue').Add_Click({
|
|
$dg = $script:Window.FindName('DgGGOResults')
|
|
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 "
|
|
")
|
|
Show-Notification "$($vals.Count) value(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
|
|
})
|
|
|
|
# GGO - Copy Row
|
|
$script:Window.FindName('BtnGGOCopyRow').Add_Click({
|
|
$dg = $script:Window.FindName('DgGGOResults')
|
|
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 " "
|
|
$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 " ")
|
|
}
|
|
[System.Windows.Clipboard]::SetText($lines -join "
|
|
")
|
|
Show-Notification "$($rows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
|
|
$dg.UnselectAllCells()
|
|
foreach ($r in $rows) { $null = $dg.SelectedItems.Add($r) }
|
|
})
|
|
|
|
# GGO - Copy All
|
|
$script:Window.FindName('BtnGGOCopyAll').Add_Click({
|
|
$dg = $script:Window.FindName('DgGGOResults')
|
|
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 " "
|
|
$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 " ")
|
|
}
|
|
[System.Windows.Clipboard]::SetText($lines -join "
|
|
")
|
|
Show-Notification "$($allRows.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
|
|
})
|
|
|
|
# GGO - Export XLSX
|
|
$script:Window.FindName('BtnGGOExportXlsx').Add_Click({
|
|
$dg = $script:Window.FindName('DgGGOResults')
|
|
if ($null -eq $dg.ItemsSource) { return }
|
|
$allRows = @($dg.ItemsSource)
|
|
if ($allRows.Count -eq 0) { return }
|
|
$dlg = [Microsoft.Win32.SaveFileDialog]::new()
|
|
$dlg.Title = 'Save Group Owners XLSX'
|
|
$dlg.Filter = 'Excel files (*.xlsx)|*.xlsx'
|
|
$dlg.DefaultExt = '.xlsx'
|
|
$dlg.FileName = "GroupOwners_$(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 })
|
|
}
|
|
$exportRows = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
foreach ($r in $allRows) {
|
|
$obj = [ordered]@{}
|
|
foreach ($cd in $colDefs) { $obj[$cd.H] = if ($cd.P -ne '') { [string]$r.($cd.P) } else { '' } }
|
|
$exportRows.Add([PSCustomObject]$obj)
|
|
}
|
|
try {
|
|
$exportRows | Export-Excel -Path $dlg.FileName -AutoSize -FreezeTopRow -BoldTopRow
|
|
Show-Notification "Exported $($allRows.Count) row(s) to XLSX." -BgColor '#D4EDDA' -FgColor '#155724'
|
|
Write-VerboseLog "GGO: Exported $($allRows.Count) rows to $($dlg.FileName)" -Level Success
|
|
} catch {
|
|
Show-Notification "Export failed: $($_.Exception.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
|
|
}
|
|
})
|
|
|
|
$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 "'","''"
|
|
$safe = $safe -replace '%','%25'
|
|
$safe = $safe -replace '\+','%2B'
|
|
$safe = $safe -replace '#','%23'
|
|
$safe = $safe -replace '&','%26'
|
|
$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 "'","''"
|
|
$safe = $safe -replace '%','%25'
|
|
$safe = $safe -replace '\+','%2B'
|
|
$safe = $safe -replace '#','%23'
|
|
$safe = $safe -replace '&','%26'
|
|
$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 "'","''"
|
|
$safe = $safe -replace '%','%25'
|
|
$safe = $safe -replace '\+','%2B'
|
|
$safe = $safe -replace '#','%23'
|
|
$safe = $safe -replace '&','%26'
|
|
$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
|
|
AdminUnits = $script:AdminUnits
|
|
LogFile = $script:LogFile
|
|
SearchKeyword = $script:SearchKeyword
|
|
SearchEntries = $script:SearchEntries
|
|
SearchExactMatch = $script:SearchExactMatch
|
|
SearchTypes = $script:SearchTypes
|
|
SearchGetManager = $script:SearchGetManager
|
|
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
|
|
GGOInputTxt = $script:GGOInputTxt
|
|
GGOResults = $script:GGOResults
|
|
UDRInputTxt = $script:UDRInputTxt
|
|
UDRResults = $script:UDRResults
|
|
PEXParams = $script:PEXParams
|
|
PEXResult = $null
|
|
PIMParams = $script:PIMParams
|
|
PIMConfirmed = $script:PIMConfirmed
|
|
PIMPrepResult = $null
|
|
PIMExecResult = $null
|
|
DRParams = $script:DRParams
|
|
DRResult = $null
|
|
PCParams = $script:PCParams
|
|
PCResolveResult = $null
|
|
PCDeleteResult = $null
|
|
RRParams = $script:RRParams
|
|
RRResolve = $script:RRResolve
|
|
RRExec = $null
|
|
BSParams = $script:BSParams
|
|
BSResolve = $script:BSResolve
|
|
BSExec = $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','Resolve-DevicesForAction'
|
|
)
|
|
$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:GGOInputTxt = $Shared['GGOInputTxt']
|
|
$script:GGOResults = $Shared['GGOResults']
|
|
$script:UDRInputTxt = $Shared['UDRInputTxt']
|
|
$script:UDRResults = $Shared['UDRResults']
|
|
$script:PEXParams = $Shared['PEXParams']
|
|
$script:PIMParams = $Shared['PIMParams']
|
|
$script:PIMConfirmed = $Shared['PIMConfirmed']
|
|
$script:DRParams = $Shared['DRParams']
|
|
$script:PCParams = $Shared['PCParams']
|
|
$script:RRParams = $Shared['RRParams']
|
|
$script:RRResolve = $Shared['RRResolve']
|
|
$script:BSParams = $Shared['BSParams']
|
|
$script:BSResolve = $Shared['BSResolve']
|
|
$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:AdminUnits = $Shared['AdminUnits']
|
|
$script:LogFile = $Shared['LogFile']
|
|
$script:SearchKeyword = $Shared['SearchKeyword']
|
|
$script:SearchEntries = $Shared['SearchEntries']
|
|
$script:SearchExactMatch = $Shared['SearchExactMatch']
|
|
$script:SearchTypes = $Shared['SearchTypes']
|
|
$script:SearchGetManager = $Shared['SearchGetManager']
|
|
$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['AdminUnits'] = $script:AdminUnits
|
|
$Shared['GGOInputTxt'] = $script:GGOInputTxt
|
|
$Shared['GGOResults'] = $script:GGOResults
|
|
$Shared['UDRInputTxt'] = $script:UDRInputTxt
|
|
$Shared['UDRResults'] = $script:UDRResults
|
|
$Shared['PEXResult'] = $script:PEXResult
|
|
$Shared['PIMPrepResult'] = $script:PIMPrepResult
|
|
$Shared['PIMExecResult'] = $script:PIMExecResult
|
|
$Shared['DRResult'] = $script:DRResult
|
|
$Shared['PCResolveResult'] = $script:PCResolveResult
|
|
$Shared['PCDeleteResult'] = $script:PCDeleteResult
|
|
$Shared['RRResolve'] = $script:RRResolve
|
|
$Shared['RRExec'] = $script:RRExec
|
|
$Shared['BSResolve'] = $script:BSResolve
|
|
$Shared['BSExec'] = $script:BSExec
|
|
$Shared['SearchResults'] = $script:SearchResults
|
|
$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) {
|
|
# ── Stop operation-specific progress timers & collapse panels ──
|
|
if ($script:LMProgressTimer) { $script:LMProgressTimer.Stop(); $script:LMProgressTimer = $null }
|
|
if ($script:CGProgressTimer) { $script:CGProgressTimer.Stop(); $script:CGProgressTimer = $null }
|
|
foreach ($pnl in @('PnlLMProgress','PnlOMProgress','PnlFGOProgress','PnlCGProgress',
|
|
'PnlFCProgress','PnlFDProgress','PnlGDIProgress','PnlDAProgress',
|
|
'PnlGPAProgress','PnlRGProgress')) {
|
|
$el = $script:Window.FindName($pnl)
|
|
if ($el) { $el.Visibility = 'Collapsed' }
|
|
}
|
|
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:AdminUnits = $shared['AdminUnits']
|
|
$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']
|
|
$script:GGOInputTxt = $shared['GGOInputTxt']
|
|
$script:GGOResults = $shared['GGOResults']
|
|
$script:UDRInputTxt = $shared['UDRInputTxt']
|
|
$script:UDRResults = $shared['UDRResults']
|
|
$script:PEXResult = $shared['PEXResult']
|
|
$script:PIMPrepResult = $shared['PIMPrepResult']
|
|
$script:PIMExecResult = $shared['PIMExecResult']
|
|
$script:DRResult = $shared['DRResult']
|
|
$script:PCResolveResult = $shared['PCResolveResult']
|
|
$script:PCDeleteResult = $shared['PCDeleteResult']
|
|
$script:RRResolve = $shared['RRResolve']
|
|
$script:RRExec = $shared['RRExec']
|
|
$script:BSResolve = $shared['BSResolve']
|
|
$script:BSExec = $shared['BSExec']
|
|
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 (v7.3)" -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 '' })
|
|
$script:FGONoOwnerMode = ($entries.Count -eq 0)
|
|
|
|
# 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 = if ($script:FGONoOwnerMode) { 'Finding groups with no owners...' } else { '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('noOwnerMode', $script:FGONoOwnerMode)
|
|
$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, $noOwnerMode)
|
|
|
|
$resultList = [System.Collections.Generic.List[PSObject]]::new()
|
|
$resolved = 0
|
|
$failed = 0
|
|
|
|
if ($noOwnerMode) {
|
|
# ── No-owner mode: fetch all groups and find those with 0 owners ──
|
|
$queue.Enqueue(@{ type = 'progress'; msg = 'Fetching all groups...'; detail = '' })
|
|
$allGroups = [System.Collections.Generic.List[hashtable]]::new()
|
|
$uri = 'https://graph.microsoft.com/v1.0/groups?$select=id,displayName,groupTypes,mailEnabled,securityEnabled,membershipRule&$top=999&$count=true'
|
|
$hdrs = @{ ConsistencyLevel = 'eventual' }
|
|
try {
|
|
while ($uri) {
|
|
if ($stopFlag['Stop']) { break }
|
|
$resp = Invoke-MgGraphRequest -Method GET -Uri $uri -Headers $hdrs
|
|
if ($resp['value']) {
|
|
foreach ($g in $resp['value']) { $allGroups.Add($g) }
|
|
}
|
|
$uri = $resp['@odata.nextLink']
|
|
}
|
|
} catch {
|
|
$queue.Enqueue(@{ type = 'progress'; msg = "Error fetching groups: $($_.Exception.Message)"; detail = '' })
|
|
}
|
|
$queue.Enqueue(@{ type = 'progress'; msg = "Checking $($allGroups.Count) group(s) for missing owners..."; detail = '' })
|
|
$checked = 0
|
|
foreach ($g in $allGroups) {
|
|
if ($stopFlag['Stop']) { break }
|
|
$checked++
|
|
if ($checked % 50 -eq 0) {
|
|
$queue.Enqueue(@{ type = 'progress'; msg = "Checking owners: $checked of $($allGroups.Count)"; detail = '' })
|
|
}
|
|
try {
|
|
$ownersResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$($g['id'])/owners?`$select=id&`$top=1"
|
|
$ownerCount = @($ownersResp['value']).Count
|
|
if ($ownerCount -eq 0) {
|
|
$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' }
|
|
$mType = 'Assigned'
|
|
if ($gTypes -and $gTypes -contains 'DynamicMembership') { $mType = 'Dynamic' }
|
|
$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 = '(none)'
|
|
GroupName = [string]$g['displayName']
|
|
GroupId = [string]$g['id']
|
|
GroupType = $gType
|
|
MembershipType = $mType
|
|
MemberCount = $memberCount
|
|
})
|
|
}
|
|
} catch {
|
|
# Skip groups where owner check fails
|
|
}
|
|
}
|
|
$resolved = $allGroups.Count
|
|
$failed = 0
|
|
} else {
|
|
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).AddArgument($script:FGONoOwnerMode)
|
|
|
|
$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 groups found matching the criteria." -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 { '' }
|
|
|
|
# Read Administrative Unit selection
|
|
$selAUItem = $script:Window.FindName('CmbCreateAU').SelectedItem
|
|
$script:CreateSelectedAU = if ($selAUItem -and $selAUItem.Tag) { @{ Id = $selAUItem.Tag; DisplayName = $selAUItem.Content } } else { $null }
|
|
|
|
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
|
|
SelectedAU = $script:CreateSelectedAU
|
|
}
|
|
|
|
$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 "'","''"
|
|
$safe_dispName = $safe_dispName -replace '%','%25'
|
|
$safe_dispName = $safe_dispName -replace '\+','%2B'
|
|
$safe_dispName = $safe_dispName -replace '#','%23'
|
|
$safe_dispName = $safe_dispName -replace '&','%26'
|
|
$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 {
|
|
$safe_mailNick = $mailNick -replace "'","''"
|
|
$safe_mailNick = $safe_mailNick -replace '%','%25'
|
|
$safe_mailNick = $safe_mailNick -replace '\+','%2B'
|
|
$safe_mailNick = $safe_mailNick -replace '#','%23'
|
|
$safe_mailNick = $safe_mailNick -replace '&','%26'
|
|
$nickResp = Invoke-MgGraphRequest -Method GET `
|
|
-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" }
|
|
$auInfo = $script:CreateParams['SelectedAU']
|
|
if ($auInfo) { $extra += "`nAdmin Unit : $($auInfo.DisplayName) ($($auInfo.Id))" }
|
|
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
|
|
}
|
|
|
|
# ── Route through AU endpoint if Administrative Unit selected ──
|
|
$selectedAU = $p['SelectedAU']
|
|
if ($selectedAU) {
|
|
$body['@odata.type'] = '#microsoft.graph.group'
|
|
$auUri = "https://graph.microsoft.com/v1.0/directory/administrativeUnits/$($selectedAU.Id)/members"
|
|
Write-VerboseLog "Creating group in Administrative Unit: $($selectedAU.DisplayName) ($($selectedAU.Id))" -Level Info
|
|
try {
|
|
$newGroup = Invoke-MgGraphRequest -Method POST -Uri $auUri `
|
|
-Body ($body | ConvertTo-Json -Depth 5) -ContentType "application/json"
|
|
Write-VerboseLog "Group created in AU '$($selectedAU.DisplayName)': $($newGroup['displayName']) ($($newGroup['id']))" -Level Success
|
|
} catch {
|
|
$auErr = $_.Exception.Message
|
|
if ($auErr -match '403|Forbidden|Authorization_RequestDenied|Insufficient privileges') {
|
|
throw "Access denied: You need Groups Administrator or User Administrator role scoped to this Administrative Unit. Error: $auErr"
|
|
}
|
|
throw
|
|
}
|
|
} else {
|
|
$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']
|
|
$auMsg = if ($script:CreateParams['SelectedAU']) { " in AU '$($script:CreateParams['SelectedAU'].DisplayName)'" } else { '' }
|
|
Show-Notification "Group '$dispName' created successfully${auMsg} (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
|
|
$inputTxt = $script:Window.FindName('TxtSearchKeyword').Text
|
|
$entries = @($inputTxt -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
|
if ($entries.Count -eq 0) {
|
|
Show-Notification "Please enter at least one search term." -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
|
|
$script:SearchGetManager = [bool]$script:Window.FindName('ChkSrchGetManager').IsChecked
|
|
$anyChecked = $inclUsers -or $inclDevices -or $inclSG -or $inclM365
|
|
$script:SearchEntries = $entries
|
|
$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 ($($entries.Count) term(s))..." -Work {
|
|
$allEntries = $script:SearchEntries
|
|
$types = $script:SearchTypes
|
|
$exactMatch = $script:SearchExactMatch
|
|
$hdrs = @{ ConsistencyLevel = 'eventual' }
|
|
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
|
|
foreach ($kw in $allEntries) {
|
|
Write-VerboseLog "Search term: '$kw'" -Level Info
|
|
|
|
# Users
|
|
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']; MembershipType = ''; GroupMemberCount = '' })
|
|
}
|
|
$userCount += @($resp['value']).Count
|
|
$uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
|
|
} while ($uri)
|
|
Write-VerboseLog "Users found for '$kw': $userCount" -Level Info
|
|
} catch { Write-VerboseLog "User search error for '$kw': $($_.Exception.Message)" -Level Warning }
|
|
}
|
|
|
|
# Devices
|
|
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']; MembershipType = ''; GroupMemberCount = '' })
|
|
}
|
|
$devCount += @($resp['value']).Count
|
|
$uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
|
|
} while ($uri)
|
|
Write-VerboseLog "Devices found for '$kw': $devCount" -Level Info
|
|
} catch { Write-VerboseLog "Device search error for '$kw': $($_.Exception.Message)" -Level Warning }
|
|
}
|
|
|
|
# Groups
|
|
if ($types['SG'] -or $types['M365']) {
|
|
try {
|
|
$uri = "https://graph.microsoft.com/v1.0/groups?`$search=`"displayName:$kw`"`&`$select=id,displayName,groupTypes,mail,membershipRuleProcessingState`&`$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']) {
|
|
$memType365 = if ($isDyn) { 'Dynamic' } else { 'Assigned' }
|
|
$mbrCnt365 = ''
|
|
try {
|
|
$cntUri = 'https://graph.microsoft.com/v1.0/groups/' + $g['id'] + '/members/$count'
|
|
$cntHdrs = @{ ConsistencyLevel = 'eventual' }
|
|
$mbrCnt365 = [string](Invoke-MgGraphRequest -Method GET -Uri $cntUri -Headers $cntHdrs)
|
|
} catch { $mbrCnt365 = '(error)' }
|
|
$results.Add([PSCustomObject]@{ DisplayName = [string]$g['displayName']; Detail = [string]$g['id']; ObjectType = 'M365 Group'; ObjectId = [string]$g['id']; MembershipType = $memType365; GroupMemberCount = $mbrCnt365 })
|
|
} elseif (-not $isM365 -and $types['SG']) {
|
|
$tl = if ($isDyn) { 'Security Group (Dynamic)' } else { 'Security Group' }
|
|
$memTypeSG = if ($isDyn) { 'Dynamic' } else { 'Assigned' }
|
|
$mbrCntSG = ''
|
|
try {
|
|
$cntUri = 'https://graph.microsoft.com/v1.0/groups/' + $g['id'] + '/members/$count'
|
|
$cntHdrs = @{ ConsistencyLevel = 'eventual' }
|
|
$mbrCntSG = [string](Invoke-MgGraphRequest -Method GET -Uri $cntUri -Headers $cntHdrs)
|
|
} catch { $mbrCntSG = '(error)' }
|
|
$results.Add([PSCustomObject]@{ DisplayName = [string]$g['displayName']; Detail = [string]$g['id']; ObjectType = $tl; ObjectId = [string]$g['id']; MembershipType = $memTypeSG; GroupMemberCount = $mbrCntSG })
|
|
}
|
|
}
|
|
$grpCount += @($resp['value']).Count
|
|
$uri = if ($resp['@odata.nextLink']) { [string]$resp['@odata.nextLink'] } else { $null }
|
|
} while ($uri)
|
|
Write-VerboseLog "Groups found for '$kw': $grpCount" -Level Info
|
|
} catch { Write-VerboseLog "Group search error for '$kw': $($_.Exception.Message)" -Level Warning }
|
|
}
|
|
}
|
|
|
|
# De-duplicate by ObjectId (same object may appear from multiple search terms)
|
|
$seen = [System.Collections.Generic.HashSet[string]]::new()
|
|
$deduped = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
foreach ($r in $results) {
|
|
if ($seen.Add($r.ObjectId)) { $deduped.Add($r) }
|
|
}
|
|
|
|
# Client-side exact-match filter when checkbox is checked
|
|
if ($exactMatch) {
|
|
$allKw = $script:SearchEntries
|
|
$deduped = [System.Collections.Generic.List[PSCustomObject]]($deduped | Where-Object {
|
|
$dn = $_.DisplayName
|
|
$matched = $false
|
|
foreach ($k in $allKw) { if ($dn -ieq $k) { $matched = $true; break } }
|
|
$matched
|
|
})
|
|
}
|
|
|
|
Write-VerboseLog "Search total (deduped): $($deduped.Count)" -Level Info
|
|
|
|
# Get Manager UPN for User objects (if checkbox checked)
|
|
$getManager = $script:SearchGetManager
|
|
if ($getManager) {
|
|
$finalList = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
$userCount = ($deduped | Where-Object { $_.ObjectType -eq 'User' }).Count
|
|
$uIdx = 0
|
|
foreach ($item in $deduped) {
|
|
$mgrUpn = ''
|
|
if ($item.ObjectType -eq 'User') {
|
|
$uIdx++
|
|
try {
|
|
$mUri = "https://graph.microsoft.com/v1.0/users/$($item.ObjectId)/manager?`$select=userPrincipalName,displayName"
|
|
$mResp = Invoke-MgGraphRequest -Method GET -Uri $mUri
|
|
$mgrUpn = if ($mResp['userPrincipalName']) { [string]$mResp['userPrincipalName'] } else { '(no manager)' }
|
|
} catch {
|
|
if ($_.Exception.Message -match '404' -or $_.Exception.Message -match 'Resource .* not found') {
|
|
$mgrUpn = '(no manager)'
|
|
} else {
|
|
$mgrUpn = '(error)'
|
|
Write-VerboseLog "Manager lookup error for $($item.DisplayName): $($_.Exception.Message)" -Level Warning
|
|
}
|
|
}
|
|
}
|
|
$finalList.Add([PSCustomObject]@{
|
|
DisplayName = $item.DisplayName
|
|
Detail = $item.Detail
|
|
Manager = $mgrUpn
|
|
ObjectType = $item.ObjectType
|
|
ObjectId = $item.ObjectId
|
|
MembershipType = $item.MembershipType
|
|
GroupMemberCount = $item.GroupMemberCount
|
|
})
|
|
}
|
|
$deduped = $finalList
|
|
Write-VerboseLog "Manager lookup completed for $userCount user(s) out of $($finalList.Count) total object(s)" -Level Info
|
|
}
|
|
|
|
$script:SearchResults = $deduped
|
|
} -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
|
|
# Dynamic Manager column: remove old one if present, add if GetManager checked
|
|
$existingMgrCol = $dg.Columns | Where-Object { $_.Header -eq "Manager" }
|
|
if ($existingMgrCol) { $dg.Columns.Remove($existingMgrCol) }
|
|
if ($script:SearchGetManager) {
|
|
$mgrCol = [System.Windows.Controls.DataGridTextColumn]::new()
|
|
$mgrCol.Header = "Manager"
|
|
$mgrCol.Binding = [System.Windows.Data.Binding]::new("Manager")
|
|
$mgrCol.Width = [System.Windows.Controls.DataGridLength]::new(220)
|
|
# Insert after Details (index 1) so order is: Object Name | Details | Manager | Object Type
|
|
$dg.Columns.Insert(2, $mgrCol)
|
|
}
|
|
|
|
# Dynamic Membership Type and Member Count columns for groups
|
|
$existingMemTypeCol = $dg.Columns | Where-Object { $_.Header -eq "Membership Type" }
|
|
if ($existingMemTypeCol) { $dg.Columns.Remove($existingMemTypeCol) }
|
|
$existingMbrCntCol = $dg.Columns | Where-Object { $_.Header -eq "Member Count" }
|
|
if ($existingMbrCntCol) { $dg.Columns.Remove($existingMbrCntCol) }
|
|
|
|
# Check if any groups in results
|
|
$hasGroups = ($results | Where-Object { $_.ObjectType -match 'Group' }) -ne $null
|
|
if ($hasGroups) {
|
|
$memTypeCol = [System.Windows.Controls.DataGridTextColumn]::new()
|
|
$memTypeCol.Header = "Membership Type"
|
|
$memTypeCol.Binding = [System.Windows.Data.Binding]::new("MembershipType")
|
|
$memTypeCol.Width = [System.Windows.Controls.DataGridLength]::new(120)
|
|
$dg.Columns.Add($memTypeCol)
|
|
|
|
$mbrCntCol = [System.Windows.Controls.DataGridTextColumn]::new()
|
|
$mbrCntCol.Header = "Member Count"
|
|
$mbrCntCol.Binding = [System.Windows.Data.Binding]::new("GroupMemberCount")
|
|
$mbrCntCol.Width = [System.Windows.Controls.DataGridLength]::new(100)
|
|
$dg.Columns.Add($mbrCntCol)
|
|
}
|
|
|
|
$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) } }
|
|
$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 ($row in $rows) {
|
|
$vals = [System.Collections.Generic.List[string]]::new()
|
|
foreach ($cd in $colDefs) {
|
|
$v = ''
|
|
if ($cd.P -ne '') { $v = [string]$row.($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'
|
|
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)
|
|
$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 ($row in $allItems) {
|
|
$vals = [System.Collections.Generic.List[string]]::new()
|
|
foreach ($cd in $colDefs) {
|
|
$v = ''
|
|
if ($cd.P -ne '') { $v = [string]$row.($cd.P) }
|
|
$vals.Add($v)
|
|
}
|
|
$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 "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 }
|
|
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
|
|
$dgExp = $script:Window.FindName('DgSearchResults')
|
|
foreach ($col in @($dgExp.Columns)) {
|
|
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
|
|
$bcPath = ''
|
|
if ($bc) { $bcPath = $bc.Binding.Path.Path }
|
|
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
|
|
}
|
|
$exportRows = foreach ($r in $allItems) {
|
|
$ht = [ordered]@{}
|
|
foreach ($cd in $colDefs) { $ht[$cd['H']] = if ($cd['P'] -ne '') { [string]$r.($cd['P']) } else { '' } }
|
|
[PSCustomObject]$ht
|
|
}
|
|
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 {
|
|
$safe_entry = $entry -replace "'","''"
|
|
$safe_entry = $safe_entry -replace '%','%25'
|
|
$safe_entry = $safe_entry -replace '\+','%2B'
|
|
$safe_entry = $safe_entry -replace '#','%23'
|
|
$safe_entry = $safe_entry -replace '&','%26'
|
|
$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 {
|
|
# $filter failed — try $search fallback for special chars like /
|
|
$searchEnc = [Uri]::EscapeDataString($entry)
|
|
$sUri = 'https://graph.microsoft.com/v1.0/groups?$search=%22displayName:' + $searchEnc + '%22&$select=id,displayName&$top=50&$count=true'
|
|
$sHdr = @{ 'ConsistencyLevel' = 'eventual' }
|
|
Write-VerboseLog ('LM search fallback for: ' + $entry) -Level Info
|
|
try {
|
|
$sResp = Invoke-MgGraphRequest -Method GET -Uri $sUri -Headers $sHdr
|
|
} catch {
|
|
Write-VerboseLog ('LM search API error: ' + $_.Exception.Message) -Level Error
|
|
Write-VerboseLog "Group not found: $entry" -Level Warning
|
|
continue
|
|
}
|
|
$found = $false
|
|
if ($sResp['value']) {
|
|
foreach ($c in $sResp['value']) {
|
|
if ($c['displayName'].Trim() -eq $entry.Trim()) {
|
|
$groupId = [string]$c['id']
|
|
$groupName = [string]$c['displayName']
|
|
$found = $true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (-not $found) {
|
|
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 "'","''"
|
|
$safe_entry = $safe_entry -replace '%','%25'
|
|
$safe_entry = $safe_entry -replace '\+','%2B'
|
|
$safe_entry = $safe_entry -replace '#','%23'
|
|
$safe_entry = $safe_entry -replace '&','%26'
|
|
$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 {
|
|
# $filter failed — try $search fallback for special chars like /
|
|
$searchEnc = [Uri]::EscapeDataString($entry)
|
|
$sUri = 'https://graph.microsoft.com/v1.0/groups?$search=%22displayName:' + $searchEnc + '%22&$select=id,displayName&$top=50&$count=true'
|
|
$sHdr = @{ 'ConsistencyLevel' = 'eventual' }
|
|
Write-VerboseLog ('CG: search fallback for: ' + $entry) -Level Info
|
|
$sResp = Invoke-MgGraphRequest -Method GET -Uri $sUri -Headers $sHdr
|
|
$found = $false
|
|
if ($sResp['value']) {
|
|
foreach ($c in $sResp['value']) {
|
|
if ($c['displayName'].Trim() -eq $entry.Trim()) {
|
|
$groupId = [string]$c['id']
|
|
$groupName = [string]$c['displayName']
|
|
$found = $true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (-not $found) {
|
|
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 "'","''"
|
|
$safe_entry = $safe_entry -replace '%','%25'
|
|
$safe_entry = $safe_entry -replace '\+','%2B'
|
|
$safe_entry = $safe_entry -replace '#','%23'
|
|
$safe_entry = $safe_entry -replace '&','%26'
|
|
$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 {
|
|
# $filter failed — try $search fallback for special chars like /
|
|
$searchEnc = [Uri]::EscapeDataString($entry)
|
|
$sUri = 'https://graph.microsoft.com/v1.0/groups?$search=%22displayName:' + $searchEnc + '%22&$select=id,displayName&$top=50&$count=true'
|
|
$sHdr = @{ 'ConsistencyLevel' = 'eventual' }
|
|
Write-VerboseLog ('CG: search fallback for: ' + $entry) -Level Info
|
|
$sResp = Invoke-MgGraphRequest -Method GET -Uri $sUri -Headers $sHdr
|
|
$found = $false
|
|
if ($sResp['value']) {
|
|
foreach ($c in $sResp['value']) {
|
|
if ($c['displayName'].Trim() -eq $entry.Trim()) {
|
|
$groupId = [string]$c['id']
|
|
$groupName = [string]$c['displayName']
|
|
$found = $true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (-not $found) {
|
|
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 "'","''"
|
|
$safe_newName = $safe_newName -replace '%','%25'
|
|
$safe_newName = $safe_newName -replace '\+','%2B'
|
|
$safe_newName = $safe_newName -replace '#','%23'
|
|
$safe_newName = $safe_newName -replace '&','%26'
|
|
$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' {
|
|
$c = $ODataType -replace '^#microsoft\.graph\.', ''
|
|
if ($c -match 'activeDirectory|azureAD') { return 'Autopilot' }
|
|
else { return 'Autopilot' }
|
|
}
|
|
'configurationPolicies_devicePrep' { return 'Autopilot (Device Prep)' }
|
|
'deviceEnrollmentConfigurations' { return 'Autopilot ESP' }
|
|
'deviceCompliancePolicies' { return '' }
|
|
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' }
|
|
'deviceEnrollmentConfigurations' { return 'Windows' }
|
|
'configurationPolicies_devicePrep' { return 'Windows' }
|
|
'deviceCompliancePolicies' {
|
|
$c = $ODataType -replace '^#microsoft\.graph\.', ''
|
|
switch -Wildcard ($c) {
|
|
'windows*' { return 'Windows' }
|
|
'ios*' { return 'iOS/iPadOS' }
|
|
'macOS*' { return 'macOS' }
|
|
'android*' { return 'Android' }
|
|
'aosp*' { return 'AOSP' }
|
|
default { return '' }
|
|
}
|
|
}
|
|
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 Info' -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 Info: starting (ApplyFilters=' + $ApplyFilters + ') ---') -Level Action
|
|
|
|
Invoke-OnBackground -BusyMessage '--- Get Policy Info ---' -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 Profile'; Uri='https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles?$select=id,displayName,description,outOfBoxExperienceSetting' }
|
|
@{ Key='configurationPolicies_devicePrep'; AssignKey='configurationPolicies'; Label='Autopilot Profile'; Uri='https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$filter=(technologies has ''enrollment'')&$select=id,name,description,platforms,technologies,templateReference' }
|
|
@{ Key='deviceEnrollmentConfigurations'; Label='Autopilot Profile'; Uri='https://graph.microsoft.com/beta/deviceManagement/deviceEnrollmentConfigurations?$select=id,displayName,priority,description' }
|
|
@{ Key='deviceCompliancePolicies'; Label='Compliance Policy'; Uri='https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies?$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','configurationPolicies_devicePrep','deviceEnrollmentConfigurations')
|
|
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)
|
|
|
|
# For deviceEnrollmentConfigurations, only process ESP (windows10EnrollmentCompletionPageConfiguration)
|
|
if ($col['Key'] -eq 'deviceEnrollmentConfigurations') {
|
|
$odType = [string]$policy['@odata.type']
|
|
if ($odType -notmatch 'windows10EnrollmentCompletionPageConfiguration') { continue }
|
|
}
|
|
$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']
|
|
|
|
# Autopilot-specific: extract description and deployment mode
|
|
$apDescription = ''
|
|
$apDeployMode = ''
|
|
$espPriority = ''
|
|
if ($col['Key'] -eq 'windowsAutopilotDeploymentProfiles') {
|
|
$apDescription = if ($policy['description']) { [string]$policy['description'] } else { '' }
|
|
$oobe = $policy['outOfBoxExperienceSetting']
|
|
if ($oobe) {
|
|
$ut = [string]$oobe['userType']
|
|
$apDeployMode = switch ($ut) {
|
|
'standard' { 'User-Driven' }
|
|
'administrator' { 'User-Driven (Admin)' }
|
|
'standardUser' { 'User-Driven' }
|
|
default { $ut }
|
|
}
|
|
}
|
|
$odtShort = $odataType -replace '^#microsoft\.graph\.', ''
|
|
if ($odtShort -match 'azureAD' -and [string]::IsNullOrEmpty($apDeployMode)) {
|
|
$apDeployMode = 'Self-Deploying'
|
|
}
|
|
Write-VerboseLog ("GPA: AP profile '" + $pName + "' | type=" + $odataType + " | mode=" + $apDeployMode) -Level Info
|
|
}
|
|
# Device Prep: extract description from configurationPolicies
|
|
if ($col['Key'] -eq 'configurationPolicies_devicePrep') {
|
|
$apDescription = if ($policy['description']) { [string]$policy['description'] } else { '' }
|
|
$apDeployMode = 'Device Prep'
|
|
Write-VerboseLog ('GPA: Device Prep profile ''' + $pName + '''') -Level Info
|
|
}
|
|
# ESP: extract priority and description
|
|
$espPriority = ''
|
|
if ($col['Key'] -eq 'deviceEnrollmentConfigurations') {
|
|
$espPriority = if ($null -ne $policy['priority']) { [string]$policy['priority'] } else { '' }
|
|
$apDescription = if ($policy['description']) { [string]$policy['description'] } else { '' }
|
|
Write-VerboseLog ('GPA: ESP profile ''' + $pName + ''' | priority=' + $espPriority) -Level Info
|
|
}
|
|
$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
|
|
$assignKey = if ($col['AssignKey']) { $col['AssignKey'] } else { $col['Key'] }
|
|
$assignUri = "https://graph.microsoft.com/beta/deviceManagement/$($assignKey)/$($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 = ''
|
|
Description = $apDescription
|
|
DeploymentMode = $apDeployMode
|
|
Priority = $espPriority
|
|
}); 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 { '' }
|
|
Description = $apDescription
|
|
DeploymentMode = $apDeployMode
|
|
Priority = $espPriority
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
$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
|
|
}
|
|
|
|
$sortedArr = @($rows | Sort-Object PolicyName, PolicyType, IncludedGroup, ExcludedGroup)
|
|
$sortedList = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
foreach ($r in $sortedArr) { $sortedList.Add($r) }
|
|
$dg = $script:Window.FindName('DgGPAResults')
|
|
$dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($sortedList)
|
|
|
|
$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 })
|
|
|
|
# ============================================================
|
|
# POLICY EXPORT / POLICY IMPORT (V0.81)
|
|
# Export policies + underlying settings (NO assignments) to JSON;
|
|
# re-create them in the same or a different tenant.
|
|
# Auth: reuses the existing Connect-MgGraph session. NEVER passes -Scopes.
|
|
# ============================================================
|
|
|
|
# ---- Nav tile: Policy Export ----
|
|
$script:Window.FindName('BtnOpPolicyExport').Add_Click({
|
|
$script:Window.FindName('TxtPEXNameFilter').Text = ''
|
|
$script:Window.FindName('CmbPEXType').SelectedIndex = 0
|
|
$script:Window.FindName('CmbPEXSubType').SelectedIndex = 0
|
|
$script:Window.FindName('CmbPEXPlatform').SelectedIndex = 0
|
|
$script:Window.FindName('PnlPEXProgress').Visibility = 'Collapsed'
|
|
$script:Window.FindName('PnlPEXResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('TxtPEXNoResults').Visibility = 'Collapsed'
|
|
$dg = $script:Window.FindName('DgPEXResults'); if ($dg) { $dg.ItemsSource = $null }
|
|
$script:Window.FindName('BtnPEXOpenFolder').IsEnabled = $false
|
|
$script:Window.FindName('BtnPEXCopyAll').IsEnabled = $false
|
|
$script:PEXResult = $null
|
|
Show-Panel 'PanelPolicyExport'
|
|
Hide-Notification
|
|
Write-VerboseLog 'Panel: Policy Export' -Level Info
|
|
})
|
|
|
|
# ---- Nav tile: Policy Import ----
|
|
$script:Window.FindName('BtnOpPolicyImport').Add_Click({
|
|
$script:Window.FindName('PnlPIMProgress').Visibility = 'Collapsed'
|
|
$script:Window.FindName('PnlPIMResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('TxtPIMNoResults').Visibility = 'Collapsed'
|
|
$dg = $script:Window.FindName('DgPIMResults'); if ($dg) { $dg.ItemsSource = $null }
|
|
$script:Window.FindName('BtnPIMCopyAll').IsEnabled = $false
|
|
$script:Window.FindName('TxtPIMNameFilter').Text = ''
|
|
$script:Window.FindName('CmbPIMType').SelectedIndex = 0
|
|
$script:Window.FindName('CmbPIMSubType').SelectedIndex = 0
|
|
$script:Window.FindName('CmbPIMPlatform').SelectedIndex = 0
|
|
Show-Panel 'PanelPolicyImport'
|
|
Hide-Notification
|
|
Write-VerboseLog 'Panel: Policy Import' -Level Info
|
|
})
|
|
|
|
# ============================================================
|
|
# EXPORT
|
|
# ============================================================
|
|
function Start-PolicyExport {
|
|
param([bool]$ApplyFilters, [string]$Target) # Target: 'Local' | 'Blob'
|
|
|
|
Hide-Notification
|
|
$nameKw = if ($ApplyFilters) { $script:Window.FindName('TxtPEXNameFilter').Text.Trim() } else { '' }
|
|
$typeF = if ($ApplyFilters) { $script:Window.FindName('CmbPEXType').SelectedItem.Content } else { '(All types)' }
|
|
$subF = if ($ApplyFilters) { $script:Window.FindName('CmbPEXSubType').SelectedItem.Content } else { '(All sub-types)' }
|
|
$platF = if ($ApplyFilters) { $script:Window.FindName('CmbPEXPlatform').SelectedItem.Content } else { '(All platforms)' }
|
|
|
|
$localRoot = $null
|
|
if ($Target -eq 'Local') {
|
|
Add-Type -AssemblyName System.Windows.Forms
|
|
$fb = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
$fb.Description = 'Choose a folder to save the exported policy JSON files'
|
|
if ($fb.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) {
|
|
Write-VerboseLog 'Policy Export cancelled (no folder chosen).' -Level Warning; return
|
|
}
|
|
$localRoot = $fb.SelectedPath
|
|
}
|
|
|
|
$sasUrl = ''
|
|
if ($Target -eq 'Blob') {
|
|
Add-Type -AssemblyName Microsoft.VisualBasic
|
|
$sasUrl = [Microsoft.VisualBasic.Interaction]::InputBox(
|
|
'Paste the FULL Blob container SAS URL - the "Blob SAS URL" field from Azure (starts with https:// and includes the container path AND the token). Needs Create + Write. Used only for this operation and never stored.',
|
|
'Export to Azure Blob Container', '')
|
|
if ([string]::IsNullOrWhiteSpace($sasUrl)) {
|
|
Write-VerboseLog 'Policy Export cancelled (no Blob SAS URL).' -Level Warning; return
|
|
}
|
|
}
|
|
|
|
$script:PEXParams = @{
|
|
NameKeyword = $nameKw; TypeFilter = $typeF; SubTypeFilter = $subF; PlatFilter = $platF
|
|
Target = $Target; LocalRoot = $localRoot; SasUrl = $sasUrl
|
|
}
|
|
$script:PEXResult = $null
|
|
|
|
$script:Window.FindName('PnlPEXResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('TxtPEXNoResults').Visibility = 'Collapsed'
|
|
$dg = $script:Window.FindName('DgPEXResults'); if ($dg) { $dg.ItemsSource = $null }
|
|
$script:Window.FindName('BtnPEXOpenFolder').IsEnabled = $false
|
|
$script:Window.FindName('BtnPEXCopyAll').IsEnabled = $false
|
|
$script:Window.FindName('PnlPEXProgress').Visibility = 'Visible'
|
|
$script:Window.FindName('TxtPEXProgressMsg').Text = 'Exporting policies...'
|
|
$script:Window.FindName('TxtPEXProgressDetail').Text = ''
|
|
foreach ($b in @('BtnPEXSelLocal','BtnPEXAllLocal','BtnPEXSelBlob','BtnPEXAllBlob')) {
|
|
$script:Window.FindName($b).IsEnabled = $false
|
|
}
|
|
Write-VerboseLog ('--- Policy Export: starting (Target=' + $Target + ', ApplyFilters=' + $ApplyFilters + ') ---') -Level Action
|
|
|
|
Invoke-OnBackground -BusyMessage '--- Policy Export ---' -Work {
|
|
|
|
$p = $script:PEXParams
|
|
$nameKw = $p['NameKeyword']
|
|
$typeFilter = $p['TypeFilter']
|
|
$subFilter = $p['SubTypeFilter']
|
|
$platFilter = $p['PlatFilter']
|
|
$target = $p['Target']
|
|
$base = 'https://graph.microsoft.com/beta/deviceManagement'
|
|
|
|
function PEX-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
|
|
}
|
|
|
|
$tenantId = ''
|
|
try { $tenantId = [string](Get-MgContext).TenantId } catch {}
|
|
|
|
# Parse the Blob container SAS URL. The SAS carries its own authorization - no app registration, no Graph, no WebDAV.
|
|
$blobBase = ''; $blobSas = ''; $blobLabel = ''
|
|
if ($target -eq 'Blob') {
|
|
try {
|
|
$raw = ([string]$p['SasUrl']).Trim()
|
|
if ($raw -notmatch '^https?://') { throw 'Paste the FULL container SAS URL (e.g. https://<account>.blob.core.windows.net/<container>?sp=...&sig=...), not just the token.' }
|
|
$qi = $raw.IndexOf('?')
|
|
if ($qi -lt 0) { throw 'The SAS URL is missing its ?...sig=... query string.' }
|
|
$blobBase = $raw.Substring(0,$qi).TrimEnd('/')
|
|
$blobSas = $raw.Substring($qi+1)
|
|
if ($blobSas -notmatch 'sig=') { throw 'The SAS query string is missing the sig= signature. Use the full Blob SAS URL from Azure.' }
|
|
$bu = [System.Uri]$blobBase
|
|
$blobLabel = $bu.Host + $bu.AbsolutePath
|
|
Write-VerboseLog ("PEX: Blob container target = " + $blobLabel + " (uploaded via SAS; token not logged)") -Level Info
|
|
} catch {
|
|
Write-VerboseLog ("PEX: Invalid Blob SAS URL: " + $_.Exception.Message) -Level Error
|
|
$script:PEXResult = @{ Rows = [System.Collections.Generic.List[PSCustomObject]]::new(); OutFolder = ''; FatalError = ("Invalid Blob SAS URL: " + $_.Exception.Message) }
|
|
return
|
|
}
|
|
}
|
|
|
|
# Output folder (local target = chosen folder; Blob target = local staging mirror)
|
|
$ts = (Get-Date).ToString('yyyyMMdd_HHmmss')
|
|
if ($target -eq 'Local') { $outFolder = $p['LocalRoot'] }
|
|
else { $outFolder = Join-Path 'C:\Logs\MDM-ODA\PolicyExports' $ts }
|
|
if (-not (Test-Path $outFolder)) { New-Item -ItemType Directory -Path $outFolder -Force | Out-Null }
|
|
|
|
# Type map
|
|
$typeMeta = @{
|
|
'deviceConfigurations' = @{ Label='Device Configuration'; NameField='displayName' }
|
|
'configurationPolicies' = @{ Label='Configuration Policy'; NameField='name' }
|
|
'groupPolicyConfigurations' = @{ Label='Administrative Templates'; NameField='displayName' }
|
|
'deviceManagementScripts' = @{ Label='Platform Script (Windows PowerShell)'; NameField='displayName' }
|
|
'deviceHealthScripts' = @{ Label='Detection/Remediation Script'; NameField='displayName' }
|
|
'windowsAutopilotDeploymentProfiles' = @{ Label='Autopilot Profile'; NameField='displayName' }
|
|
'deviceCompliancePolicies' = @{ Label='Compliance Policy'; NameField='displayName' }
|
|
}
|
|
|
|
$enumUris = @{
|
|
'deviceConfigurations' = ($base + '/deviceConfigurations?' + '$select=id,displayName')
|
|
'configurationPolicies' = ($base + '/configurationPolicies?' + '$select=id,name,platforms,technologies,templateReference')
|
|
'groupPolicyConfigurations' = ($base + '/groupPolicyConfigurations?' + '$select=id,displayName,policyConfigurationIngestionType')
|
|
'deviceManagementScripts' = ($base + '/deviceManagementScripts?' + '$select=id,displayName')
|
|
'deviceHealthScripts' = ($base + '/deviceHealthScripts?' + '$select=id,displayName,publisher,isGlobalScript')
|
|
'windowsAutopilotDeploymentProfiles' = ($base + '/windowsAutopilotDeploymentProfiles?' + '$select=id,displayName,description,outOfBoxExperienceSetting')
|
|
'deviceCompliancePolicies' = ($base + '/deviceCompliancePolicies?' + '$select=id,displayName')
|
|
}
|
|
|
|
$order = @('deviceConfigurations','configurationPolicies','groupPolicyConfigurations','deviceManagementScripts','deviceHealthScripts','windowsAutopilotDeploymentProfiles','deviceCompliancePolicies')
|
|
|
|
$rows = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
$manifest = [System.Collections.Generic.List[object]]::new()
|
|
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
|
|
|
foreach ($key in $order) {
|
|
$label = $typeMeta[$key]['Label']
|
|
$nameFld = $typeMeta[$key]['NameField']
|
|
|
|
if ($typeFilter -ne '(All types)' -and $label -ne $typeFilter) { continue }
|
|
|
|
Write-VerboseLog ("PEX: Enumerating " + $label + "...") -Level Info
|
|
$list = @()
|
|
try { $list = @(PEX-Page -Uri $enumUris[$key]) }
|
|
catch { Write-VerboseLog ("PEX: Could not enumerate " + $key + ": " + $_.Exception.Message) -Level Warning; continue }
|
|
Write-VerboseLog ("PEX: " + $list.Count + " " + $label + " found") -Level Info
|
|
|
|
foreach ($item in $list) {
|
|
if ($null -ne $Shared -and $Shared['StopRequested']) { Write-VerboseLog 'PEX: Stop requested.' -Level Warning; break }
|
|
|
|
$polId = [string]$item['id']
|
|
$pName = [string]$item[$nameFld]
|
|
if (-not $pName) { $pName = [string]$item['displayName'] }
|
|
if ($nameKw) {
|
|
$kws = @($nameKw -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
|
if ($kws.Count -gt 0) {
|
|
$kwHit = $false
|
|
foreach ($kwt in $kws) { if ($pName -like ('*' + $kwt + '*')) { $kwHit = $true; break } }
|
|
if (-not $kwHit) { continue }
|
|
}
|
|
}
|
|
|
|
$odataType = [string]$item['@odata.type']
|
|
$tmplName = if ($item['templateReference']) { [string]$item['templateReference']['templateDisplayName'] } else { '' }
|
|
$platforms = [string]$item['platforms']
|
|
$subType = Get-GPASubType -PolicyCollection $key -ODataType $odataType -TemplateDisplayName $tmplName
|
|
$platform = Get-GPAPlatform -PolicyCollection $key -ODataType $odataType -Platforms $platforms
|
|
|
|
if ($subFilter -ne '(All sub-types)' -and $subType -ne $subFilter) { continue }
|
|
if ($platFilter -ne '(All platforms)' -and $platform -ne $platFilter) { continue }
|
|
|
|
$warnList = [System.Collections.Generic.List[string]]::new()
|
|
$status = 'Exported'
|
|
$fname = ''
|
|
try {
|
|
# Full GET per type (with required expands)
|
|
$full = $null
|
|
$defs = $null
|
|
$admxFiles = $null
|
|
switch ($key) {
|
|
'configurationPolicies' {
|
|
$full = Invoke-MgGraphRequest -Method GET -Uri ($base + '/configurationPolicies/' + $polId + '?' + '$expand=settings')
|
|
}
|
|
'deviceCompliancePolicies' {
|
|
$full = Invoke-MgGraphRequest -Method GET -Uri ($base + '/deviceCompliancePolicies/' + $polId + '?' + '$expand=scheduledActionsForRule($expand=scheduledActionConfigurations)')
|
|
}
|
|
'groupPolicyConfigurations' {
|
|
$full = Invoke-MgGraphRequest -Method GET -Uri ($base + '/groupPolicyConfigurations/' + $polId)
|
|
# Admin Templates: the combined nested $expand (presentationValues($expand=presentation)) 400s on
|
|
# some tenants. Robust two-step: get definitionValues (+definition), then presentationValues per value.
|
|
$defsRaw = @(PEX-Page -Uri ($base + '/groupPolicyConfigurations/' + $polId + '/definitionValues?' + '$expand=definition'))
|
|
$defsList = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($dv in $defsRaw) {
|
|
if ($dv -is [System.Collections.IDictionary]) {
|
|
$dvId = [string]$dv['id']
|
|
$pvs = @()
|
|
if ($dvId) { try { $pvs = @(PEX-Page -Uri ($base + '/groupPolicyConfigurations/' + $polId + '/definitionValues/' + $dvId + '/presentationValues?' + '$expand=presentation')) } catch {} }
|
|
$dv['presentationValues'] = $pvs
|
|
$defsList.Add($dv)
|
|
}
|
|
}
|
|
$defs = @($defsList)
|
|
# Custom/ingested ADMX: capture the uploaded ADMX + ADML files so the policy can round-trip cross-tenant.
|
|
$admxFiles = [System.Collections.Generic.List[object]]::new()
|
|
$admxSeen = @{}
|
|
foreach ($dv in $defs) {
|
|
$dd = $dv['definition']
|
|
if (($dd -is [System.Collections.IDictionary]) -and ([string]$dd['policyType'] -eq 'admxIngested')) {
|
|
$ddId = [string]$dd['id']
|
|
if (-not $ddId) { continue }
|
|
try {
|
|
$dfull = Invoke-MgGraphRequest -Method GET -Uri ($base + '/groupPolicyDefinitions/' + $ddId + '?' + '$expand=definitionFile')
|
|
$df = $dfull['definitionFile']
|
|
if ($df -is [System.Collections.IDictionary]) {
|
|
$fid = [string]$df['id']
|
|
if ($fid -and -not $admxSeen.ContainsKey($fid)) {
|
|
$admxSeen[$fid] = $true
|
|
$ff = Invoke-MgGraphRequest -Method GET -Uri ($base + '/groupPolicyUploadedDefinitionFiles/' + $fid + '?' + '$expand=groupPolicyUploadedLanguageFiles&$select=id,displayName,description,fileName,languageCodes,defaultLanguageCode,targetPrefix,targetNamespace,policyType,revision,content,groupPolicyUploadedLanguageFiles')
|
|
foreach ($lf in @($ff['groupPolicyUploadedLanguageFiles'])) {
|
|
if (($lf -is [System.Collections.IDictionary]) -and (-not $lf['content'])) {
|
|
try { $lfx = Invoke-MgGraphRequest -Method GET -Uri ($base + '/groupPolicyUploadedDefinitionFiles/' + $fid + '/groupPolicyUploadedLanguageFiles/' + [string]$lf['id'] + '?' + '$select=fileName,languageCode,content'); $lf['content'] = $lfx['content'] } catch {}
|
|
}
|
|
}
|
|
$admxFiles.Add($ff)
|
|
}
|
|
}
|
|
} catch { $warnList.Add('Could not capture ingested ADMX: ' + $_.Exception.Message) }
|
|
}
|
|
}
|
|
$ing = [string]$item['policyConfigurationIngestionType']
|
|
if ($ing -and $ing -ne 'builtIn') {
|
|
if ($admxFiles.Count -gt 0) { $warnList.Add('Custom/ingested ADMX bundled (' + $admxFiles.Count + ' file[s]) for cross-tenant re-ingestion on import.') }
|
|
else { $warnList.Add('Custom/ingested ADMX definitions may not exist in the target tenant.') }
|
|
}
|
|
}
|
|
'deviceHealthScripts' {
|
|
$full = Invoke-MgGraphRequest -Method GET -Uri ($base + '/deviceHealthScripts/' + $polId)
|
|
$pub = [string]$full['publisher']; $isg = $full['isGlobalScript']
|
|
if ($pub -eq 'Microsoft' -or $isg -eq $true) { $warnList.Add('Microsoft built-in remediation: cannot be re-created on import (will be skipped)') }
|
|
}
|
|
default {
|
|
$full = Invoke-MgGraphRequest -Method GET -Uri ($base + '/' + $key + '/' + $polId)
|
|
}
|
|
}
|
|
|
|
# Secret detection (best-effort)
|
|
$rawScan = ($full | ConvertTo-Json -Depth 30 -Compress)
|
|
if ($rawScan -match 'secretReferenceValueId|isEncrypted|SecretSettingValue') {
|
|
$warnList.Add('Contains secret/encrypted values that are write-only and will NOT round-trip')
|
|
}
|
|
|
|
$exp = [ordered]@{
|
|
mdmOdaExport = [ordered]@{
|
|
schemaVersion = '1.0'
|
|
exportedBy = 'MDM-ODA V0.81'
|
|
exportedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
|
|
sourceTenantId = $tenantId
|
|
policyTypeKey = $key
|
|
policyTypeLabel= $label
|
|
graphImportUri = ($base + '/' + $key)
|
|
displayName = $pName
|
|
sourcePolicyId = $polId
|
|
subType = $subType
|
|
platform = $platform
|
|
warnings = @($warnList)
|
|
}
|
|
policy = $full
|
|
}
|
|
if ($key -eq 'groupPolicyConfigurations') { $exp['adminTemplateDefinitionValues'] = @($defs) }
|
|
if ($key -eq 'groupPolicyConfigurations' -and $admxFiles -and $admxFiles.Count -gt 0) { $exp['customAdmxFiles'] = @($admxFiles) }
|
|
|
|
$json = $exp | ConvertTo-Json -Depth 30
|
|
$safe = ($pName -replace '[^\w\.\- ]','_').Trim()
|
|
if ($safe.Length -gt 70) { $safe = $safe.Substring(0,70) }
|
|
$id8 = if ($polId.Length -ge 8) { $polId.Substring(0,8) } else { $polId }
|
|
$fname = ($key + '__' + $safe + '__' + $id8 + '.json')
|
|
$fpath = Join-Path $outFolder $fname
|
|
[System.IO.File]::WriteAllText($fpath, $json, $utf8)
|
|
|
|
if ($target -eq 'Blob') {
|
|
try {
|
|
$blobUri = $blobBase + '/' + [System.Uri]::EscapeDataString($fname) + '?' + $blobSas
|
|
$null = Invoke-WebRequest -Method Put -Uri $blobUri -Headers @{ 'x-ms-blob-type'='BlockBlob' } -Body $json -ContentType 'application/json' -UseBasicParsing -ErrorAction Stop
|
|
Write-VerboseLog ("PEX: Uploaded to Blob: " + $fname) -Level Success
|
|
} catch {
|
|
$status = 'Local only'
|
|
$detail = [string]$_.Exception.Message
|
|
try { if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $detail = [string]$_.ErrorDetails.Message } } catch {}
|
|
if ($detail.Length -gt 400) { $detail = $detail.Substring(0,400) + ' ...(truncated)' }
|
|
$warnList.Add('Blob upload failed: ' + $detail)
|
|
Write-VerboseLog ("PEX: Blob upload FAILED for " + $fname + " -> " + $blobLabel + "/" + $fname) -Level Error
|
|
Write-VerboseLog ("PEX: detail: " + $detail) -Level Error
|
|
}
|
|
}
|
|
|
|
$manifest.Add(@{ file=$fname; type=$key; displayName=$pName; sourcePolicyId=$polId })
|
|
Write-VerboseLog ("PEX: Exported '" + $pName + "' (" + $label + ")") -Level Success
|
|
} catch {
|
|
$status = 'Failed'
|
|
$em = [string]$_.Exception.Message
|
|
try { if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $em = [string]$_.ErrorDetails.Message } } catch {}
|
|
try {
|
|
if (($em -notmatch '"message"') -and $_.Exception -and $_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) {
|
|
$rc = $_.Exception.Response.Content
|
|
if ($rc) { $bb = $rc.ReadAsStringAsync().Result; if ($bb) { $em = [string]$bb } }
|
|
}
|
|
} catch {}
|
|
$em = ($em -replace '\s+',' ').Trim()
|
|
if ($em.Length -gt 500) { $em = $em.Substring(0,500) + ' ...' }
|
|
$warnList.Add($em)
|
|
Write-VerboseLog ("PEX: FAILED '" + $pName + "': " + $em) -Level Error
|
|
}
|
|
|
|
$rows.Add([PSCustomObject]@{
|
|
DisplayName = $pName
|
|
PolicyType = $label
|
|
Status = $status
|
|
FileName = $fname
|
|
Warnings = ($warnList -join '; ')
|
|
})
|
|
}
|
|
}
|
|
|
|
# Write manifest
|
|
try {
|
|
$manPath = Join-Path $outFolder ('_manifest_' + $ts + '.json')
|
|
$manObj = [ordered]@{ exportedBy='MDM-ODA V0.81'; exportedAtUtc=(Get-Date).ToUniversalTime().ToString('o'); sourceTenantId=$tenantId; count=$manifest.Count; files=@($manifest) }
|
|
[System.IO.File]::WriteAllText($manPath, ($manObj | ConvertTo-Json -Depth 10), $utf8)
|
|
} catch { Write-VerboseLog ("PEX: manifest write failed: " + $_.Exception.Message) -Level Warning }
|
|
|
|
$script:PEXResult = @{ Rows = $rows; OutFolder = $outFolder; FatalError = '' }
|
|
Write-VerboseLog ("PEX: Complete. " + $rows.Count + " policy file(s) written to " + $outFolder) -Level Info
|
|
|
|
} -Done {
|
|
|
|
foreach ($b in @('BtnPEXSelLocal','BtnPEXAllLocal','BtnPEXSelBlob','BtnPEXAllBlob')) {
|
|
$script:Window.FindName($b).IsEnabled = $true
|
|
}
|
|
$script:Window.FindName('PnlPEXProgress').Visibility = 'Collapsed'
|
|
|
|
$res = $script:PEXResult
|
|
if ($res -and $res['FatalError']) {
|
|
Show-Notification -Message $res['FatalError'] -BgColor '#F8D7DA' -FgColor '#721C24'
|
|
}
|
|
$rows = if ($res) { $res['Rows'] } else { $null }
|
|
if (-not $rows -or $rows.Count -eq 0) {
|
|
$script:Window.FindName('TxtPEXNoResults').Visibility = 'Visible'
|
|
Write-VerboseLog 'PEX: No policies exported.' -Level Warning
|
|
return
|
|
}
|
|
$sorted = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
foreach ($r in @($rows | Sort-Object PolicyType, DisplayName)) { $sorted.Add($r) }
|
|
$dg = $script:Window.FindName('DgPEXResults')
|
|
$dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($sorted)
|
|
$ok = @($rows | Where-Object { $_.Status -eq 'Exported' }).Count
|
|
$script:Window.FindName('TxtPEXCount').Text = ("$ok of $($rows.Count) exported | " + $res['OutFolder'])
|
|
$script:PEXOutFolder = $res['OutFolder']
|
|
$script:Window.FindName('BtnPEXOpenFolder').IsEnabled = $true
|
|
$script:Window.FindName('BtnPEXCopyAll').IsEnabled = $true
|
|
$script:Window.FindName('PnlPEXResults').Visibility = 'Visible'
|
|
}
|
|
}
|
|
|
|
$script:Window.FindName('BtnPEXSelLocal').Add_Click({ Start-PolicyExport -ApplyFilters $true -Target 'Local' })
|
|
$script:Window.FindName('BtnPEXAllLocal').Add_Click({ Start-PolicyExport -ApplyFilters $false -Target 'Local' })
|
|
$script:Window.FindName('BtnPEXSelBlob').Add_Click( { Start-PolicyExport -ApplyFilters $true -Target 'Blob' })
|
|
$script:Window.FindName('BtnPEXAllBlob').Add_Click( { Start-PolicyExport -ApplyFilters $false -Target 'Blob' })
|
|
|
|
$script:Window.FindName('BtnPEXOpenFolder').Add_Click({
|
|
if ($script:PEXOutFolder -and (Test-Path $script:PEXOutFolder)) { Start-Process explorer.exe $script:PEXOutFolder }
|
|
})
|
|
$script:Window.FindName('BtnPEXCopyAll').Add_Click({
|
|
$dg = $script:Window.FindName('DgPEXResults')
|
|
if ($dg -and $dg.ItemsSource) {
|
|
$sb = [System.Text.StringBuilder]::new()
|
|
[void]$sb.AppendLine("DisplayName`tType`tStatus`tFile`tWarnings")
|
|
foreach ($r in $dg.ItemsSource) { [void]$sb.AppendLine(($r.DisplayName,$r.PolicyType,$r.Status,$r.FileName,$r.Warnings -join "`t")) }
|
|
try { [System.Windows.Clipboard]::SetText($sb.ToString()); Show-Notification -Message 'Copied export summary to clipboard' -BgColor '#D4EDDA' -FgColor '#155724' } catch {}
|
|
}
|
|
})
|
|
|
|
# ============================================================
|
|
# IMPORT
|
|
# ============================================================
|
|
|
|
# Preview-before-write dialog. Returns the selected subset (List) or $null if cancelled.
|
|
function Show-DuplicatePrompt {
|
|
param([int]$DupCount,[int]$Total)
|
|
[xml]$dupXaml = @"
|
|
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
Title="Duplicate policies found" Height="240" Width="500"
|
|
WindowStartupLocation="CenterScreen" Background="#F4F7F4" ResizeMode="NoResize">
|
|
<Grid Margin="18">
|
|
<Grid.RowDefinitions><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
|
|
<StackPanel Grid.Row="0">
|
|
<TextBlock Text="Some selected policies already exist in the target tenant" FontSize="14" FontWeight="SemiBold" Foreground="#166534" TextWrapping="Wrap"/>
|
|
<TextBlock x:Name="TxtDupMsg" FontSize="12" Foreground="#374151" Margin="0,10,0,0" TextWrapping="Wrap"/>
|
|
<TextBlock FontSize="11" Foreground="#6B7280" Margin="0,10,0,0" TextWrapping="Wrap"
|
|
Text="Proceed Anyway creates duplicates with an (Imported) suffix. Skip Duplicates imports only the missing ones. Back to Review returns to the list."/>
|
|
</StackPanel>
|
|
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,16,0,0">
|
|
<Button x:Name="BtnDupBack" Content="Back to Review" Width="120" Height="32" Margin="0,0,8,0"/>
|
|
<Button x:Name="BtnDupSkip" Content="Skip Duplicates" Width="130" Height="32" Margin="0,0,8,0"/>
|
|
<Button x:Name="BtnDupProceed" Content="Proceed Anyway" Width="140" Height="32" Background="#16A34A" Foreground="White" FontWeight="SemiBold"/>
|
|
</StackPanel>
|
|
</Grid>
|
|
</Window>
|
|
"@
|
|
$drdr = New-Object System.Xml.XmlNodeReader $dupXaml
|
|
$dw = [Windows.Markup.XamlReader]::Load($drdr)
|
|
$dw.FindName('TxtDupMsg').Text = ("$DupCount of $Total selected policy(ies) already exist in the target tenant (matched by name within the same policy type).")
|
|
$dupHolder = @{ R = 'Back' }
|
|
$dw.FindName('BtnDupBack').Add_Click({ $dupHolder.R = 'Back'; $dw.Close() }.GetNewClosure())
|
|
$dw.FindName('BtnDupSkip').Add_Click({ $dupHolder.R = 'Skip'; $dw.Close() }.GetNewClosure())
|
|
$dw.FindName('BtnDupProceed').Add_Click({ $dupHolder.R = 'Proceed'; $dw.Close() }.GetNewClosure())
|
|
$dw.ShowDialog() | Out-Null
|
|
return $dupHolder.R
|
|
}
|
|
|
|
function Show-ImportPreview {
|
|
param($Rows)
|
|
[xml]$pvXaml = @"
|
|
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
Title="Confirm Policy Import" Height="520" Width="1010"
|
|
WindowStartupLocation="CenterScreen" Background="#F4F7F4" 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="Review before import" FontSize="15" FontWeight="SemiBold" Foreground="#166534"/>
|
|
<TextBlock TextWrapping="Wrap" FontSize="11" Foreground="#4B5563" Margin="0,4,0,0"
|
|
Text="Each selected policy will be created as a NEW object with an (Imported) suffix. Nothing existing is modified. Rows that are not importable are shown but cannot be selected. Deselect any rows you want to skip."/>
|
|
</StackPanel>
|
|
<DataGrid x:Name="DgPreview" Grid.Row="1" AutoGenerateColumns="False" IsReadOnly="True"
|
|
SelectionMode="Extended" SelectionUnit="FullRow" HeadersVisibility="Column"
|
|
GridLinesVisibility="All" RowBackground="White" AlternatingRowBackground="#F5F5FF"
|
|
CanUserResizeColumns="True" FontSize="12">
|
|
<DataGrid.Columns>
|
|
<DataGridTextColumn Header="Source Name" Binding="{Binding DisplayName}" Width="200"/>
|
|
<DataGridTextColumn Header="Type" Binding="{Binding PolicyType}" Width="150"/>
|
|
<DataGridTextColumn Header="Will Create As" Binding="{Binding PlannedName}" Width="200"/>
|
|
<DataGridTextColumn Header="Importable" Binding="{Binding ImportableText}" Width="80"/>
|
|
<DataGridTextColumn Header="Status" Binding="{Binding PresenceStatus}" Width="110"/>
|
|
<DataGridTextColumn Header="Warnings" Binding="{Binding Warnings}" Width="200"/>
|
|
</DataGrid.Columns>
|
|
</DataGrid>
|
|
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
|
<TextBlock x:Name="TxtPvCount" VerticalAlignment="Center" Margin="0,0,16,0" FontSize="11" Foreground="#6B7280"/>
|
|
<Button x:Name="BtnPvSelectAll" Content="Select All" Width="100" Height="32" Margin="0,0,8,0"/>
|
|
<Button x:Name="BtnPvClear" Content="Clear" Width="80" Height="32" Margin="0,0,8,0"/>
|
|
<Button x:Name="BtnPvCancel" Content="Cancel" Width="90" Height="32" Margin="0,0,8,0"/>
|
|
<Button x:Name="BtnPvImport" Content="Import Selected" Width="150" Height="32"
|
|
Background="#16A34A" Foreground="White" FontWeight="SemiBold"/>
|
|
</StackPanel>
|
|
</Grid>
|
|
</Window>
|
|
"@
|
|
$rdr = New-Object System.Xml.XmlNodeReader $pvXaml
|
|
$win = [Windows.Markup.XamlReader]::Load($rdr)
|
|
$dg = $win.FindName('DgPreview')
|
|
$importable = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
foreach ($r in $Rows) { $importable.Add($r) }
|
|
$dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($importable)
|
|
$win.FindName('TxtPvCount').Text = ("$($Rows.Count) file(s) parsed")
|
|
|
|
# Pre-select importable rows
|
|
$dg.Add_Loaded({
|
|
param($sender,$e)
|
|
foreach ($it in $sender.ItemsSource) { if ($it.Importable) { $sender.SelectedItems.Add($it) } }
|
|
})
|
|
|
|
# NOTE: GetNewClosure() runs the handler in its OWN module scope, so `$script:X = ...` inside it
|
|
# would NOT reach this function. Communicate via a captured hashtable (mutating a shared object works).
|
|
# Button handlers only record the selection + intent and close the dialog; the duplicate DECISION
|
|
# runs below in the FUNCTION scope (not inside a closure) so it always fires reliably.
|
|
$pvHolder = @{ Result = $null; DoImport = $false }
|
|
$win.FindName('BtnPvCancel').Add_Click({ $pvHolder.DoImport = $false; $win.Close() }.GetNewClosure())
|
|
$win.FindName('BtnPvSelectAll').Add_Click({
|
|
$g = $win.FindName('DgPreview'); $g.UnselectAll()
|
|
foreach ($it in @($g.ItemsSource)) { if ($it.Importable) { $g.SelectedItems.Add($it) } }
|
|
}.GetNewClosure())
|
|
$win.FindName('BtnPvClear').Add_Click({ $win.FindName('DgPreview').UnselectAll() }.GetNewClosure())
|
|
$win.FindName('BtnPvImport').Add_Click({
|
|
$g = $win.FindName('DgPreview')
|
|
$sel = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
foreach ($it in @($g.SelectedItems)) { if ($it.Importable) { $sel.Add($it) } }
|
|
if ($sel.Count -eq 0) { foreach ($it in @($g.ItemsSource)) { if ($it.Importable) { $sel.Add($it) } } }
|
|
$pvHolder.Result = $sel
|
|
$pvHolder.DoImport = $true
|
|
$win.Close()
|
|
}.GetNewClosure())
|
|
|
|
$win.ShowDialog() | Out-Null
|
|
|
|
if (-not $pvHolder.DoImport) { return $null }
|
|
$sel = @($pvHolder.Result)
|
|
if ($sel.Count -eq 0) { return $null }
|
|
# How many of the chosen policies already exist in the target tenant (by content)?
|
|
$dupN = 0; foreach ($it in $sel) { if ($it.AlreadyPresent) { $dupN++ } }
|
|
if ($dupN -le 0) { return $sel }
|
|
$choice = Show-DuplicatePrompt -DupCount $dupN -Total $sel.Count
|
|
if ($choice -eq 'Proceed') { return $sel }
|
|
if ($choice -eq 'Skip') {
|
|
$sel2 = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
foreach ($it in $sel) { if (-not $it.AlreadyPresent) { $sel2.Add($it) } }
|
|
if ($sel2.Count -eq 0) {
|
|
[void][System.Windows.MessageBox]::Show(('All ' + $sel.Count + ' selected policy(ies) already exist in the target tenant. There are no new (non-duplicate) policies to import.'), 'Nothing to import', 'OK', 'Information')
|
|
return (Show-ImportPreview -Rows $Rows)
|
|
}
|
|
[void][System.Windows.MessageBox]::Show(($sel2.Count.ToString() + ' new policy(ies) will be imported; ' + $dupN + ' already-present duplicate(s) skipped.'), 'Skipping duplicates', 'OK', 'Information')
|
|
return $sel2
|
|
}
|
|
# Back to Review -> re-open the table
|
|
return (Show-ImportPreview -Rows $Rows)
|
|
}
|
|
|
|
function Start-PolicyImport {
|
|
param([bool]$Selected, [string]$Source) # Source: 'Local' | 'Blob'
|
|
|
|
Hide-Notification
|
|
$candidates = [System.Collections.Generic.List[hashtable]]::new()
|
|
$sasUrl = ''
|
|
|
|
if ($Source -eq 'Local') {
|
|
Add-Type -AssemblyName System.Windows.Forms
|
|
if ($Selected) {
|
|
$ofd = New-Object System.Windows.Forms.OpenFileDialog
|
|
$ofd.Filter = 'Policy export JSON (*.json)|*.json'
|
|
$ofd.Multiselect = $true
|
|
$ofd.Title = 'Select policy export JSON files to import'
|
|
if ($ofd.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { Write-VerboseLog 'Policy Import cancelled.' -Level Warning; return }
|
|
foreach ($f in $ofd.FileNames) { $candidates.Add(@{ Source='Local'; Path=$f; Name=([System.IO.Path]::GetFileName($f)) }) }
|
|
} else {
|
|
$fb = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
$fb.Description = 'Choose a folder containing policy export JSON files (all .json will be imported)'
|
|
if ($fb.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { Write-VerboseLog 'Policy Import cancelled.' -Level Warning; return }
|
|
foreach ($f in @(Get-ChildItem -Path $fb.SelectedPath -Filter '*.json' -File | Where-Object { $_.Name -notlike '_manifest_*' })) {
|
|
$candidates.Add(@{ Source='Local'; Path=$f.FullName; Name=$f.Name })
|
|
}
|
|
if ($candidates.Count -eq 0) { Show-Notification -Message 'No .json files found in that folder.' -BgColor '#FFF3CD' -FgColor '#856404'; return }
|
|
}
|
|
} else {
|
|
Add-Type -AssemblyName Microsoft.VisualBasic
|
|
$sasUrl = [Microsoft.VisualBasic.Interaction]::InputBox(
|
|
'Paste the FULL Blob container SAS URL - the "Blob SAS URL" field from Azure (starts with https:// and includes the container path AND the token). Needs Read + List. Used only for this operation and never stored.',
|
|
'Import from Azure Blob Container', '')
|
|
if ([string]::IsNullOrWhiteSpace($sasUrl)) { Write-VerboseLog 'Policy Import cancelled (no Blob SAS URL).' -Level Warning; return }
|
|
}
|
|
|
|
$pimNameKw = ''; $pimType='(All types)'; $pimSub='(All sub-types)'; $pimPlat='(All platforms)'
|
|
try { $pimNameKw = [string]$script:Window.FindName('TxtPIMNameFilter').Text.Trim() } catch {}
|
|
try { $ti=$script:Window.FindName('CmbPIMType').SelectedItem; if ($ti) { $pimType=[string]$ti.Content } } catch {}
|
|
try { $si=$script:Window.FindName('CmbPIMSubType').SelectedItem; if ($si) { $pimSub=[string]$si.Content } } catch {}
|
|
try { $pli=$script:Window.FindName('CmbPIMPlatform').SelectedItem; if ($pli) { $pimPlat=[string]$pli.Content } } catch {}
|
|
$script:PIMParams = @{ Source=$Source; Selected=$Selected; Candidates=$candidates; SasUrl=$sasUrl; NameKeyword=$pimNameKw; TypeFilter=$pimType; SubTypeFilter=$pimSub; PlatFilter=$pimPlat }
|
|
$script:PIMPrepResult = $null
|
|
|
|
$script:Window.FindName('PnlPIMResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('TxtPIMNoResults').Visibility = 'Collapsed'
|
|
$dg = $script:Window.FindName('DgPIMResults'); if ($dg) { $dg.ItemsSource = $null }
|
|
$script:Window.FindName('PnlPIMProgress').Visibility = 'Visible'
|
|
$script:Window.FindName('TxtPIMProgressMsg').Text = 'Reading export files...'
|
|
$script:Window.FindName('TxtPIMProgressDetail').Text = ''
|
|
foreach ($b in @('BtnPIMSelLocal','BtnPIMAllLocal','BtnPIMSelBlob','BtnPIMAllBlob')) { $script:Window.FindName($b).IsEnabled = $false }
|
|
Write-VerboseLog ('--- Policy Import: reading files (Source=' + $Source + ') ---') -Level Action
|
|
|
|
# PHASE 1 - prepare (parse + build preview)
|
|
Invoke-OnBackground -BusyMessage '--- Policy Import: preparing ---' -Work {
|
|
|
|
$p = $script:PIMParams
|
|
$rows = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
|
|
$labelMap = @{
|
|
'deviceConfigurations'='Device Configuration'; 'configurationPolicies'='Configuration Policy'
|
|
'groupPolicyConfigurations'='Administrative Templates'; 'deviceManagementScripts'='Platform Script (Windows PowerShell)'
|
|
'deviceHealthScripts'='Detection/Remediation Script'; 'windowsAutopilotDeploymentProfiles'='Autopilot Profile'
|
|
'deviceCompliancePolicies'='Compliance Policy'
|
|
}
|
|
|
|
# Collect raw JSON strings from Local files or Blob container
|
|
$items = [System.Collections.Generic.List[hashtable]]::new()
|
|
if ($p['Source'] -eq 'Local') {
|
|
foreach ($c in $p['Candidates']) {
|
|
try { $items.Add(@{ Name=$c['Name']; Json=[System.IO.File]::ReadAllText($c['Path']) }) }
|
|
catch { Write-VerboseLog ('PIM: cannot read ' + $c['Name'] + ': ' + $_.Exception.Message) -Level Warning }
|
|
}
|
|
} else {
|
|
try {
|
|
$raw = ([string]$p['SasUrl']).Trim()
|
|
if ($raw -notmatch '^https?://') { throw 'Paste the FULL container SAS URL (e.g. https://<account>.blob.core.windows.net/<container>?sp=...&sig=...), not just the token.' }
|
|
$qi = $raw.IndexOf('?')
|
|
if ($qi -lt 0) { throw 'The SAS URL is missing its ?...sig=... query string.' }
|
|
$blobBase = $raw.Substring(0,$qi).TrimEnd('/')
|
|
$blobSas = $raw.Substring($qi+1)
|
|
if ($blobSas -notmatch 'sig=') { throw 'The SAS query string is missing the sig= signature. Use the full Blob SAS URL from Azure.' }
|
|
$bu = [System.Uri]$blobBase
|
|
Write-VerboseLog ('PIM: Blob container = ' + $bu.Host + $bu.AbsolutePath + ' (via SAS; token not logged)') -Level Info
|
|
$blobNames = [System.Collections.Generic.List[string]]::new()
|
|
$marker = ''
|
|
do {
|
|
$listUri = $blobBase + '?restype=container&comp=list&' + $blobSas
|
|
if ($marker) { $listUri = $listUri + '&marker=' + [System.Uri]::EscapeDataString($marker) }
|
|
$resp = Invoke-WebRequest -Method Get -Uri $listUri -UseBasicParsing -ErrorAction Stop
|
|
$listXml = [string]$resp.Content
|
|
$lt = $listXml.IndexOf('<'); if ($lt -gt 0) { $listXml = $listXml.Substring($lt) }
|
|
$xml = New-Object System.Xml.XmlDocument
|
|
$xml.LoadXml($listXml)
|
|
foreach ($bl in @($xml.EnumerationResults.Blobs.Blob)) {
|
|
$nm = [string]$bl.Name
|
|
if ($nm -match '\.json$' -and $nm -notlike '*_manifest_*') { $blobNames.Add($nm) }
|
|
}
|
|
$marker = [string]$xml.EnumerationResults.NextMarker
|
|
} while ($marker)
|
|
Write-VerboseLog ('PIM: ' + $blobNames.Count + ' JSON blob(s) found in container') -Level Info
|
|
foreach ($nm in $blobNames) {
|
|
try {
|
|
$dlUri = $blobBase + '/' + [System.Uri]::EscapeDataString($nm) + '?' + $blobSas
|
|
$r = Invoke-WebRequest -Method Get -Uri $dlUri -UseBasicParsing -ErrorAction Stop
|
|
$items.Add(@{ Name=$nm; Json=[string]$r.Content })
|
|
} catch { $dd=[string]$_.Exception.Message; if($dd.Length -gt 300){$dd=$dd.Substring(0,300)+' ...'}; Write-VerboseLog ('PIM: download failed for ' + $nm + ': ' + $dd) -Level Warning }
|
|
}
|
|
} catch {
|
|
$detail = [string]$_.Exception.Message
|
|
try { if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $detail = [string]$_.ErrorDetails.Message } } catch {}
|
|
if ($detail.Length -gt 400) { $detail = $detail.Substring(0,400) + ' ...(truncated)' }
|
|
Write-VerboseLog ('PIM: Blob container could not be read: ' + $detail) -Level Error
|
|
$script:PIMPrepResult = @{ Rows=$rows; FatalError=('Blob container could not be read via SAS: ' + $detail) }
|
|
return
|
|
}
|
|
}
|
|
|
|
foreach ($it in $items) {
|
|
$importable = $true
|
|
$warn = [System.Collections.Generic.List[string]]::new()
|
|
$disp = ''; $typeKey = ''; $label = ''; $planned = ''; $subType = ''; $platform = ''
|
|
$parsed = $null
|
|
try {
|
|
$parsed = $it['Json'] | ConvertFrom-Json -AsHashtable
|
|
if (-not $parsed.ContainsKey('mdmOdaExport') -or -not $parsed.ContainsKey('policy')) { throw 'Not a recognised MDM-ODA export file.' }
|
|
$meta = $parsed['mdmOdaExport']
|
|
$typeKey= [string]$meta['policyTypeKey']
|
|
$label = if ($labelMap.ContainsKey($typeKey)) { $labelMap[$typeKey] } else { $typeKey }
|
|
$disp = [string]$meta['displayName']
|
|
$subType = [string]$meta['subType']
|
|
$platform= [string]$meta['platform']
|
|
$planned= ($disp + ' (Imported)')
|
|
if ($meta['warnings']) { foreach ($w in @($meta['warnings'])) { if ($w) { $warn.Add([string]$w) } } }
|
|
|
|
# Not-importable cases
|
|
if ($typeKey -eq 'deviceHealthScripts') {
|
|
$pol = $parsed['policy']
|
|
if (([string]$pol['publisher'] -eq 'Microsoft') -or ($pol['isGlobalScript'] -eq $true)) {
|
|
$importable = $false; $warn.Add('Microsoft built-in remediation cannot be recreated')
|
|
}
|
|
}
|
|
} catch {
|
|
$importable = $false; $warn.Add($_.Exception.Message)
|
|
if (-not $disp) { $disp = $it['Name'] }
|
|
if (-not $label) { $label = '(unknown)' }
|
|
}
|
|
|
|
$rows.Add([PSCustomObject]@{
|
|
FileName = $it['Name']
|
|
DisplayName = $disp
|
|
PolicyType = $label
|
|
SubType = $subType
|
|
Platform = $platform
|
|
TypeKey = $typeKey
|
|
PlannedName = $planned
|
|
Importable = $importable
|
|
ImportableText = if ($importable) { 'Yes' } else { 'No' }
|
|
AlreadyPresent = $false
|
|
PresenceStatus = ''
|
|
Warnings = ($warn -join '; ')
|
|
Result = ''
|
|
Parsed = $parsed
|
|
})
|
|
}
|
|
|
|
# Apply optional filters (name keyword / type / sub-type / platform) - honoured by both Import Selected and Import All
|
|
$fNameKw = [string]$p['NameKeyword']; $fType = [string]$p['TypeFilter']; $fSub = [string]$p['SubTypeFilter']; $fPlat = [string]$p['PlatFilter']
|
|
$filtered = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
foreach ($rw in $rows) {
|
|
if ($fNameKw) {
|
|
$kws = @($fNameKw -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
|
if ($kws.Count -gt 0) {
|
|
$kwHit = $false
|
|
foreach ($kwt in $kws) { if ([string]$rw.DisplayName -like ('*' + $kwt + '*')) { $kwHit = $true; break } }
|
|
if (-not $kwHit) { continue }
|
|
}
|
|
}
|
|
if ($fType -and $fType -ne '(All types)' -and [string]$rw.PolicyType -ne $fType) { continue }
|
|
if ($fSub -and $fSub -ne '(All sub-types)' -and [string]$rw.SubType -ne $fSub) { continue }
|
|
if ($fPlat -and $fPlat -ne '(All platforms)' -and [string]$rw.Platform -ne $fPlat) { continue }
|
|
$filtered.Add($rw)
|
|
}
|
|
# ---- Content-based duplicate detection ------------------------------------------------------
|
|
# Flag a row "Already Present" only when a target-tenant policy has IDENTICAL content (settings),
|
|
# ignoring name, ids, timestamps and the fields the import itself rewrites (roleScopeTagIds, technologies).
|
|
# Candidates are narrowed by name (exact source name OR our '(Imported...)' naming) then CONFIRMED by a
|
|
# canonical content hash, so re-imports (which carry an '(Imported)' suffix) are correctly detected.
|
|
function PIM-Canon {
|
|
param($node)
|
|
$drop = @('id','name','displayname','createddatetime','lastmodifieddatetime','version','rolescopetagids','supportsscopetags','isassigned','settingcount','creationsource','prioritymetadata','technologies','templatedisplayname','templatedisplayversion','templatefamily','highestavailableversion','iscloneable','@odata.count')
|
|
if ($node -is [System.Collections.IDictionary]) {
|
|
$keys = @($node.Keys | Where-Object { $kl=([string]$_).ToLowerInvariant(); ($drop -notcontains $kl) -and ($kl -notlike '*@odata.context') } | Sort-Object)
|
|
$parts = foreach ($k in $keys) { ([string]$k).ToLowerInvariant() + '=' + (PIM-Canon $node[$k]) }
|
|
return '{' + ($parts -join ';') + '}'
|
|
} elseif ($node -is [System.Collections.IEnumerable] -and -not ($node -is [string])) {
|
|
return '[' + (@(foreach ($el in $node) { PIM-Canon $el }) -join ',') + ']'
|
|
} else { return [string]$node }
|
|
}
|
|
function PIM-FetchFull {
|
|
param([string]$Base,[string]$Key,[string]$Id)
|
|
try {
|
|
switch ($Key) {
|
|
'configurationPolicies' { return (Invoke-MgGraphRequest -Method GET -Uri ($Base + '/configurationPolicies/' + $Id + '?' + '$expand=settings')) }
|
|
'deviceCompliancePolicies' { return (Invoke-MgGraphRequest -Method GET -Uri ($Base + '/deviceCompliancePolicies/' + $Id + '?' + '$expand=scheduledActionsForRule($expand=scheduledActionConfigurations)')) }
|
|
'groupPolicyConfigurations' {
|
|
$c = Invoke-MgGraphRequest -Method GET -Uri ($Base + '/groupPolicyConfigurations/' + $Id)
|
|
$dl = [System.Collections.Generic.List[object]]::new()
|
|
try {
|
|
$du = ($Base + '/groupPolicyConfigurations/' + $Id + '/definitionValues?' + '$expand=definition')
|
|
while ($du) {
|
|
$dr = Invoke-MgGraphRequest -Method GET -Uri $du
|
|
foreach ($dv in @($dr['value'])) {
|
|
if ($dv -is [System.Collections.IDictionary]) {
|
|
$dvid=[string]$dv['id']; $pvv=@()
|
|
if ($dvid) { try { $pvr = Invoke-MgGraphRequest -Method GET -Uri ($Base + '/groupPolicyConfigurations/' + $Id + '/definitionValues/' + $dvid + '/presentationValues?' + '$expand=presentation'); $pvv=@($pvr['value']) } catch {} }
|
|
$dv['presentationValues']=$pvv; $dl.Add($dv)
|
|
}
|
|
}
|
|
$du = $dr['@odata.nextLink']
|
|
}
|
|
} catch {}
|
|
return @{ policy=$c; defs=@($dl) }
|
|
}
|
|
default { return (Invoke-MgGraphRequest -Method GET -Uri ($Base + '/' + $Key + '/' + $Id)) }
|
|
}
|
|
} catch { return $null }
|
|
}
|
|
function PIM-ImportContent {
|
|
param($Parsed,[string]$Key)
|
|
if ($Key -eq 'groupPolicyConfigurations') {
|
|
$d = if ($Parsed.ContainsKey('adminTemplateDefinitionValues')) { @($Parsed['adminTemplateDefinitionValues']) } else { @() }
|
|
return @{ policy=$Parsed['policy']; defs=$d }
|
|
}
|
|
return $Parsed['policy']
|
|
}
|
|
Write-VerboseLog 'PIM: checking target tenant for duplicate content...' -Level Info
|
|
$presBase = 'https://graph.microsoft.com/beta/deviceManagement'
|
|
$tgtIndex = @{}
|
|
$tgtCanon = @{}
|
|
foreach ($rw in $filtered) {
|
|
if (-not $rw.Importable) { $rw.PresenceStatus = ''; continue }
|
|
$pk = [string]$rw.TypeKey
|
|
$pnf = if ($pk -eq 'configurationPolicies') { 'name' } else { 'displayName' }
|
|
if (-not $tgtIndex.ContainsKey($pk)) {
|
|
$lst = [System.Collections.Generic.List[object]]::new()
|
|
try {
|
|
$pu = ($presBase + '/' + $pk + '?' + '$select=id,' + $pnf)
|
|
while ($pu) {
|
|
$pr = Invoke-MgGraphRequest -Method GET -Uri $pu
|
|
foreach ($pv in @($pr['value'])) { $lst.Add(@{ id=[string]$pv['id']; name=([string]$pv[$pnf]) }) }
|
|
$pu = $pr['@odata.nextLink']
|
|
}
|
|
} catch { Write-VerboseLog ('PIM: dup-check could not list ' + $pk + ': ' + $_.Exception.Message) -Level Warning }
|
|
$tgtIndex[$pk] = $lst
|
|
}
|
|
$srcName = ([string]$rw.DisplayName).Trim()
|
|
$cands = @($tgtIndex[$pk] | Where-Object { $n=([string]$_.name).Trim(); ($n -eq $srcName) -or ($n.StartsWith(($srcName + ' (Imported'), [System.StringComparison]::OrdinalIgnoreCase)) })
|
|
if ($cands.Count -eq 0) { $rw.AlreadyPresent=$false; $rw.PresenceStatus='Missing'; continue }
|
|
$impCanon = ''
|
|
try { $impCanon = PIM-Canon (PIM-ImportContent -Parsed $rw.Parsed -Key $pk) } catch {}
|
|
$match = $false
|
|
foreach ($cd in $cands) {
|
|
$ckey = $pk + '|' + [string]$cd.id
|
|
if (-not $tgtCanon.ContainsKey($ckey)) {
|
|
$full = PIM-FetchFull -Base $presBase -Key $pk -Id ([string]$cd.id)
|
|
$tgtCanon[$ckey] = if ($null -ne $full) { PIM-Canon $full } else { '' }
|
|
}
|
|
if ($impCanon -and $tgtCanon[$ckey] -and ($tgtCanon[$ckey] -eq $impCanon)) { $match = $true; break }
|
|
}
|
|
if (-not $match) {
|
|
# Policies with write-only secret/encrypted values can never content-match (the secret is not in the
|
|
# export and the target mints fresh references on import). With a same-name candidate present, treat as present.
|
|
$secScan = ''
|
|
try { $secScan = ($rw.Parsed['policy'] | ConvertTo-Json -Depth 30 -Compress) } catch {}
|
|
if ($secScan -match 'secretReferenceValueId|isEncrypted|SecretSettingValue|encryptedValue') {
|
|
$match = $true
|
|
$secNote = 'Matched by name (contains write-only secrets that cannot be content-compared)'
|
|
$rw.Warnings = if ([string]::IsNullOrEmpty([string]$rw.Warnings)) { $secNote } else { ([string]$rw.Warnings) + '; ' + $secNote }
|
|
}
|
|
}
|
|
$rw.AlreadyPresent = $match
|
|
$rw.PresenceStatus = if ($match) { 'Already Present' } else { 'Missing' }
|
|
}
|
|
$script:PIMPrepResult = @{ Rows=$filtered; FatalError='' }
|
|
Write-VerboseLog ('PIM: prepared ' + $rows.Count + ' file(s); ' + $filtered.Count + ' match the filters') -Level Info
|
|
|
|
} -Done {
|
|
|
|
foreach ($b in @('BtnPIMSelLocal','BtnPIMAllLocal','BtnPIMSelBlob','BtnPIMAllBlob')) { $script:Window.FindName($b).IsEnabled = $true }
|
|
$script:Window.FindName('PnlPIMProgress').Visibility = 'Collapsed'
|
|
|
|
$prep = $script:PIMPrepResult
|
|
if ($prep -and $prep['FatalError']) { Show-Notification -Message $prep['FatalError'] -BgColor '#F8D7DA' -FgColor '#721C24'; return }
|
|
$rows = if ($prep) { $prep['Rows'] } else { $null }
|
|
if (-not $rows -or $rows.Count -eq 0) { $script:Window.FindName('TxtPIMNoResults').Visibility='Visible'; return }
|
|
|
|
$confirmed = Show-ImportPreview -Rows $rows
|
|
if (-not $confirmed -or $confirmed.Count -eq 0) { Write-VerboseLog 'PIM: import cancelled / nothing selected.' -Level Warning; return }
|
|
|
|
$script:PIMConfirmed = $confirmed
|
|
Invoke-PolicyImportExecute
|
|
}
|
|
}
|
|
|
|
function Invoke-PolicyImportExecute {
|
|
$script:PIMExecResult = $null
|
|
$script:Window.FindName('PnlPIMProgress').Visibility = 'Visible'
|
|
$script:Window.FindName('TxtPIMProgressMsg').Text = 'Creating policies...'
|
|
foreach ($b in @('BtnPIMSelLocal','BtnPIMAllLocal','BtnPIMSelBlob','BtnPIMAllBlob')) { $script:Window.FindName($b).IsEnabled = $false }
|
|
Write-VerboseLog '--- Policy Import: creating policies ---' -Level Action
|
|
|
|
Invoke-OnBackground -BusyMessage '--- Policy Import: executing ---' -Work {
|
|
|
|
$confirmed = $script:PIMConfirmed
|
|
$base = 'https://graph.microsoft.com/beta/deviceManagement'
|
|
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
|
|
# Log the token's delegated scopes once (read-only) - Autopilot create needs DeviceManagementServiceConfig.ReadWrite.All
|
|
try {
|
|
$sc = ((Get-MgContext).Scopes -join ', ')
|
|
Write-VerboseLog ('PIM: token scopes = ' + $sc) -Level Info
|
|
if ($sc -notmatch 'DeviceManagementServiceConfig\.ReadWrite\.All') {
|
|
Write-VerboseLog 'PIM: NOTE - token is MISSING DeviceManagementServiceConfig.ReadWrite.All. Autopilot profile CREATE requires it (a different scope than config policies). Add it to the App Registration and admin-consent, then reconnect.' -Level Warning
|
|
}
|
|
} catch {}
|
|
|
|
# Recursive strip of read-only / server / secret / assignment keys
|
|
function PIM-Strip { param($Node)
|
|
if ($Node -is [System.Collections.IDictionary]) {
|
|
$drop = @('id','createdDateTime','lastModifiedDateTime','version','highestAvailableVersion','isAssigned','isGlobalScript','supportsScopeTags','settingCount','priorityMetaData','creationSource','secretReferenceValueId','isEncrypted','assignments','groupAssignments','deviceStatuses','userStatuses','assignedDevices','deviceStatusOverview','userStatusOverview','runSummary','deviceRunStates','managementServiceAppId','enableWhiteGlove','extractHardwareHash')
|
|
foreach ($k in @($Node.Keys)) {
|
|
$kl = [string]$k
|
|
if ($drop -contains $kl) { $Node.Remove($k); continue }
|
|
if ($kl -like '*@odata.context' -or $kl -like '*@OData.Community*' -or $kl -like '*Summary') { $Node.Remove($k); continue }
|
|
if ($kl -eq 'roleScopeTagIds') { $Node[$k] = @('0'); continue }
|
|
if ($kl -eq 'notificationTemplateId') { $Node[$k] = ''; continue }
|
|
if ($kl -eq 'notificationMessageCCList') { $Node[$k] = @(); continue }
|
|
PIM-Strip -Node $Node[$k]
|
|
}
|
|
} elseif ($Node -is [System.Collections.IEnumerable] -and -not ($Node -is [string])) {
|
|
foreach ($el in $Node) { PIM-Strip -Node $el }
|
|
}
|
|
}
|
|
|
|
# Existing names for collision checks (per collection, lazy)
|
|
$existingCache = @{}
|
|
function PIM-ExistingNames { param([string]$Key,[string]$NameField)
|
|
if ($existingCache.ContainsKey($Key)) { return ,$existingCache[$Key] }
|
|
$set = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
|
|
try {
|
|
$u = ($base + '/' + $Key + '?' + '$select=id,' + $NameField)
|
|
while ($u) {
|
|
$r = Invoke-MgGraphRequest -Method GET -Uri $u
|
|
foreach ($v in @($r['value'])) { $nm=[string]$v[$NameField]; if ($nm) { [void]$set.Add($nm) } }
|
|
$u = $r['@odata.nextLink']
|
|
}
|
|
} catch {}
|
|
$existingCache[$Key] = $set
|
|
return ,$set
|
|
}
|
|
|
|
# POST helper that surfaces the real Graph error body via -SkipHttpErrorCheck (falls back on older SDKs)
|
|
function PIM-Post { param([string]$Uri,[string]$BodyJson)
|
|
$sc = 0
|
|
try {
|
|
$resp = Invoke-MgGraphRequest -Method POST -Uri $Uri -Body $BodyJson -ContentType 'application/json' -SkipHttpErrorCheck -StatusCodeVariable sc
|
|
} catch [System.Management.Automation.ParameterBindingException] {
|
|
return (Invoke-MgGraphRequest -Method POST -Uri $Uri -Body $BodyJson -ContentType 'application/json')
|
|
}
|
|
if ($sc -ge 200 -and $sc -lt 300) { return $resp }
|
|
$eb = ''
|
|
try { if ($resp -is [System.Collections.IDictionary]) { $eb = ($resp | ConvertTo-Json -Depth 8 -Compress) } else { $eb = [string]$resp } } catch { $eb = [string]$resp }
|
|
throw ('HTTP ' + $sc + ': ' + $eb)
|
|
}
|
|
|
|
# Non-throwing POST used by the Autopilot create ladder: returns @{ ok; sc; err } so we can try body variants.
|
|
function PIM-TryPost { param([string]$Uri,[string]$BodyJson,[hashtable]$ExtraHeaders)
|
|
$sc = 0
|
|
try {
|
|
if ($ExtraHeaders -and $ExtraHeaders.Count) {
|
|
$resp = Invoke-MgGraphRequest -Method POST -Uri $Uri -Body $BodyJson -ContentType 'application/json' -Headers $ExtraHeaders -SkipHttpErrorCheck -StatusCodeVariable sc
|
|
} else {
|
|
$resp = Invoke-MgGraphRequest -Method POST -Uri $Uri -Body $BodyJson -ContentType 'application/json' -SkipHttpErrorCheck -StatusCodeVariable sc
|
|
}
|
|
} catch [System.Management.Automation.ParameterBindingException] {
|
|
try { $null = Invoke-MgGraphRequest -Method POST -Uri $Uri -Body $BodyJson -ContentType 'application/json'; return @{ ok = $true; sc = 201; err = '' } }
|
|
catch { return @{ ok = $false; sc = -1; err = [string]$_.Exception.Message } }
|
|
} catch {
|
|
return @{ ok = $false; sc = -1; err = [string]$_.Exception.Message }
|
|
}
|
|
if ($sc -ge 200 -and $sc -lt 300) { return @{ ok = $true; sc = $sc; err = '' } }
|
|
$eb = ''
|
|
try { if ($resp -is [System.Collections.IDictionary]) { $eb = ($resp | ConvertTo-Json -Depth 8 -Compress) } else { $eb = [string]$resp } } catch { $eb = [string]$resp }
|
|
if ($eb.Length -gt 600) { $eb = $eb.Substring(0,600) + ' ...' }
|
|
return @{ ok = $false; sc = $sc; err = ('HTTP ' + $sc + ': ' + $eb) }
|
|
}
|
|
|
|
foreach ($row in $confirmed) {
|
|
if ($null -ne $Shared -and $Shared['StopRequested']) { Write-VerboseLog 'PIM: Stop requested.' -Level Warning; break }
|
|
$disp = [string]$row.DisplayName
|
|
$key = [string]$row.TypeKey
|
|
$label= [string]$row.PolicyType
|
|
$warn = [System.Collections.Generic.List[string]]::new()
|
|
if ($row.Warnings) { $warn.Add([string]$row.Warnings) }
|
|
$result = 'Failed'
|
|
$apHandled = $false
|
|
$planned = [string]$row.PlannedName
|
|
try {
|
|
$parsed = $row.Parsed
|
|
$pol = $parsed['policy']
|
|
$nameField = if ($key -eq 'configurationPolicies') { 'name' } else { 'displayName' }
|
|
|
|
# Unique (Imported) name
|
|
$names = PIM-ExistingNames -Key $key -NameField $nameField
|
|
$cand = $planned; $n = 2
|
|
while ($names.Contains($cand)) { $cand = ($disp + ' (Imported ' + $n + ')'); $n++ }
|
|
$planned = $cand
|
|
|
|
if ($key -eq 'groupPolicyConfigurations') {
|
|
$defs = if ($parsed.ContainsKey('adminTemplateDefinitionValues')) { @($parsed['adminTemplateDefinitionValues']) } else { @() }
|
|
# Detect custom/ingested definitions (need ADMX ingestion + per-tenant GUID remap in the target).
|
|
$hasCustom = $false
|
|
foreach ($d in $defs) { $dd0=$d['definition']; if (($dd0 -is [System.Collections.IDictionary]) -and ([string]$dd0['policyType'] -eq 'admxIngested')) { $hasCustom=$true; break } }
|
|
|
|
$defKeyMap = @{}
|
|
$presKeyMap = @{}
|
|
if ($hasCustom) {
|
|
$admxFiles = if ($parsed.ContainsKey('customAdmxFiles')) { @($parsed['customAdmxFiles']) } else { @() }
|
|
if ($admxFiles.Count -eq 0) { throw 'This custom Administrative Template was exported without its ADMX files. Re-export it with the current MDM-ODA version, then import.' }
|
|
Write-VerboseLog ('PIM: ' + $disp + ' uses custom ADMX - ingesting ' + $admxFiles.Count + ' file(s) in target...') -Level Info
|
|
$tgtFiles = @()
|
|
try { $tf0 = Invoke-MgGraphRequest -Method GET -Uri ($base + '/groupPolicyUploadedDefinitionFiles?' + '$select=id,displayName,fileName,targetNamespace,status'); $tgtFiles = @($tf0['value']) } catch {}
|
|
$ingestedFileIds = [System.Collections.Generic.List[string]]::new()
|
|
foreach ($af in $admxFiles) {
|
|
if (-not ($af -is [System.Collections.IDictionary])) { continue }
|
|
$ns = [string]$af['targetNamespace']
|
|
$existing = $null
|
|
foreach ($ef in $tgtFiles) { if (([string]$ef['targetNamespace']) -ieq $ns) { $existing=$ef; break } }
|
|
if ($existing) { $ingestedFileIds.Add([string]$existing['id']); Write-VerboseLog ('PIM: ADMX already present in target (' + $ns + ')') -Level Info; continue }
|
|
$langs = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($lf in @($af['groupPolicyUploadedLanguageFiles'])) {
|
|
if ($lf -is [System.Collections.IDictionary]) { $langs.Add(@{ '@odata.type'='microsoft.graph.groupPolicyUploadedLanguageFile'; fileName=[string]$lf['fileName']; languageCode=[string]$lf['languageCode']; content=$lf['content'] }) }
|
|
}
|
|
$ub = [ordered]@{
|
|
'@odata.type'='#microsoft.graph.groupPolicyUploadedDefinitionFile'
|
|
displayName=[string]$af['displayName']; description=[string]$af['description']; fileName=[string]$af['fileName']
|
|
policyType='admxIngested'; defaultLanguageCode=[string]$af['defaultLanguageCode']; languageCodes=@($af['languageCodes'])
|
|
targetPrefix=[string]$af['targetPrefix']; targetNamespace=$ns; content=$af['content']; groupPolicyUploadedLanguageFiles=@($langs)
|
|
}
|
|
if ($af.ContainsKey('revision') -and $af['revision']) { $ub['revision']=[string]$af['revision'] }
|
|
$cf = PIM-Post -Uri ($base + '/groupPolicyUploadedDefinitionFiles') -BodyJson ($ub | ConvertTo-Json -Depth 8)
|
|
$newFid = [string]$cf['id']
|
|
$ready=$false
|
|
for ($i=0; $i -lt 40; $i++) {
|
|
Start-Sleep -Seconds 3
|
|
$sts=''
|
|
try { $st = Invoke-MgGraphRequest -Method GET -Uri ($base + '/groupPolicyUploadedDefinitionFiles/' + $newFid + '?' + '$select=id,status'); $sts=[string]$st['status'] } catch {}
|
|
if ($sts -eq 'available') { $ready=$true; break }
|
|
if ($sts -eq 'uploadFailed') { throw ('ADMX ingestion failed in target for namespace ' + $ns) }
|
|
}
|
|
if (-not $ready) { throw ('ADMX ingestion did not become available in time for namespace ' + $ns) }
|
|
$ingestedFileIds.Add($newFid)
|
|
Write-VerboseLog ('PIM: ingested ADMX in target (' + $ns + ')') -Level Success
|
|
}
|
|
foreach ($fid in ($ingestedFileIds | Select-Object -Unique)) {
|
|
try {
|
|
$tfd = Invoke-MgGraphRequest -Method GET -Uri ($base + '/groupPolicyUploadedDefinitionFiles/' + $fid + '?' + '$expand=definitions')
|
|
foreach ($td in @($tfd['definitions'])) {
|
|
if (-not ($td -is [System.Collections.IDictionary])) { continue }
|
|
$tdid = [string]$td['id']
|
|
$tk = (([string]$td['displayName']) + ([string]$td['categoryPath']) + ([string]$td['classType'])).ToLowerInvariant()
|
|
$defKeyMap[$tk] = $tdid
|
|
try {
|
|
$tdp = Invoke-MgGraphRequest -Method GET -Uri ($base + '/groupPolicyDefinitions/' + $tdid + '?' + '$expand=presentations')
|
|
$po=0
|
|
foreach ($tp in @($tdp['presentations'])) {
|
|
if ($tp -is [System.Collections.IDictionary]) {
|
|
$presKeyMap[$tdid + '|lbl|' + ([string]$tp['label']).ToLowerInvariant()] = [string]$tp['id']
|
|
$presKeyMap[$tdid + '|ord|' + $po] = [string]$tp['id']
|
|
$po++
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
$body = @{ '@odata.type'='#microsoft.graph.groupPolicyConfiguration'; displayName=$planned; roleScopeTagIds=@('0') }
|
|
if ($hasCustom) { $body['policyConfigurationIngestionType'] = 'custom' }
|
|
if ($pol['description']) { $body['description'] = [string]$pol['description'] }
|
|
$created = PIM-Post -Uri ($base + '/groupPolicyConfigurations') -BodyJson ($body | ConvertTo-Json -Depth 5)
|
|
$newId = [string]$created['id']
|
|
|
|
$added = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($d in $defs) {
|
|
$srcDef = $d['definition']
|
|
if (-not ($srcDef -is [System.Collections.IDictionary])) { continue }
|
|
$srcDefId = [string]$srcDef['id']
|
|
$isIng = ([string]$srcDef['policyType'] -eq 'admxIngested')
|
|
if ($isIng) {
|
|
$sk = (([string]$srcDef['displayName']) + ([string]$srcDef['categoryPath']) + ([string]$srcDef['classType'])).ToLowerInvariant()
|
|
$tgtDefId = [string]$defKeyMap[$sk]
|
|
if (-not $tgtDefId) { throw ('Could not match custom definition "' + [string]$srcDef['displayName'] + '" to a target definition after ADMX ingestion.') }
|
|
} else { $tgtDefId = $srcDefId }
|
|
if (-not $tgtDefId) { continue }
|
|
$pvals = [System.Collections.Generic.List[object]]::new()
|
|
$po=0
|
|
foreach ($pv in @($d['presentationValues'])) {
|
|
$srcPres = $pv['presentation']
|
|
$srcPresId = if ($srcPres -is [System.Collections.IDictionary]) { [string]$srcPres['id'] } else { '' }
|
|
if ($isIng) {
|
|
$lbl = if ($srcPres -is [System.Collections.IDictionary]) { ([string]$srcPres['label']).ToLowerInvariant() } else { '' }
|
|
$tgtPresId = [string]$presKeyMap[$tgtDefId + '|lbl|' + $lbl]
|
|
if (-not $tgtPresId) { $tgtPresId = [string]$presKeyMap[$tgtDefId + '|ord|' + $po] }
|
|
if (-not $tgtPresId) { $tgtPresId = $srcPresId }
|
|
} else { $tgtPresId = $srcPresId }
|
|
$pvObj = @{ '@odata.type'=[string]$pv['@odata.type']; 'presentation@odata.bind'=($base + "/groupPolicyDefinitions('" + $tgtDefId + "')/presentations('" + $tgtPresId + "')") }
|
|
if ($pv.ContainsKey('values')) { $pvObj['values'] = $pv['values'] }
|
|
elseif ($pv.ContainsKey('value')) { $pvObj['value'] = $pv['value'] }
|
|
$pvals.Add($pvObj)
|
|
$po++
|
|
}
|
|
$added.Add(@{ enabled=[bool]$d['enabled']; 'definition@odata.bind'=($base + "/groupPolicyDefinitions('" + $tgtDefId + "')"); presentationValues=@($pvals) })
|
|
}
|
|
$upBody = @{ added=@($added); updated=@(); deletedIds=@() }
|
|
try {
|
|
PIM-Post -Uri ($base + '/groupPolicyConfigurations/' + $newId + '/updateDefinitionValues') -BodyJson ($upBody | ConvertTo-Json -Depth 30) | Out-Null
|
|
} catch {
|
|
try { Invoke-MgGraphRequest -Method DELETE -Uri ($base + '/groupPolicyConfigurations/' + $newId) | Out-Null } catch {}
|
|
throw
|
|
}
|
|
$result = 'Created'
|
|
} else {
|
|
PIM-Strip -Node $pol
|
|
$pol[$nameField] = $planned
|
|
if (-not $pol.ContainsKey('roleScopeTagIds')) { $pol['roleScopeTagIds'] = @('0') }
|
|
|
|
# Autopilot CLASSIC deployment profile create — matches the CIPP-proven fix (CIPP #5554 / CIPP-API
|
|
# PR #1923, Mar 2026) for the opaque empty-phrase 400 from the DeviceEnrollmentFE
|
|
# StatelessDeviceEnrollmentFEService backend. Ground rules that avoid the pre-validation reject:
|
|
# - NEW-schema field names (locale / preprovisioningAllowed / hardwareHashExtractionEnabled), NOT legacy.
|
|
# - locale must NEVER be 'os-default' or 'user-select' (invalid BCP-47 -> 400); map those to ''.
|
|
# - send ONLY the new-schema SINGULAR outOfBoxExperienceSetting (never the deprecated plural, never both).
|
|
# - OMIT userType from OOBE when deviceUsageType='shared' (self-deploying has no user context -> 400).
|
|
# - OMIT enrollmentStatusScreenSettings and managementServiceAppId from the create body.
|
|
# - roleScopeTagIds = @(); Hybrid (activeDirectory) sets hybridAzureADJoinSkipConnectivityCheck, no name template.
|
|
if ($key -eq 'windowsAutopilotDeploymentProfiles') {
|
|
$oobeS = if ($pol.ContainsKey('outOfBoxExperienceSetting') -and ($pol['outOfBoxExperienceSetting'] -is [System.Collections.IDictionary])) { $pol['outOfBoxExperienceSetting'] }
|
|
elseif ($pol.ContainsKey('outOfBoxExperienceSettings') -and ($pol['outOfBoxExperienceSettings'] -is [System.Collections.IDictionary])) { $pol['outOfBoxExperienceSettings'] } else { $null }
|
|
$getO = {
|
|
param($aName,$bName)
|
|
if ($oobeS -and $oobeS.ContainsKey($aName)) { return $oobeS[$aName] }
|
|
elseif ($oobeS -and $oobeS.ContainsKey($bName)) { return $oobeS[$bName] }
|
|
else { return $null }
|
|
}
|
|
# OOBE scalars in NEW-schema (singular) names; source may hold singular or deprecated-plural names.
|
|
$oPriv = [bool](& $getO 'privacySettingsHidden' 'hidePrivacySettings')
|
|
$oEula = [bool](& $getO 'eulaHidden' 'hideEULA')
|
|
$oSkip = [bool](& $getO 'keyboardSelectionPageSkipped' 'skipKeyboardSelectionPage')
|
|
$oEsc = [bool](& $getO 'escapeLinkHidden' 'hideEscapeLink')
|
|
$oUsage = [string](& $getO 'deviceUsageType' 'deviceUsageType'); if (-not $oUsage) { $oUsage = 'singleUser' }
|
|
$oUser = [string](& $getO 'userType' 'userType'); if (-not $oUser) { $oUser = 'standard' }
|
|
$apOdata = if ($pol.ContainsKey('@odata.type') -and $pol['@odata.type']) { [string]$pol['@odata.type'] } else { '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile' }
|
|
$apDesc = if ($pol.ContainsKey('description') -and $null -ne $pol['description']) { [string]$pol['description'] } else { '' }
|
|
$apLocale = if ($pol.ContainsKey('locale') -and $pol['locale']) { [string]$pol['locale'] } elseif ($pol.ContainsKey('language') -and $pol['language']) { [string]$pol['language'] } else { 'os-default' }
|
|
$apHHE = if ($pol.ContainsKey('hardwareHashExtractionEnabled')) { [bool]$pol['hardwareHashExtractionEnabled'] } elseif ($pol.ContainsKey('extractHardwareHash')) { [bool]$pol['extractHardwareHash'] } else { $false }
|
|
$apPreprov = if ($pol.ContainsKey('preprovisioningAllowed')) { [bool]$pol['preprovisioningAllowed'] } elseif ($pol.ContainsKey('enableWhiteGlove')) { [bool]$pol['enableWhiteGlove'] } else { $false }
|
|
$apDType = if ($pol.ContainsKey('deviceType') -and $pol['deviceType']) { [string]$pol['deviceType'] } else { 'windowsPc' }
|
|
$apDNT = if ($pol.ContainsKey('deviceNameTemplate') -and $pol['deviceNameTemplate']) { [string]$pol['deviceNameTemplate'] } else { '' }
|
|
$apHybridSkip = if ($pol.ContainsKey('hybridAzureADJoinSkipConnectivityCheck')) { [bool]$pol['hybridAzureADJoinSkipConnectivityCheck'] } else { $false }
|
|
# Body builder (CIPP-correct): new-schema singular OOBE; IncludeTemplate toggles the device name template.
|
|
$mkAp = {
|
|
param([bool]$IncludeTemplate)
|
|
$isHybrid = ($apOdata -match 'activeDirectory')
|
|
$isShared = ($oUsage -eq 'shared')
|
|
# Field order mirrors the tenant's own working Intune-portal POST body captured 2026-07-05.
|
|
$b = [ordered]@{}
|
|
$b['@odata.type'] = $apOdata
|
|
$b['displayName'] = $planned
|
|
$b['description'] = $apDesc
|
|
if (-not $isHybrid) { $b['deviceNameTemplate'] = $(if ($IncludeTemplate) { $apDNT } else { '' }) }
|
|
$b['locale'] = $apLocale
|
|
$b['preprovisioningAllowed'] = $apPreprov
|
|
$b['deviceType'] = $apDType
|
|
$b['hardwareHashExtractionEnabled'] = $apHHE
|
|
$b['roleScopeTagIds'] = @()
|
|
$b['hybridAzureADJoinSkipConnectivityCheck'] = $apHybridSkip
|
|
$oobe = [ordered]@{}
|
|
$oobe['deviceUsageType'] = $oUsage
|
|
$oobe['escapeLinkHidden'] = $oEsc
|
|
$oobe['privacySettingsHidden'] = $oPriv
|
|
$oobe['eulaHidden'] = $oEula
|
|
if (-not $isShared) { $oobe['userType'] = $oUser }
|
|
$oobe['keyboardSelectionPageSkipped'] = $oSkip
|
|
$b['outOfBoxExperienceSetting'] = $oobe
|
|
return $b
|
|
}
|
|
# Candidate ladder (first HTTP 2xx wins): full body, then without the device name template (>15-char guard).
|
|
$apCands = [System.Collections.Generic.List[object]]::new()
|
|
$apCands.Add(@{ label = ('CIPP-schema; usage=' + $oUsage + '; nameTemplate'); body = (& $mkAp $true) })
|
|
if ($apDNT) { $apCands.Add(@{ label = ('CIPP-schema; usage=' + $oUsage + '; no-template'); body = (& $mkAp $false) }) }
|
|
|
|
$apUri = ($base + '/windowsAutopilotDeploymentProfiles')
|
|
# The Intune portal's own successful create sends this header; some DeviceEnrollmentFE paths gate on it.
|
|
$apHdr = @{ 'x-ms-command-name' = 'WindowsEnrollment_AutopilotProfiles_createAutopilotProfile' }
|
|
$apIdx = -1; $apLastErr = ''
|
|
foreach ($cand in $apCands) {
|
|
$apIdx++
|
|
$cJson = $cand.body | ConvertTo-Json -Depth 10
|
|
try { [System.IO.File]::WriteAllText('C:\Logs\MDM-ODA\Logs\PIM_last_autopilot_body.json', $cJson) } catch {}
|
|
$r = PIM-TryPost -Uri $apUri -BodyJson $cJson -ExtraHeaders $apHdr
|
|
if ($r['ok']) {
|
|
$result = 'Created'
|
|
if ($apIdx -gt 0) { $warn.Add('Autopilot created via fallback body [' + $cand.label + '] after the faithful body was rejected. Verify userType and device name template in the target tenant.') }
|
|
Write-VerboseLog ("PIM: Autopilot created via [" + $cand.label + "]") -Level Success
|
|
break
|
|
} else {
|
|
$apLastErr = [string]$r['err']
|
|
Write-VerboseLog ("PIM: Autopilot attempt [" + $cand.label + "] rejected: " + $apLastErr) -Level Warning
|
|
}
|
|
}
|
|
if ($result -ne 'Created') { throw ('All Autopilot body variants rejected. Last error: ' + $apLastErr) }
|
|
$apHandled = $true
|
|
}
|
|
|
|
if ($key -eq 'configurationPolicies') {
|
|
# Plain Settings Catalog policies must not carry an empty/blank templateReference (stale/empty -> 400)
|
|
if ($pol.ContainsKey('templateReference')) {
|
|
$tr = $pol['templateReference']
|
|
$tid = if ($tr -is [System.Collections.IDictionary]) { [string]$tr['templateId'] } else { '' }
|
|
if ([string]::IsNullOrWhiteSpace($tid)) { $pol.Remove('templateReference') }
|
|
}
|
|
# Endpoint Security Firewall Rules: the DCV2 backend only binds the endpointSecurityFirewall
|
|
# template when 'technologies' includes microsoftSense; a bare 'mdm' -> ResourceNotFound on create.
|
|
$tfam = if ($pol.ContainsKey('templateReference') -and ($pol['templateReference'] -is [System.Collections.IDictionary])) { [string]$pol['templateReference']['templateFamily'] } else { '' }
|
|
if ($tfam -eq 'endpointSecurityFirewall') {
|
|
$tech = if ($pol.ContainsKey('technologies')) { [string]$pol['technologies'] } else { 'mdm' }
|
|
if ($tech -notmatch 'microsoftSense') { $pol['technologies'] = (($tech.Trim().TrimEnd(',')) + ',microsoftSense'); $warn.Add('Firewall Rules: added microsoftSense to technologies for template binding.') }
|
|
}
|
|
# Reusable settings references can't be recreated cross-tenant without remapping -> skip with a clear reason
|
|
$polScan = ($pol | ConvertTo-Json -Depth 30 -Compress)
|
|
if ($polScan -match 'settingInstanceReference|reusablePolicySettings') {
|
|
$result = 'Skipped'
|
|
$warn.Add('References a reusable settings group not present in the target tenant. Create the reusable settings there first, then re-import.')
|
|
Write-VerboseLog ("PIM: SKIPPED '" + $disp + "' [configurationPolicies]: reusable settings dependency") -Level Warning
|
|
}
|
|
}
|
|
|
|
if ($key -eq 'deviceCompliancePolicies') {
|
|
if (-not $pol.ContainsKey('scheduledActionsForRule') -or -not @($pol['scheduledActionsForRule']).Count) {
|
|
$pol['scheduledActionsForRule'] = @(@{ ruleName='PasswordRequired'; scheduledActionConfigurations=@(@{ actionType='block'; gracePeriodHours=0; notificationTemplateId=''; notificationMessageCCList=@() }) })
|
|
}
|
|
}
|
|
|
|
if ($result -ne 'Skipped' -and -not $apHandled) {
|
|
$bodyJson = $pol | ConvertTo-Json -Depth 30
|
|
PIM-Post -Uri ($base + '/' + $key) -BodyJson $bodyJson | Out-Null
|
|
$result = 'Created'
|
|
}
|
|
}
|
|
if ($result -eq 'Created') {
|
|
if ($names) { [void]$names.Add($planned) }
|
|
Write-VerboseLog ("PIM: Created '" + $planned + "' (" + $label + ")") -Level Success
|
|
}
|
|
} catch {
|
|
$result = 'Failed'
|
|
# Prefer the real Graph error body (ErrorDetails / Response content) over the generic HttpClient message
|
|
$emsg = [string]$_.Exception.Message
|
|
try { if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $emsg = [string]$_.ErrorDetails.Message } } catch {}
|
|
try {
|
|
if (($emsg -notmatch '"message"') -and $_.Exception -and $_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) {
|
|
$rc = $_.Exception.Response.Content
|
|
if ($rc) { $b = $rc.ReadAsStringAsync().Result; if ($b) { $emsg = [string]$b } }
|
|
}
|
|
} catch {}
|
|
$emsg = ($emsg -replace '\s+',' ').Trim()
|
|
# Reclassify cross-tenant DEPENDENCY failures as Skipped (the referenced object is absent in the target).
|
|
if ($emsg -match 'Reusable Settings are not found') {
|
|
$result = 'Skipped'
|
|
$emsg = 'Skipped: references a reusable settings group not present in the target tenant. Create the reusable settings group there first, then re-import.'
|
|
} elseif ($key -eq 'groupPolicyConfigurations' -and ($emsg -match 'updateDefinitionValues' -or $emsg -match 'ResourceNotFound' -or $emsg -match 'NotFound' -or $emsg -match ' 404')) {
|
|
$result = 'Skipped'
|
|
$emsg = 'Skipped: this Administrative Template references custom/ingested ADMX definitions absent in the target tenant. Built-in ADMX imports fine; custom ADMX must be ingested in the target first.'
|
|
}
|
|
if ($emsg.Length -gt 600) { $emsg = $emsg.Substring(0,600) + ' ...' }
|
|
# Full failure dump (exact request body + complete exception) for precise diagnosis of stubborn 400s.
|
|
try {
|
|
$dbgBody = try { $pol | ConvertTo-Json -Depth 40 } catch { '(body serialization failed)' }
|
|
$dbg = ("KEY: " + $key + " NAME: " + $disp + " -> " + $planned + "`r`nGRID MSG: " + $emsg + "`r`n`r`n=== ErrorDetails.Message ===`r`n" + ([string]$_.ErrorDetails.Message) + "`r`n`r`n=== Exception (full) ===`r`n" + ($_ | Out-String) + "`r`n=== REQUEST BODY ===`r`n" + $dbgBody)
|
|
[System.IO.File]::WriteAllText(('C:\Logs\MDM-ODA\Logs\PIM_last_failed_' + $key + '.json'), $dbg)
|
|
} catch {}
|
|
$warn.Add($emsg)
|
|
Write-VerboseLog ("PIM: FAILED '" + $disp + "' [" + $key + "]: " + $emsg) -Level Error
|
|
}
|
|
|
|
$results.Add([PSCustomObject]@{
|
|
DisplayName = $disp
|
|
PolicyType = $label
|
|
PlannedName = $planned
|
|
Result = $result
|
|
Warnings = ($warn -join '; ')
|
|
})
|
|
}
|
|
|
|
$script:PIMExecResult = $results
|
|
Write-VerboseLog ('PIM: Complete. ' + $results.Count + ' processed.') -Level Info
|
|
|
|
} -Done {
|
|
|
|
foreach ($b in @('BtnPIMSelLocal','BtnPIMAllLocal','BtnPIMSelBlob','BtnPIMAllBlob')) { $script:Window.FindName($b).IsEnabled = $true }
|
|
$script:Window.FindName('PnlPIMProgress').Visibility = 'Collapsed'
|
|
|
|
$rows = $script:PIMExecResult
|
|
if (-not $rows -or $rows.Count -eq 0) { $script:Window.FindName('TxtPIMNoResults').Visibility='Visible'; return }
|
|
$lst = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
foreach ($r in @($rows | Sort-Object Result, PolicyType, DisplayName)) { $lst.Add($r) }
|
|
$dg = $script:Window.FindName('DgPIMResults')
|
|
$dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($lst)
|
|
$ok = @($rows | Where-Object { $_.Result -eq 'Created' }).Count
|
|
$script:Window.FindName('TxtPIMCount').Text = ("$ok of $($rows.Count) created")
|
|
$script:Window.FindName('BtnPIMCopyAll').IsEnabled = $true
|
|
$script:Window.FindName('PnlPIMResults').Visibility = 'Visible'
|
|
}
|
|
}
|
|
|
|
$script:Window.FindName('BtnPIMSelLocal').Add_Click({ Start-PolicyImport -Selected $true -Source 'Local' })
|
|
$script:Window.FindName('BtnPIMAllLocal').Add_Click({ Start-PolicyImport -Selected $false -Source 'Local' })
|
|
$script:Window.FindName('BtnPIMSelBlob').Add_Click( { Start-PolicyImport -Selected $true -Source 'Blob' })
|
|
$script:Window.FindName('BtnPIMAllBlob').Add_Click( { Start-PolicyImport -Selected $false -Source 'Blob' })
|
|
|
|
$script:Window.FindName('BtnPIMCopyAll').Add_Click({
|
|
$dg = $script:Window.FindName('DgPIMResults')
|
|
if ($dg -and $dg.ItemsSource) {
|
|
$sb = [System.Text.StringBuilder]::new()
|
|
[void]$sb.AppendLine("SourceName`tType`tCreatedAs`tResult`tWarnings")
|
|
foreach ($r in $dg.ItemsSource) { [void]$sb.AppendLine(($r.DisplayName,$r.PolicyType,$r.PlannedName,$r.Result,$r.Warnings -join "`t")) }
|
|
try { [System.Windows.Clipboard]::SetText($sb.ToString()); Show-Notification -Message 'Copied import summary to clipboard' -BgColor '#D4EDDA' -FgColor '#155724' } catch {}
|
|
}
|
|
})
|
|
|
|
# ============================================================
|
|
# DUPLICATE POLICY REVIEW
|
|
# ============================================================
|
|
$script:Window.FindName('BtnOpDupReview').Add_Click({
|
|
$script:Window.FindName('TxtDRNameFilter').Text=''
|
|
$script:Window.FindName('CmbDRPlatform').SelectedIndex=0
|
|
$script:Window.FindName('PnlDRProgress').Visibility='Collapsed'
|
|
$script:Window.FindName('PnlDRResults').Visibility='Collapsed'
|
|
$script:Window.FindName('TxtDRNoResults').Visibility='Collapsed'
|
|
$dg=$script:Window.FindName('DgDRResults'); if ($dg) { $dg.ItemsSource=$null }
|
|
$script:DRResult=$null
|
|
Show-Panel 'PanelDupReview'
|
|
Hide-Notification
|
|
Write-VerboseLog 'Panel: Duplicate Policy Review' -Level Info
|
|
})
|
|
$script:Window.FindName('BtnDRScan').Add_Click({ Start-DuplicateReview })
|
|
|
|
function Start-DuplicateReview {
|
|
Hide-Notification
|
|
$kw=''; $plat='(All platforms)'
|
|
try { $kw=[string]$script:Window.FindName('TxtDRNameFilter').Text.Trim() } catch {}
|
|
try { $pi=$script:Window.FindName('CmbDRPlatform').SelectedItem; if ($pi){$plat=[string]$pi.Content} } catch {}
|
|
$script:DRParams=@{ Keyword=$kw; Platform=$plat }
|
|
$script:DRResult=$null
|
|
$script:Window.FindName('PnlDRResults').Visibility='Collapsed'
|
|
$script:Window.FindName('TxtDRNoResults').Visibility='Collapsed'
|
|
$dg=$script:Window.FindName('DgDRResults'); if ($dg){$dg.ItemsSource=$null}
|
|
$script:Window.FindName('BtnDRExport').IsEnabled=$false
|
|
$script:Window.FindName('BtnDRCopyAll').IsEnabled=$false
|
|
$script:Window.FindName('PnlDRProgress').Visibility='Visible'
|
|
$script:Window.FindName('TxtDRProgressMsg').Text='Scanning all policy types for duplicates...'
|
|
$script:Window.FindName('BtnDRScan').IsEnabled=$false
|
|
Write-VerboseLog '--- Duplicate Policy Review: scanning ---' -Level Action
|
|
|
|
Invoke-OnBackground -BusyMessage '--- Duplicate Review: scanning ---' -Work {
|
|
$p=$script:DRParams; $base='https://graph.microsoft.com/beta/deviceManagement'
|
|
$rows=[System.Collections.Generic.List[PSCustomObject]]::new()
|
|
function DR-Canon { param($node)
|
|
$drop=@('id','name','displayname','createddatetime','lastmodifieddatetime','version','rolescopetagids','supportsscopetags','isassigned','settingcount','creationsource','prioritymetadata','technologies','templatedisplayname','templatedisplayversion','templatefamily','highestavailableversion','iscloneable','@odata.count')
|
|
if ($node -is [System.Collections.IDictionary]) {
|
|
$keys=@($node.Keys | Where-Object { $kl=([string]$_).ToLowerInvariant(); ($drop -notcontains $kl) -and ($kl -notlike '*@odata.context') } | Sort-Object)
|
|
$parts = foreach ($k in $keys) { ([string]$k).ToLowerInvariant() + '=' + (DR-Canon $node[$k]) }
|
|
return '{' + ($parts -join ';') + '}'
|
|
} elseif ($node -is [System.Collections.IEnumerable] -and -not ($node -is [string])) {
|
|
$els = foreach ($el in $node) { DR-Canon $el }
|
|
return '[' + ($els -join ',') + ']'
|
|
} else { return [string]$node }
|
|
}
|
|
function DR-Page { param([string]$Uri) $all=[System.Collections.Generic.List[object]]::new(); $u=$Uri; while ($u){ $r=Invoke-MgGraphRequest -Method GET -Uri $u; if ($r['value']){foreach($v in @($r['value'])){$all.Add($v)}}; $u=$r['@odata.nextLink'] }; return $all }
|
|
$typeMeta=@{ 'deviceConfigurations'=@{Label='Device Configuration';NameField='displayName'}; 'configurationPolicies'=@{Label='Configuration Policy';NameField='name'}; 'groupPolicyConfigurations'=@{Label='Administrative Templates';NameField='displayName'}; 'deviceManagementScripts'=@{Label='Platform Script (Windows PowerShell)';NameField='displayName'}; 'deviceHealthScripts'=@{Label='Detection/Remediation Script';NameField='displayName'}; 'windowsAutopilotDeploymentProfiles'=@{Label='Autopilot Profile';NameField='displayName'}; 'deviceCompliancePolicies'=@{Label='Compliance Policy';NameField='displayName'} }
|
|
$order=@('deviceConfigurations','configurationPolicies','groupPolicyConfigurations','deviceManagementScripts','deviceHealthScripts','windowsAutopilotDeploymentProfiles','deviceCompliancePolicies')
|
|
$buckets=@{}
|
|
foreach ($key in $order) {
|
|
if ($null -ne $Shared -and $Shared['StopRequested']) { break }
|
|
$nameFld=$typeMeta[$key]['NameField']; $label=$typeMeta[$key]['Label']
|
|
$list=@()
|
|
try {
|
|
switch ($key) {
|
|
'configurationPolicies' { $list=@(DR-Page -Uri ($base + '/configurationPolicies?' + '$expand=settings')) }
|
|
'deviceCompliancePolicies' { $list=@(DR-Page -Uri ($base + '/deviceCompliancePolicies?' + '$expand=scheduledActionsForRule($expand=scheduledActionConfigurations)')) }
|
|
default { $list=@(DR-Page -Uri ($base + '/' + $key)) }
|
|
}
|
|
} catch { Write-VerboseLog ('DR: could not list ' + $key + ': ' + $_.Exception.Message) -Level Warning; continue }
|
|
foreach ($it in $list) {
|
|
if (-not ($it -is [System.Collections.IDictionary])) { continue }
|
|
$polId2=[string]$it['id']; $pname=[string]$it[$nameFld]; if (-not $pname){$pname=[string]$it['displayName']}
|
|
$odt=[string]$it['@odata.type']; $plats=[string]$it['platforms']
|
|
$platform=Get-GPAPlatform -PolicyCollection $key -ODataType $odt -Platforms $plats
|
|
$content=$it
|
|
if ($key -eq 'groupPolicyConfigurations') {
|
|
try { $content=@{ pol=$it; defs=@(DR-Page -Uri ($base + '/groupPolicyConfigurations/' + $polId2 + '/definitionValues?' + '$expand=definition,presentationValues($expand=presentation)')) } } catch { $content=$it }
|
|
} elseif ($key -eq 'deviceManagementScripts' -or $key -eq 'deviceHealthScripts') {
|
|
try { $content=Invoke-MgGraphRequest -Method GET -Uri ($base + '/' + $key + '/' + $polId2) } catch {}
|
|
}
|
|
$h=DR-Canon $content
|
|
$lm=''; try { $lmv=[string]$it['lastModifiedDateTime']; if ($lmv){$lm=([datetime]$lmv).ToString('MM/dd/yy')} } catch {}
|
|
$meta=[PSCustomObject]@{ Id=$polId2; DisplayName=$pname; Platform=$platform; PolicyType=$label; TypeKey=$key; LastModified=$lm; Hash=$h }
|
|
if (-not $buckets.ContainsKey($h)) { $buckets[$h]=[System.Collections.Generic.List[object]]::new() }
|
|
$buckets[$h].Add($meta)
|
|
}
|
|
}
|
|
$grpNum=0; $groupCacheName=@{}; $filterCacheName=@{}
|
|
foreach ($h in $buckets.Keys) {
|
|
$members=$buckets[$h]
|
|
if ($members.Count -lt 2) { continue }
|
|
$grpNum++
|
|
foreach ($m in $members) {
|
|
$asg=''
|
|
try {
|
|
$ar=@(DR-Page -Uri ($base + '/' + $m.TypeKey + '/' + $m.Id + '/assignments'))
|
|
$parts=[System.Collections.Generic.List[string]]::new()
|
|
foreach ($a in $ar) {
|
|
$tgt=$a['target']; if (-not ($tgt -is [System.Collections.IDictionary])) { continue }
|
|
$tt=[string]$tgt['@odata.type']; $gname=''; $mode=''
|
|
if ($tt -match 'groupAssignmentTarget') {
|
|
$gid=[string]$tgt['groupId']
|
|
if ($gid) { if (-not $groupCacheName.ContainsKey($gid)) { try { $g=Invoke-MgGraphRequest -Method GET -Uri ('https://graph.microsoft.com/beta/groups/' + $gid + '?$select=displayName'); $groupCacheName[$gid]=[string]$g['displayName'] } catch { $groupCacheName[$gid]=$gid } }; $gname=$groupCacheName[$gid] }
|
|
$mode=if ($tt -match 'exclusion') { 'Exclude' } else { 'Include' }
|
|
} elseif ($tt -match 'allDevices') { $gname='All Devices'; $mode='Include' }
|
|
elseif ($tt -match 'allLicensedUsers') { $gname='All Users'; $mode='Include' }
|
|
$fid=[string]$tgt['deviceAndAppManagementAssignmentFilterId']; $ftype=[string]$tgt['deviceAndAppManagementAssignmentFilterType']; $fpart=''
|
|
if ($fid -and $ftype -and $ftype -ne 'none') { if (-not $filterCacheName.ContainsKey($fid)) { try { $f=Invoke-MgGraphRequest -Method GET -Uri ($base + '/assignmentFilters/' + $fid + '?$select=displayName'); $filterCacheName[$fid]=[string]$f['displayName'] } catch { $filterCacheName[$fid]=$fid } }; $fpart=' {Filter ' + $ftype + ': ' + $filterCacheName[$fid] + '}' }
|
|
if ($gname) { $parts.Add('[' + $mode + '] ' + $gname + $fpart) }
|
|
}
|
|
$asg=($parts -join '; ')
|
|
} catch {}
|
|
if (-not $asg) { $asg='(none)' }
|
|
$rows.Add([PSCustomObject]@{ Grp=$grpNum; DisplayName=$m.DisplayName; Platform=$m.Platform; PolicyType=$m.PolicyType; LastModified=$m.LastModified; Assignments=$asg })
|
|
}
|
|
}
|
|
$kw=[string]$p['Keyword']; $plat=[string]$p['Platform']
|
|
$filtered=[System.Collections.Generic.List[PSCustomObject]]::new()
|
|
$kws=@(); if ($kw) { $kws=@($kw -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) }
|
|
foreach ($r in $rows) {
|
|
if ($plat -and $plat -ne '(All platforms)' -and ([string]$r.Platform -ne $plat)) { continue }
|
|
if ($kws.Count -gt 0) { $hit=$false; foreach ($k in $kws) { if ([string]$r.DisplayName -like ('*'+$k+'*')) { $hit=$true; break } }; if (-not $hit) { continue } }
|
|
$filtered.Add($r)
|
|
}
|
|
$script:DRResult=@($filtered | Sort-Object Grp, DisplayName)
|
|
Write-VerboseLog ('DR: ' + $grpNum + ' duplicate set(s); ' + $filtered.Count + ' policies listed') -Level Info
|
|
} -Done {
|
|
$script:Window.FindName('PnlDRProgress').Visibility='Collapsed'
|
|
$script:Window.FindName('BtnDRScan').IsEnabled=$true
|
|
$rows=$script:DRResult
|
|
if (-not $rows -or @($rows).Count -eq 0) { $script:Window.FindName('TxtDRNoResults').Visibility='Visible'; return }
|
|
$lst=[System.Collections.Generic.List[PSCustomObject]]::new(); foreach ($r in @($rows)) { $lst.Add($r) }
|
|
$dg=$script:Window.FindName('DgDRResults'); $dg.ItemsSource=[System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($lst)
|
|
$sets=(@($rows | Select-Object -ExpandProperty Grp -Unique)).Count
|
|
$script:Window.FindName('TxtDRCount').Text=("$sets duplicate set(s), $(@($rows).Count) policies")
|
|
$script:Window.FindName('BtnDRExport').IsEnabled=$true
|
|
$script:Window.FindName('BtnDRCopyAll').IsEnabled=$true
|
|
$script:Window.FindName('PnlDRResults').Visibility='Visible'
|
|
}
|
|
}
|
|
|
|
$script:Window.FindName('BtnDRCopyAll').Add_Click({
|
|
$dg=$script:Window.FindName('DgDRResults')
|
|
if ($dg -and $dg.ItemsSource) {
|
|
$sb=[System.Text.StringBuilder]::new()
|
|
[void]$sb.AppendLine("Set`tPolicy Name`tPlatform`tType`tLast Modified`tAssignment Groups")
|
|
foreach ($r in $dg.ItemsSource) { [void]$sb.AppendLine(($r.Grp,$r.DisplayName,$r.Platform,$r.PolicyType,$r.LastModified,$r.Assignments -join "`t")) }
|
|
try { [System.Windows.Clipboard]::SetText($sb.ToString()); Show-Notification -Message 'Copied duplicate list to clipboard' -BgColor '#D4EDDA' -FgColor '#155724' } catch {}
|
|
}
|
|
})
|
|
$script:Window.FindName('BtnDRExport').Add_Click({
|
|
$dg=$script:Window.FindName('DgDRResults')
|
|
if (-not ($dg -and $dg.ItemsSource)) { return }
|
|
Add-Type -AssemblyName System.Windows.Forms
|
|
$sfd=New-Object System.Windows.Forms.SaveFileDialog
|
|
$sfd.Filter='CSV (*.csv)|*.csv'; $sfd.FileName=('DuplicatePolicies_' + (Get-Date -Format 'yyyyMMdd_HHmmss') + '.csv')
|
|
if ($sfd.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { return }
|
|
try { @($dg.ItemsSource) | Select-Object @{n='Set';e={$_.Grp}}, @{n='PolicyName';e={$_.DisplayName}}, Platform, PolicyType, LastModified, Assignments | Export-Csv -Path $sfd.FileName -NoTypeInformation -Encoding UTF8; Show-Notification -Message 'Exported duplicate list to CSV' -BgColor '#D4EDDA' -FgColor '#155724' } catch { Show-Notification -Message ('Export failed: ' + $_.Exception.Message) -BgColor '#F8D7DA' -FgColor '#721C24' }
|
|
})
|
|
|
|
# ============================================================
|
|
# POLICY CLEANUP (backup-first, then delete)
|
|
# ============================================================
|
|
$script:Window.FindName('BtnOpPolicyCleanup').Add_Click({
|
|
$script:Window.FindName('TxtPCNames').Text=''
|
|
$script:Window.FindName('CmbPCPlatform').SelectedIndex=0
|
|
$script:Window.FindName('CmbPCBackup').SelectedIndex=0
|
|
$script:Window.FindName('PnlPCProgress').Visibility='Collapsed'
|
|
$script:Window.FindName('PnlPCResults').Visibility='Collapsed'
|
|
$script:Window.FindName('TxtPCNoResults').Visibility='Collapsed'
|
|
$dg=$script:Window.FindName('DgPCResults'); if ($dg){$dg.ItemsSource=$null}
|
|
$script:Window.FindName('BtnPCDelete').IsEnabled=$false
|
|
$script:PCResolved=$null; $script:PCResolveResult=$null; $script:PCDeleteResult=$null
|
|
Show-Panel 'PanelPolicyCleanup'
|
|
Hide-Notification
|
|
Write-VerboseLog 'Panel: Policy Cleanup' -Level Info
|
|
})
|
|
$script:Window.FindName('BtnPCResolve').Add_Click({ Start-PolicyCleanupResolve })
|
|
$script:Window.FindName('BtnPCDelete').Add_Click({ Start-PolicyCleanupDelete })
|
|
|
|
function Start-PolicyCleanupResolve {
|
|
Hide-Notification
|
|
$namesRaw=''; try { $namesRaw=[string]$script:Window.FindName('TxtPCNames').Text } catch {}
|
|
$names=@($namesRaw -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
|
if ($names.Count -eq 0) { Show-Notification -Message 'Paste at least one policy name (one per line).' -BgColor '#FFF3CD' -FgColor '#856404'; return }
|
|
$plat='(All platforms)'; try { $pi=$script:Window.FindName('CmbPCPlatform').SelectedItem; if ($pi){$plat=[string]$pi.Content} } catch {}
|
|
$script:PCParams=@{ Names=$names; Platform=$plat }
|
|
$script:PCResolveResult=$null
|
|
$script:Window.FindName('PnlPCResults').Visibility='Collapsed'
|
|
$script:Window.FindName('TxtPCNoResults').Visibility='Collapsed'
|
|
$dg=$script:Window.FindName('DgPCResults'); if ($dg){$dg.ItemsSource=$null}
|
|
$script:Window.FindName('BtnPCDelete').IsEnabled=$false
|
|
$script:Window.FindName('PnlPCProgress').Visibility='Visible'
|
|
$script:Window.FindName('TxtPCProgressMsg').Text='Resolving policy names against the tenant...'
|
|
$script:Window.FindName('BtnPCResolve').IsEnabled=$false
|
|
Write-VerboseLog '--- Policy Cleanup: resolve ---' -Level Action
|
|
|
|
Invoke-OnBackground -BusyMessage '--- Policy Cleanup: resolving ---' -Work {
|
|
$p=$script:PCParams; $base='https://graph.microsoft.com/beta/deviceManagement'
|
|
$names=@($p['Names']); $plat=[string]$p['Platform']
|
|
$typeMeta=@{ 'deviceConfigurations'=@{Label='Device Configuration';NameField='displayName'}; 'configurationPolicies'=@{Label='Configuration Policy';NameField='name'}; 'groupPolicyConfigurations'=@{Label='Administrative Templates';NameField='displayName'}; 'deviceManagementScripts'=@{Label='Platform Script (Windows PowerShell)';NameField='displayName'}; 'deviceHealthScripts'=@{Label='Detection/Remediation Script';NameField='displayName'}; 'windowsAutopilotDeploymentProfiles'=@{Label='Autopilot Profile';NameField='displayName'}; 'deviceCompliancePolicies'=@{Label='Compliance Policy';NameField='displayName'} }
|
|
$order=@('deviceConfigurations','configurationPolicies','groupPolicyConfigurations','deviceManagementScripts','deviceHealthScripts','windowsAutopilotDeploymentProfiles','deviceCompliancePolicies')
|
|
function PC-Page { param([string]$Uri) $all=[System.Collections.Generic.List[object]]::new(); $u=$Uri; while ($u){ $r=Invoke-MgGraphRequest -Method GET -Uri $u; if ($r['value']){foreach($v in @($r['value'])){$all.Add($v)}}; $u=$r['@odata.nextLink'] }; return $all }
|
|
$catalog=[System.Collections.Generic.List[object]]::new()
|
|
foreach ($key in $order) {
|
|
$nf=$typeMeta[$key]['NameField']; $lbl=$typeMeta[$key]['Label']
|
|
try {
|
|
$sel=if ($key -eq 'configurationPolicies') { '$select=id,name,platforms,technologies' } else { '$select=id,displayName' }
|
|
$lst=@(PC-Page -Uri ($base + '/' + $key + '?' + $sel))
|
|
} catch { continue }
|
|
foreach ($it in $lst) {
|
|
if (-not ($it -is [System.Collections.IDictionary])) { continue }
|
|
$nm=[string]$it[$nf]; if (-not $nm){$nm=[string]$it['displayName']}
|
|
$platform=Get-GPAPlatform -PolicyCollection $key -ODataType ([string]$it['@odata.type']) -Platforms ([string]$it['platforms'])
|
|
$catalog.Add(@{ Id=[string]$it['id']; Name=$nm; TypeKey=$key; Label=$lbl; Platform=$platform })
|
|
}
|
|
}
|
|
$rows=[System.Collections.Generic.List[PSCustomObject]]::new()
|
|
foreach ($n in $names) {
|
|
$matched=@($catalog | Where-Object { ([string]$_.Name).Trim() -ieq $n -and ($plat -eq '(All platforms)' -or [string]$_.Platform -eq $plat) })
|
|
if ($matched.Count -eq 0) { $rows.Add([PSCustomObject]@{ InputName=$n; DisplayName=''; Platform=''; PolicyType=''; Status='Not found'; Id=''; TypeKey='' }) }
|
|
else { foreach ($m in $matched) { $rows.Add([PSCustomObject]@{ InputName=$n; DisplayName=$m.Name; Platform=$m.Platform; PolicyType=$m.Label; Status='Resolved'; Id=$m.Id; TypeKey=$m.TypeKey }) } }
|
|
}
|
|
$script:PCResolveResult=@($rows)
|
|
Write-VerboseLog ('Cleanup: resolved ' + (@($rows | Where-Object { $_.Status -eq 'Resolved' })).Count + ' of ' + $names.Count + ' name(s)') -Level Info
|
|
} -Done {
|
|
$script:Window.FindName('PnlPCProgress').Visibility='Collapsed'
|
|
$script:Window.FindName('BtnPCResolve').IsEnabled=$true
|
|
$rows=$script:PCResolveResult
|
|
if (-not $rows -or @($rows).Count -eq 0) { $script:Window.FindName('TxtPCNoResults').Visibility='Visible'; return }
|
|
$script:PCResolved=@($rows)
|
|
$lst=[System.Collections.Generic.List[PSCustomObject]]::new(); foreach ($r in @($rows)) { $lst.Add($r) }
|
|
$dg=$script:Window.FindName('DgPCResults'); $dg.ItemsSource=[System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($lst)
|
|
$rc=@($rows | Where-Object { $_.Status -eq 'Resolved' }).Count; $nfc=@($rows | Where-Object { $_.Status -eq 'Not found' }).Count
|
|
$script:Window.FindName('TxtPCCount').Text=("$rc resolved, $nfc not found")
|
|
$script:Window.FindName('PnlPCResults').Visibility='Visible'
|
|
if ($rc -gt 0) { $script:Window.FindName('BtnPCDelete').IsEnabled=$true }
|
|
}
|
|
}
|
|
|
|
function Start-PolicyCleanupDelete {
|
|
$resolved=@($script:PCResolved | Where-Object { $_.Status -eq 'Resolved' })
|
|
if ($resolved.Count -eq 0) { Show-Notification -Message 'Nothing resolved to delete. Run Resolve & Validate first.' -BgColor '#FFF3CD' -FgColor '#856404'; return }
|
|
$backupChoice='Local folder'; try { $bi=$script:Window.FindName('CmbPCBackup').SelectedItem; if ($bi){$backupChoice=[string]$bi.Content} } catch {}
|
|
$localFolder=''; $sasUrl=''
|
|
if ($backupChoice -like 'Local*') {
|
|
Add-Type -AssemblyName System.Windows.Forms
|
|
$fb=New-Object System.Windows.Forms.FolderBrowserDialog; $fb.Description='Choose a folder to BACK UP the policies before deletion'
|
|
if ($fb.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { Write-VerboseLog 'Cleanup cancelled (no backup folder).' -Level Warning; return }
|
|
$localFolder=$fb.SelectedPath
|
|
} else {
|
|
Add-Type -AssemblyName Microsoft.VisualBasic
|
|
$sasUrl=[Microsoft.VisualBasic.Interaction]::InputBox('Paste the FULL Blob container SAS URL (needs Write). Policy backups are uploaded here before deletion.','Backup to Azure Blob','')
|
|
if ([string]::IsNullOrWhiteSpace($sasUrl)) { Write-VerboseLog 'Cleanup cancelled (no SAS URL).' -Level Warning; return }
|
|
}
|
|
$msg=("You are about to BACK UP and PERMANENTLY DELETE " + $resolved.Count + " policy(ies) from this tenant.`n`nBackups (settings + assignment groups + filters) are written first. If ANY backup fails, nothing is deleted.`n`nProceed?")
|
|
$ans=[System.Windows.MessageBox]::Show($msg,'Confirm Policy Cleanup','YesNo','Warning')
|
|
if ($ans -ne 'Yes') { Write-VerboseLog 'Cleanup cancelled by user.' -Level Warning; return }
|
|
|
|
$script:PCParams=@{ Resolved=$resolved; BackupChoice=$backupChoice; LocalFolder=$localFolder; SasUrl=$sasUrl }
|
|
$script:PCDeleteResult=$null
|
|
$script:Window.FindName('PnlPCProgress').Visibility='Visible'
|
|
$script:Window.FindName('TxtPCProgressMsg').Text='Backing up policies (delete only if all backups succeed)...'
|
|
$script:Window.FindName('BtnPCDelete').IsEnabled=$false
|
|
$script:Window.FindName('BtnPCResolve').IsEnabled=$false
|
|
Write-VerboseLog '--- Policy Cleanup: backup + delete ---' -Level Action
|
|
|
|
Invoke-OnBackground -BusyMessage '--- Policy Cleanup: backup+delete ---' -Work {
|
|
$p=$script:PCParams; $base='https://graph.microsoft.com/beta/deviceManagement'
|
|
$resolved=@($p['Resolved']); $results=[System.Collections.Generic.List[PSCustomObject]]::new()
|
|
$utf8=New-Object System.Text.UTF8Encoding($false); $ts=(Get-Date).ToString('yyyyMMdd_HHmmss')
|
|
$blobBase=''; $blobSas=''
|
|
if ($p['BackupChoice'] -notlike 'Local*') {
|
|
$raw=([string]$p['SasUrl']).Trim(); $qi=$raw.IndexOf('?')
|
|
if ($qi -lt 0) { $script:PCDeleteResult=@{ FatalError='SAS URL missing its ?...sig=... query string. Nothing deleted.' }; return }
|
|
$blobBase=$raw.Substring(0,$qi).TrimEnd('/'); $blobSas=$raw.Substring($qi+1)
|
|
}
|
|
$expandMap=@{ 'configurationPolicies'='$expand=settings'; 'deviceCompliancePolicies'='$expand=scheduledActionsForRule($expand=scheduledActionConfigurations)' }
|
|
$groupCacheName=@{}; $filterCacheName=@{}
|
|
$backups=[System.Collections.Generic.List[object]]::new(); $backupFail=$false; $failMsg=''
|
|
foreach ($r in $resolved) {
|
|
try {
|
|
$k=[string]$r.TypeKey; $polId2=[string]$r.Id
|
|
$uri=($base + '/' + $k + '/' + $polId2); if ($expandMap.ContainsKey($k)) { $uri=$uri + '?' + $expandMap[$k] }
|
|
$full=Invoke-MgGraphRequest -Method GET -Uri $uri
|
|
$asgOut=[System.Collections.Generic.List[object]]::new()
|
|
try {
|
|
$ar=Invoke-MgGraphRequest -Method GET -Uri ($base + '/' + $k + '/' + $polId2 + '/assignments')
|
|
foreach ($a in @($ar['value'])) {
|
|
$tgt=$a['target']; if (-not ($tgt -is [System.Collections.IDictionary])) { continue }
|
|
$tt=[string]$tgt['@odata.type']; $gname=''; $mode='Include'
|
|
if ($tt -match 'groupAssignmentTarget') { $gid=[string]$tgt['groupId']; if ($gid){ if (-not $groupCacheName.ContainsKey($gid)){ try { $g=Invoke-MgGraphRequest -Method GET -Uri ('https://graph.microsoft.com/beta/groups/' + $gid + '?$select=displayName'); $groupCacheName[$gid]=[string]$g['displayName'] } catch { $groupCacheName[$gid]=$gid } }; $gname=$groupCacheName[$gid] }; $mode=if ($tt -match 'exclusion'){'Exclude'}else{'Include'} }
|
|
elseif ($tt -match 'allDevices') { $gname='All Devices' } elseif ($tt -match 'allLicensedUsers') { $gname='All Users' }
|
|
$fid=[string]$tgt['deviceAndAppManagementAssignmentFilterId']; $ftype=[string]$tgt['deviceAndAppManagementAssignmentFilterType']; $fname=''
|
|
if ($fid -and $ftype -and $ftype -ne 'none') { if (-not $filterCacheName.ContainsKey($fid)){ try { $f=Invoke-MgGraphRequest -Method GET -Uri ($base + '/assignmentFilters/' + $fid + '?$select=displayName'); $filterCacheName[$fid]=[string]$f['displayName'] } catch { $filterCacheName[$fid]=$fid } }; $fname=$filterCacheName[$fid] }
|
|
$asgOut.Add([ordered]@{ mode=$mode; groupName=$gname; groupId=[string]$tgt['groupId']; filterType=$ftype; filterName=$fname; filterId=$fid; rawTarget=$tgt })
|
|
}
|
|
} catch {}
|
|
$bobj=[ordered]@{ mdmOdaBackup=[ordered]@{ backedUpAtUtc=(Get-Date).ToUniversalTime().ToString('o'); reason='PolicyCleanup'; policyTypeKey=$k; displayName=[string]$r.DisplayName; sourcePolicyId=$polId2 }; policy=$full; assignments=@($asgOut) }
|
|
$json=$bobj | ConvertTo-Json -Depth 40
|
|
$safe=(([string]$r.DisplayName) -replace '[^\w\.\- ]','_').Trim(); if ($safe.Length -gt 60){$safe=$safe.Substring(0,60)}
|
|
$fname2=('CLEANUP_' + $ts + '__' + $k + '__' + $safe + '.json')
|
|
if ($p['BackupChoice'] -like 'Local*') { [System.IO.File]::WriteAllText((Join-Path $p['LocalFolder'] $fname2), $json, $utf8) }
|
|
else { $bu=$blobBase + '/' + [System.Uri]::EscapeDataString($fname2) + '?' + $blobSas; $null=Invoke-WebRequest -Method Put -Uri $bu -Headers @{ 'x-ms-blob-type'='BlockBlob' } -Body $json -ContentType 'application/json' -UseBasicParsing -ErrorAction Stop }
|
|
$backups.Add(@{ Row=$r; File=$fname2 })
|
|
} catch { $backupFail=$true; $failMsg=('Backup failed for "' + [string]$r.DisplayName + '": ' + $_.Exception.Message); break }
|
|
}
|
|
if ($backupFail) { $script:PCDeleteResult=@{ FatalError=('ABORTED - no policies were deleted. ' + $failMsg) }; return }
|
|
foreach ($b in $backups) {
|
|
$r=$b.Row; $k=[string]$r.TypeKey; $polId2=[string]$r.Id; $st='Deleted'; $err=''
|
|
try { Invoke-MgGraphRequest -Method DELETE -Uri ($base + '/' + $k + '/' + $polId2) | Out-Null }
|
|
catch { $st='Delete failed'; $err=[string]$_.Exception.Message; try { if ($_.ErrorDetails -and $_.ErrorDetails.Message){$err=[string]$_.ErrorDetails.Message} } catch {} }
|
|
$results.Add([PSCustomObject]@{ InputName=[string]$r.InputName; DisplayName=[string]$r.DisplayName; Platform=[string]$r.Platform; PolicyType=[string]$r.PolicyType; Status=$st; BackupFile=$b.File; Detail=$err })
|
|
}
|
|
$script:PCDeleteResult=@{ Rows=@($results); FatalError='' }
|
|
Write-VerboseLog ('Cleanup: backed up + processed ' + $results.Count + ' policy(ies)') -Level Info
|
|
} -Done {
|
|
$script:Window.FindName('PnlPCProgress').Visibility='Collapsed'
|
|
$script:Window.FindName('BtnPCResolve').IsEnabled=$true
|
|
$res=$script:PCDeleteResult
|
|
if ($res -and $res['FatalError']) { Show-Notification -Message $res['FatalError'] -BgColor '#F8D7DA' -FgColor '#721C24'; $script:Window.FindName('BtnPCDelete').IsEnabled=$true; return }
|
|
$rows=if ($res) { $res['Rows'] } else { @() }
|
|
$lst=[System.Collections.Generic.List[PSCustomObject]]::new(); foreach ($r in @($rows)) { $lst.Add([PSCustomObject]@{ InputName=$r.InputName; DisplayName=$r.DisplayName; Platform=$r.Platform; PolicyType=$r.PolicyType; Status=$r.Status }) }
|
|
$dg=$script:Window.FindName('DgPCResults'); $dg.ItemsSource=[System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($lst)
|
|
$ok=@($rows | Where-Object { $_.Status -eq 'Deleted' }).Count
|
|
$script:Window.FindName('TxtPCCount').Text=("$ok of $(@($rows).Count) deleted (all backed up first)")
|
|
$script:Window.FindName('PnlPCResults').Visibility='Visible'
|
|
Show-Notification -Message ("Cleanup complete: $ok deleted, backups saved") -BgColor '#D4EDDA' -FgColor '#155724'
|
|
}
|
|
}
|
|
|
|
# ============================================================
|
|
# END POLICY EXPORT / IMPORT (V0.81)
|
|
# ============================================================
|
|
|
|
# ============================================================
|
|
# DEVICE MANAGEMENT: Run Remediation + Bulk Sync (V0.82)
|
|
# ============================================================
|
|
|
|
# Shared device resolver (injected into Invoke-OnBackground runspace via fnNames).
|
|
# Uses only Invoke-GraphGet / Invoke-MgGraphRequest (both available in the runspace).
|
|
# Returns @{ Devices = List[hashtable]; Unresolved = List[string] }
|
|
function Resolve-DevicesForAction {
|
|
param([string]$InputType,[string[]]$Entries)
|
|
$devs = [System.Collections.Generic.List[hashtable]]::new()
|
|
$unres = [System.Collections.Generic.List[string]]::new()
|
|
$seen = [System.Collections.Generic.HashSet[string]]::new()
|
|
$sel = 'id,deviceName,operatingSystem,osVersion,managedDeviceOwnerType,azureADDeviceId,userPrincipalName'
|
|
$guidRx = '^[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}$'
|
|
$addRec = {
|
|
param($iDev)
|
|
$iid = [string]$iDev['id']
|
|
if (-not $iid) { return }
|
|
if ($seen.Add($iid)) {
|
|
$devs.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']
|
|
})
|
|
}
|
|
}
|
|
$odEsc = {
|
|
param($s)
|
|
$x = [string]$s
|
|
$x = $x -replace "'","''"
|
|
$x = $x -replace '%','%25'
|
|
$x = $x -replace '\+','%2B'
|
|
$x = $x -replace '#','%23'
|
|
$x = $x -replace '&','%26'
|
|
return $x
|
|
}
|
|
foreach ($entry in $Entries) {
|
|
$e = [string]$entry
|
|
if ([string]::IsNullOrWhiteSpace($e)) { continue }
|
|
$e = $e.Trim()
|
|
try {
|
|
switch ($InputType) {
|
|
"UPN (resolve user's devices)" {
|
|
$enc = [Uri]::EscapeDataString($e)
|
|
$owned = (Invoke-GraphGet "https://graph.microsoft.com/v1.0/users/$enc/ownedDevices?`$select=deviceId,displayName&`$top=999")
|
|
$found = $false
|
|
foreach ($ud in $owned) {
|
|
$az = [string]$ud['deviceId']; if (-not $az) { continue }
|
|
$encAz = [Uri]::EscapeDataString($az)
|
|
$r = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$encAz'&`$select=$sel&`$top=1"
|
|
$v = @($r['value']); if ($v.Count -gt 0) { & $addRec $v[0]; $found = $true }
|
|
}
|
|
if (-not $found) { $unres.Add("$e (no managed devices)") }
|
|
}
|
|
'Device Name' {
|
|
$safe = & $odEsc $e
|
|
$r = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=deviceName eq '$safe'&`$select=$sel&`$top=50"
|
|
$v = @($r['value'])
|
|
if ($v.Count -gt 0) { foreach ($d in $v) { & $addRec $d } } else { $unres.Add($e) }
|
|
}
|
|
'Entra Device ID' {
|
|
if ($e -notmatch $guidRx) { $unres.Add("$e (not a GUID)"); break }
|
|
$encId = [Uri]::EscapeDataString($e)
|
|
$r = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$encId'&`$select=$sel&`$top=1"
|
|
$v = @($r['value']); if ($v.Count -gt 0) { & $addRec $v[0] } else { $unres.Add("$e (not enrolled)") }
|
|
}
|
|
'Intune Device ID' {
|
|
if ($e -notmatch $guidRx) { $unres.Add("$e (not a GUID)"); break }
|
|
try {
|
|
$d = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$e`?`$select=$sel"
|
|
if ($d -and $d['id']) { & $addRec $d } else { $unres.Add("$e (not found)") }
|
|
} catch { $unres.Add("$e (not found)") }
|
|
}
|
|
'Serial Number' {
|
|
$safe = & $odEsc $e
|
|
$r = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=serialNumber eq '$safe'&`$select=$sel&`$top=50"
|
|
$v = @($r['value'])
|
|
if ($v.Count -gt 0) { foreach ($d in $v) { & $addRec $d } } else { $unres.Add($e) }
|
|
}
|
|
'Group (User or Device)' {
|
|
$gid = $null
|
|
if ($e -match $guidRx) { $gid = $e }
|
|
else {
|
|
$safe = & $odEsc $e
|
|
$gr = $null
|
|
try { $gr = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=displayName eq '$safe'&`$select=id,displayName&`$top=1" } catch {}
|
|
$gv = @($gr['value'])
|
|
if ($gv.Count -eq 0) {
|
|
try { $gr = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups?`$search=`"displayName:$([Uri]::EscapeDataString($e))`"&`$select=id,displayName&`$top=5" -Headers @{ ConsistencyLevel = 'eventual' } } catch {}
|
|
$gv = @($gr['value'])
|
|
}
|
|
if ($gv.Count -gt 0) { $gid = [string]$gv[0]['id'] }
|
|
}
|
|
if (-not $gid) { $unres.Add("$e (group not found)"); break }
|
|
$members = (Invoke-GraphGet "https://graph.microsoft.com/v1.0/groups/$gid/transitiveMembers?`$select=id&`$top=999")
|
|
$anyDev = $false
|
|
foreach ($mem in $members) {
|
|
$ot = [string]$mem['@odata.type']; $mid = [string]$mem['id']
|
|
if ($ot -eq '#microsoft.graph.device') {
|
|
$dr = $null; try { $dr = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/devices/$mid`?`$select=deviceId" } catch {}
|
|
$az = [string]$dr['deviceId']; if (-not $az) { continue }
|
|
$encAz = [Uri]::EscapeDataString($az)
|
|
$r = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$encAz'&`$select=$sel&`$top=1"
|
|
$v = @($r['value']); if ($v.Count -gt 0) { & $addRec $v[0]; $anyDev = $true }
|
|
} elseif ($ot -eq '#microsoft.graph.user') {
|
|
$owned = (Invoke-GraphGet "https://graph.microsoft.com/v1.0/users/$mid/ownedDevices?`$select=deviceId&`$top=999")
|
|
foreach ($ud in $owned) {
|
|
$az = [string]$ud['deviceId']; if (-not $az) { continue }
|
|
$encAz = [Uri]::EscapeDataString($az)
|
|
$r = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=azureADDeviceId eq '$encAz'&`$select=$sel&`$top=1"
|
|
$v = @($r['value']); if ($v.Count -gt 0) { & $addRec $v[0]; $anyDev = $true }
|
|
}
|
|
}
|
|
}
|
|
if (-not $anyDev) { $unres.Add("$e (no managed devices in group)") }
|
|
}
|
|
default { $unres.Add("$e (unknown input type)") }
|
|
}
|
|
} catch {
|
|
$msg = [string]$_.Exception.Message
|
|
$unres.Add("$e (error: $msg)")
|
|
}
|
|
}
|
|
return @{ Devices = $devs; Unresolved = $unres }
|
|
}
|
|
|
|
# Device-action confirmation dialog (UI thread) - visually consistent with
|
|
# Show-ConfirmationDialog used by Group Management, with device-appropriate labels.
|
|
function Show-DeviceActionConfirm {
|
|
param(
|
|
[string]$Title,
|
|
[string]$ActionLabel,
|
|
[string]$ScriptName = '',
|
|
[string]$Safeguard = '',
|
|
[object[]]$Devices,
|
|
[object[]]$Excluded
|
|
)
|
|
$Devices = @($Devices | Where-Object { $_ })
|
|
$Excluded = @($Excluded | Where-Object { $_ })
|
|
$tgtCount = $Devices.Count
|
|
$exCount = $Excluded.Count
|
|
$sb = [System.Text.StringBuilder]::new()
|
|
$null = $sb.AppendLine("ACTION : $ActionLabel")
|
|
if ($ScriptName) { $null = $sb.AppendLine("SCRIPT : $ScriptName") }
|
|
if ($Safeguard) { $null = $sb.AppendLine("SAFEGUARD : $Safeguard") }
|
|
$null = $sb.AppendLine("")
|
|
$null = $sb.AppendLine("Target devices : $tgtCount")
|
|
if ($exCount -gt 0) {
|
|
$null = $sb.AppendLine("Excluded/skipped: $exCount")
|
|
$null = $sb.AppendLine("")
|
|
$null = $sb.AppendLine("-- EXCLUDED (will be skipped) --")
|
|
foreach ($x in $Excluded) { $null = $sb.AppendLine(" $([char]0x2717) $x") }
|
|
}
|
|
$null = $sb.AppendLine("")
|
|
$null = $sb.AppendLine("-- TARGET DEVICES (will be processed) --")
|
|
foreach ($d in $Devices) {
|
|
$nm = [string]$d['DeviceName']; $os = [string]$d['OS']; $ow = [string]$d['Ownership']; $up = [string]$d['UPN']
|
|
$null = $sb.AppendLine(" $([char]0x2713) $nm [$os] ($ow) $up")
|
|
}
|
|
[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="✓ Confirm & 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
|
|
}
|
|
|
|
# Populate the Run Remediation script dropdown from deviceHealthScripts (UI thread).
|
|
function Load-RemediationScripts {
|
|
$cmb = $script:Window.FindName('CmbRRScript')
|
|
if (-not $cmb) { return }
|
|
$cmb.Items.Clear()
|
|
$script:RRScriptsAll = @()
|
|
$script:RRScriptMap = @{}
|
|
try {
|
|
$scripts = (Invoke-GraphGet "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts?`$select=id,displayName,publisher&`$top=200")
|
|
} catch {
|
|
$msg = [string]$_.Exception.Message
|
|
Show-Notification -Message ("Could not load remediation scripts: " + $msg) -BgColor '#F8D7DA' -FgColor '#721C24'
|
|
return
|
|
}
|
|
$names = [System.Collections.Generic.List[string]]::new()
|
|
foreach ($s in ($scripts | Sort-Object { [string]$_['displayName'] })) {
|
|
$nm = [string]$s['displayName']; $id = [string]$s['id']
|
|
if ([string]::IsNullOrWhiteSpace($nm)) { continue }
|
|
if (-not $script:RRScriptMap.ContainsKey($nm)) { $script:RRScriptMap[$nm] = $id; $names.Add($nm) }
|
|
}
|
|
$script:RRScriptsAll = @($names)
|
|
foreach ($n in $script:RRScriptsAll) { [void]$cmb.Items.Add([string]$n) }
|
|
Write-VerboseLog ("Run Remediation: loaded " + $cmb.Items.Count + " script(s)") -Level Info
|
|
}
|
|
|
|
# ---- Tile clicks ----
|
|
$script:Window.FindName('BtnOpRunRemediation').Add_Click({
|
|
$script:Window.FindName('TxtRRInputList').Text = ''
|
|
$script:Window.FindName('CmbRRInputType').SelectedIndex = 0
|
|
$script:Window.FindName('TxtRRInputLabel').Text = "UPNs - one per line"
|
|
$script:Window.FindName('PnlRRResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('PnlRRProgress').Visibility = 'Collapsed'
|
|
Show-Panel 'PanelRunRemediation'
|
|
Hide-Notification
|
|
$script:Window.FindName('TxtRRScriptFilter').Text = ''
|
|
Load-RemediationScripts
|
|
Write-VerboseLog 'Panel: Run Remediation' -Level Info
|
|
})
|
|
$script:Window.FindName('BtnRRReload').Add_Click({ $script:Window.FindName('TxtRRScriptFilter').Text = ''; Load-RemediationScripts })
|
|
$script:Window.FindName('TxtRRScriptFilter').Add_TextChanged({
|
|
$cmb = $script:Window.FindName('CmbRRScript')
|
|
if (-not $cmb) { return }
|
|
$txt = [string]$script:Window.FindName('TxtRRScriptFilter').Text
|
|
$all = @($script:RRScriptsAll)
|
|
if ([string]::IsNullOrWhiteSpace($txt)) { $filtered = $all } else { $filtered = @($all | Where-Object { $_ -like ('*' + $txt + '*') }) }
|
|
$cmb.Items.Clear()
|
|
foreach ($n in $filtered) { [void]$cmb.Items.Add([string]$n) }
|
|
if (-not [string]::IsNullOrWhiteSpace($txt) -and $cmb.Items.Count -gt 0) { $cmb.IsDropDownOpen = $true } else { $cmb.IsDropDownOpen = $false }
|
|
})
|
|
|
|
$script:Window.FindName('BtnOpBulkSync').Add_Click({
|
|
$script:Window.FindName('TxtBSInputList').Text = ''
|
|
$script:Window.FindName('CmbBSInputType').SelectedIndex = 0
|
|
$script:Window.FindName('CmbBSPlatform').SelectedIndex = 0
|
|
$script:Window.FindName('TxtBSInputLabel').Text = "UPNs - one per line"
|
|
$script:Window.FindName('PnlBSResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('PnlBSProgress').Visibility = 'Collapsed'
|
|
Show-Panel 'PanelBulkSync'
|
|
Hide-Notification
|
|
Write-VerboseLog 'Panel: Bulk Sync' -Level Info
|
|
})
|
|
|
|
# ---- Input-type label swap ----
|
|
$script:Window.FindName('CmbRRInputType').Add_SelectionChanged({
|
|
$sel = [string]$script:Window.FindName('CmbRRInputType').SelectedItem.Content
|
|
$lbl = switch ($sel) {
|
|
"UPN (resolve user's devices)" { 'UPNs - one per line' }
|
|
'Device Name' { 'DEVICE NAMES - one per line' }
|
|
'Entra Device ID' { 'ENTRA DEVICE IDs - one per line' }
|
|
'Intune Device ID' { 'INTUNE DEVICE IDs - one per line' }
|
|
'Serial Number' { 'SERIAL NUMBERS - one per line' }
|
|
'Group (User or Device)' { 'GROUP NAMES OR IDs - one per line' }
|
|
default { 'INPUT - one per line' }
|
|
}
|
|
$script:Window.FindName('TxtRRInputLabel').Text = $lbl
|
|
})
|
|
$script:Window.FindName('CmbBSInputType').Add_SelectionChanged({
|
|
$sel = [string]$script:Window.FindName('CmbBSInputType').SelectedItem.Content
|
|
$lbl = switch ($sel) {
|
|
"UPN (resolve user's devices)" { 'UPNs - one per line' }
|
|
'Device Name' { 'DEVICE NAMES - one per line' }
|
|
'Entra Device ID' { 'ENTRA DEVICE IDs - one per line' }
|
|
'Intune Device ID' { 'INTUNE DEVICE IDs - one per line' }
|
|
'Serial Number' { 'SERIAL NUMBERS - one per line' }
|
|
'Group (User or Device)' { 'GROUP NAMES OR IDs - one per line' }
|
|
default { 'INPUT - one per line' }
|
|
}
|
|
$script:Window.FindName('TxtBSInputLabel').Text = $lbl
|
|
})
|
|
|
|
# ---- Run Remediation: resolve -> filter corp Windows -> confirm -> execute ----
|
|
$script:Window.FindName('BtnRRRun').Add_Click({
|
|
$cmbS = $script:Window.FindName('CmbRRScript')
|
|
$scriptName = [string]$cmbS.SelectedItem
|
|
if ([string]::IsNullOrWhiteSpace($scriptName)) { $scriptName = [string]$cmbS.Text }
|
|
$scriptName = $scriptName.Trim()
|
|
if ([string]::IsNullOrWhiteSpace($scriptName)) { Show-Notification -Message 'Select a remediation script first.' -BgColor '#FFF3CD' -FgColor '#856404'; return }
|
|
$scriptId = ''
|
|
if ($script:RRScriptMap -and $script:RRScriptMap.ContainsKey($scriptName)) { $scriptId = [string]$script:RRScriptMap[$scriptName] }
|
|
if ([string]::IsNullOrWhiteSpace($scriptId)) { Show-Notification -Message 'Could not match that script name. Pick a script from the list.' -BgColor '#FFF3CD' -FgColor '#856404'; return }
|
|
$itype = [string]$script:Window.FindName('CmbRRInputType').SelectedItem.Content
|
|
$txt = [string]$script:Window.FindName('TxtRRInputList').Text
|
|
if ([string]::IsNullOrWhiteSpace($txt)) { Show-Notification -Message 'Enter at least one device identifier.' -BgColor '#FFF3CD' -FgColor '#856404'; return }
|
|
$script:RRParams = @{ ScriptId = $scriptId; ScriptName = $scriptName; InputType = $itype; InputTxt = $txt }
|
|
$script:Window.FindName('PnlRRResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('PnlRRProgress').Visibility = 'Visible'
|
|
$script:Window.FindName('TxtRRProgressMsg').Text = 'Resolving devices...'
|
|
$btn = $script:Window.FindName('BtnRRRun')
|
|
Invoke-OnBackground -DisableButton $btn -BusyMessage '--- Run Remediation: resolving devices ---' -Work {
|
|
$p = $script:RRParams
|
|
$entries = @($p['InputTxt'] -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
|
$res = Resolve-DevicesForAction -InputType $p['InputType'] -Entries $entries
|
|
$inc = [System.Collections.Generic.List[hashtable]]::new()
|
|
$exc = [System.Collections.Generic.List[string]]::new()
|
|
foreach ($u in @($res['Unresolved'])) { $exc.Add([string]$u) }
|
|
foreach ($d in @($res['Devices'])) {
|
|
$osn = [string]$d['OS']; $own = [string]$d['Ownership']
|
|
$isWin = ($osn -like 'Windows*')
|
|
$isCorp = ($own -eq 'company')
|
|
if ($isWin -and $isCorp) { $inc.Add($d) }
|
|
else {
|
|
$rsn = if (-not $isWin -and -not $isCorp) { 'personal + non-Windows' } elseif (-not $isWin) { 'non-Windows' } else { 'personal' }
|
|
$exc.Add(("{0} [{1}] ({2}) - skipped: {3}" -f [string]$d['DeviceName'],$osn,$own,$rsn))
|
|
}
|
|
}
|
|
$script:RRResolve = @{ Included = @($inc); Excluded = @($exc) }
|
|
Write-VerboseLog ("Run Remediation: " + $inc.Count + " corporate Windows device(s), " + $exc.Count + " excluded/unresolved") -Level Info
|
|
} -Done {
|
|
$script:Window.FindName('PnlRRProgress').Visibility = 'Collapsed'
|
|
$rr = $script:RRResolve
|
|
$inc = @(); $exc = @()
|
|
if ($rr) {
|
|
if ($null -ne $rr['Included']) { $inc = @($rr['Included']) }
|
|
if ($null -ne $rr['Excluded']) { $exc = @($rr['Excluded']) }
|
|
}
|
|
if ($inc.Count -eq 0) { Show-Notification -Message 'No corporate Windows devices resolved. Nothing to run.' -BgColor '#FFF3CD' -FgColor '#856404'; return }
|
|
$ok = Show-DeviceActionConfirm -Title 'Run Remediation' -ActionLabel ('Run remediation on ' + $inc.Count + ' device(s)') -ScriptName $script:RRParams['ScriptName'] -Safeguard 'Company-owned Windows devices only' -Devices $inc -Excluded $exc
|
|
if (-not $ok) { Write-VerboseLog 'Run Remediation cancelled by user.' -Level Warning; return }
|
|
$script:Window.FindName('PnlRRProgress').Visibility = 'Visible'
|
|
$script:Window.FindName('TxtRRProgressMsg').Text = 'Running remediation...'
|
|
$btn2 = $script:Window.FindName('BtnRRRun')
|
|
Invoke-OnBackground -DisableButton $btn2 -BusyMessage '--- Run Remediation: executing ---' -Work {
|
|
$p = $script:RRParams; $rr = $script:RRResolve
|
|
$sid = [string]$p['ScriptId']
|
|
$rows = [System.Collections.Generic.List[object]]::new()
|
|
$okN = 0; $failN = 0
|
|
foreach ($d in @($rr['Included'])) {
|
|
$iid = [string]$d['IntuneId']; $st = ''; $er = ''; $sc = 0; $resp = $null
|
|
$rurl = "https://graph.microsoft.com/beta/deviceManagement/managedDevices('$iid')/initiateOnDemandProactiveRemediation"
|
|
Write-VerboseLog ("Run Remediation POST -> managedDevices('$iid') body {scriptPolicyId=$sid}") -Level Info
|
|
try {
|
|
$resp = Invoke-MgGraphRequest -Method POST -Uri $rurl -Body @{ scriptPolicyId = $sid } -ContentType 'application/json' -StatusCodeVariable sc -SkipHttpErrorCheck
|
|
} catch {
|
|
$sc = -1
|
|
$er = [string]$_.Exception.Message
|
|
try { if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $er = [string]$_.ErrorDetails.Message } } catch {}
|
|
}
|
|
if ($sc -ge 200 -and $sc -lt 300) {
|
|
$st = "Sent (HTTP $sc)"; $okN++
|
|
} else {
|
|
$failN++; $st = "Failed (HTTP $sc)"
|
|
if (-not $er) { try { $er = if ($resp) { ($resp | ConvertTo-Json -Depth 6 -Compress) } else { '' } } catch { $er = '' } }
|
|
Write-VerboseLog ("Run Remediation FAILED HTTP $sc for $($d['DeviceName']): $er") -Level Error
|
|
}
|
|
$resTxt = if ($er) { "$st - $er" } else { $st }
|
|
$rows.Add([PSCustomObject]@{ DeviceName = [string]$d['DeviceName']; OS = [string]$d['OS']; Ownership = [string]$d['Ownership']; UPN = [string]$d['UPN']; Result = $resTxt })
|
|
}
|
|
$script:RRExec = @{ Rows = @($rows); Ok = $okN; Fail = $failN }
|
|
Write-VerboseLog ("Run Remediation: sent $okN, failed $failN") -Level Info
|
|
} -Done {
|
|
$script:Window.FindName('PnlRRProgress').Visibility = 'Collapsed'
|
|
$ex = $script:RRExec
|
|
$rows = @(); if ($ex -and $null -ne $ex['Rows']) { $rows = @($ex['Rows']) }
|
|
$lst = [System.Collections.Generic.List[PSCustomObject]]::new(); foreach ($r in $rows) { $lst.Add($r) }
|
|
$dg = $script:Window.FindName('DgRRResults'); $dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($lst)
|
|
$okN = if ($ex) { [int]$ex['Ok'] } else { 0 }; $failN = if ($ex) { [int]$ex['Fail'] } else { 0 }
|
|
$script:Window.FindName('TxtRRCount').Text = ("$okN sent, $failN failed of $($rows.Count) device(s)")
|
|
$script:Window.FindName('PnlRRResults').Visibility = 'Visible'
|
|
$bg = if ($failN -gt 0) { '#FFF3CD' } else { '#D4EDDA' }; $fg = if ($failN -gt 0) { '#856404' } else { '#155724' }
|
|
Show-Notification -Message ("Run Remediation sent to $okN device(s), $failN failed. Each runs within a few minutes via WNS; output appears under Intune > Reports > Scripts and remediations.") -BgColor $bg -FgColor $fg
|
|
}
|
|
}
|
|
})
|
|
|
|
# ---- Bulk Sync: resolve -> platform filter -> confirm -> execute ----
|
|
$script:Window.FindName('BtnBSRun').Add_Click({
|
|
$itype = [string]$script:Window.FindName('CmbBSInputType').SelectedItem.Content
|
|
$txt = [string]$script:Window.FindName('TxtBSInputList').Text
|
|
if ([string]::IsNullOrWhiteSpace($txt)) { Show-Notification -Message 'Enter at least one device identifier.' -BgColor '#FFF3CD' -FgColor '#856404'; return }
|
|
$plat = [string]$script:Window.FindName('CmbBSPlatform').SelectedItem.Content
|
|
$script:BSParams = @{ InputType = $itype; InputTxt = $txt; Platform = $plat }
|
|
$script:Window.FindName('PnlBSResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('PnlBSProgress').Visibility = 'Visible'
|
|
$script:Window.FindName('TxtBSProgressMsg').Text = 'Resolving devices...'
|
|
$btn = $script:Window.FindName('BtnBSRun')
|
|
Invoke-OnBackground -DisableButton $btn -BusyMessage '--- Bulk Sync: resolving devices ---' -Work {
|
|
$p = $script:BSParams
|
|
$entries = @($p['InputTxt'] -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
|
$res = Resolve-DevicesForAction -InputType $p['InputType'] -Entries $entries
|
|
$pf = [string]$p['Platform']
|
|
$inc = [System.Collections.Generic.List[hashtable]]::new()
|
|
$exc = [System.Collections.Generic.List[string]]::new()
|
|
foreach ($u in @($res['Unresolved'])) { $exc.Add([string]$u) }
|
|
foreach ($d in @($res['Devices'])) {
|
|
$osn = [string]$d['OS']
|
|
$match = switch ($pf) {
|
|
'(All platforms)' { $true }
|
|
'Windows' { ($osn -like 'Windows*') }
|
|
'Android' { ($osn -like 'Android*') }
|
|
'iOS/iPadOS' { ($osn -like 'iOS*' -or $osn -like 'iPad*' -or $osn -eq 'iPhone') }
|
|
'macOS' { ($osn -like 'mac*' -or $osn -like 'Mac*') }
|
|
default { $true }
|
|
}
|
|
if ($match) { $inc.Add($d) }
|
|
else { $exc.Add(("{0} [{1}] - skipped: platform filter ({2})" -f [string]$d['DeviceName'],$osn,$pf)) }
|
|
}
|
|
$script:BSResolve = @{ Included = @($inc); Excluded = @($exc) }
|
|
Write-VerboseLog ("Bulk Sync: " + $inc.Count + " device(s) match, " + $exc.Count + " excluded/unresolved") -Level Info
|
|
} -Done {
|
|
$script:Window.FindName('PnlBSProgress').Visibility = 'Collapsed'
|
|
$rr = $script:BSResolve
|
|
$inc = @(); $exc = @()
|
|
if ($rr) {
|
|
if ($null -ne $rr['Included']) { $inc = @($rr['Included']) }
|
|
if ($null -ne $rr['Excluded']) { $exc = @($rr['Excluded']) }
|
|
}
|
|
if ($inc.Count -eq 0) { Show-Notification -Message 'No devices resolved for the selected filter. Nothing to sync.' -BgColor '#FFF3CD' -FgColor '#856404'; return }
|
|
$ok = Show-DeviceActionConfirm -Title 'Bulk Sync' -ActionLabel ('Send sync to ' + $inc.Count + ' device(s)') -Devices $inc -Excluded $exc
|
|
if (-not $ok) { Write-VerboseLog 'Bulk Sync cancelled by user.' -Level Warning; return }
|
|
$script:Window.FindName('PnlBSProgress').Visibility = 'Visible'
|
|
$script:Window.FindName('TxtBSProgressMsg').Text = 'Sending sync...'
|
|
$btn2 = $script:Window.FindName('BtnBSRun')
|
|
Invoke-OnBackground -DisableButton $btn2 -BusyMessage '--- Bulk Sync: executing ---' -Work {
|
|
$rr = $script:BSResolve
|
|
$rows = [System.Collections.Generic.List[object]]::new()
|
|
$okN = 0; $failN = 0
|
|
foreach ($d in @($rr['Included'])) {
|
|
$iid = [string]$d['IntuneId']; $st = 'Sync sent'; $er = ''
|
|
try {
|
|
Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$iid/syncDevice" | Out-Null
|
|
$okN++
|
|
} catch {
|
|
$st = 'Failed'; $failN++
|
|
$er = [string]$_.Exception.Message
|
|
try { if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $er = [string]$_.ErrorDetails.Message } } catch {}
|
|
}
|
|
$resTxt = if ($er) { "$st - $er" } else { $st }
|
|
$rows.Add([PSCustomObject]@{ DeviceName = [string]$d['DeviceName']; OS = [string]$d['OS']; Ownership = [string]$d['Ownership']; UPN = [string]$d['UPN']; Result = $resTxt })
|
|
}
|
|
$script:BSExec = @{ Rows = @($rows); Ok = $okN; Fail = $failN }
|
|
Write-VerboseLog ("Bulk Sync: sent $okN, failed $failN") -Level Info
|
|
} -Done {
|
|
$script:Window.FindName('PnlBSProgress').Visibility = 'Collapsed'
|
|
$ex = $script:BSExec
|
|
$rows = @(); if ($ex -and $null -ne $ex['Rows']) { $rows = @($ex['Rows']) }
|
|
$lst = [System.Collections.Generic.List[PSCustomObject]]::new(); foreach ($r in $rows) { $lst.Add($r) }
|
|
$dg = $script:Window.FindName('DgBSResults'); $dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($lst)
|
|
$okN = if ($ex) { [int]$ex['Ok'] } else { 0 }; $failN = if ($ex) { [int]$ex['Fail'] } else { 0 }
|
|
$script:Window.FindName('TxtBSCount').Text = ("$okN sync sent, $failN failed of $($rows.Count) device(s)")
|
|
$script:Window.FindName('PnlBSResults').Visibility = 'Visible'
|
|
$bg = if ($failN -gt 0) { '#FFF3CD' } else { '#D4EDDA' }; $fg = if ($failN -gt 0) { '#856404' } else { '#155724' }
|
|
Show-Notification -Message ("Bulk Sync complete: $okN sent, $failN failed") -BgColor $bg -FgColor $fg
|
|
}
|
|
}
|
|
})
|
|
|
|
# ============================================================
|
|
# END Run Remediation + Bulk Sync (V0.82)
|
|
# ============================================================
|
|
|
|
# ── 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) } }
|
|
$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"
|
|
$txtLines = [System.Collections.Generic.List[string]]::new()
|
|
$txtLines.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)
|
|
}
|
|
$txtLines.Add($vals -join "`t")
|
|
}
|
|
[System.Windows.Clipboard]::SetText($txtLines -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)
|
|
$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"
|
|
$body = foreach ($r in $src) {
|
|
$vals = [System.Collections.Generic.List[string]]::new()
|
|
foreach ($cd in $colDefs) {
|
|
$v = ''
|
|
if ($cd.P -ne '') { $v = [string]$r.($cd.P) }
|
|
$vals.Add($v)
|
|
}
|
|
$vals -join "`t"
|
|
}
|
|
[System.Windows.Clipboard]::SetText(($header, ($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 }
|
|
|
|
$dlg = [Microsoft.Win32.SaveFileDialog]::new()
|
|
$dlg.Title = "Export Policy Assignments"
|
|
$dlg.Filter = "Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*"
|
|
$dlg.FileName = "GPAExport_$(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 }
|
|
$colDefs = [System.Collections.Generic.List[hashtable]]::new()
|
|
$dgExp = $script:Window.FindName('DgGPAResults')
|
|
foreach ($col in @($dgExp.Columns)) {
|
|
$bc = $col -as [System.Windows.Controls.DataGridBoundColumn]
|
|
$bcPath = ''
|
|
if ($bc) { $bcPath = $bc.Binding.Path.Path }
|
|
$colDefs.Add(@{ H = [string]$col.Header; P = $bcPath })
|
|
}
|
|
$exportRows = foreach ($r in $src) {
|
|
$ht = [ordered]@{}
|
|
foreach ($cd in $colDefs) { $ht[$cd['H']] = if ($cd['P'] -ne '') { [string]$r.($cd['P']) } else { '' } }
|
|
[PSCustomObject]$ht
|
|
}
|
|
if (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue) {
|
|
Import-Module ImportExcel -ErrorAction SilentlyContinue
|
|
$exportRows | Export-Excel -Path $outPath -WorksheetName "PolicyInfo" -AutoSize -FreezeTopRow -BoldTopRow -TableStyle Medium2
|
|
Show-Notification "Exported $($src.Count) rows: $(Split-Path $outPath -Leaf)" -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 "GPA: Exported $($src.Count) rows to: $outPath" -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 "'","''"
|
|
$safe = $safe -replace '%','%25'
|
|
$safe = $safe -replace '\+','%2B'
|
|
$safe = $safe -replace '#','%23'
|
|
$safe = $safe -replace '&','%26'
|
|
$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 "'","''"
|
|
$safe = $safe -replace '%','%25'
|
|
$safe = $safe -replace '\+','%2B'
|
|
$safe = $safe -replace '#','%23'
|
|
$safe = $safe -replace '&','%26'
|
|
$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 {
|
|
# $search fallback for special chars like /
|
|
$sEnc = [Uri]::EscapeDataString($entry)
|
|
$sUri = 'https://graph.microsoft.com/v1.0/groups?$search=%22displayName:' + $sEnc + '%22&$select=id,displayName&$top=50&$count=true'
|
|
$sHdr = @{ 'ConsistencyLevel' = 'eventual' }
|
|
$sResp = Invoke-MgGraphRequest -Method GET -Uri $sUri -Headers $sHdr
|
|
$sFound = $false
|
|
if ($sResp['value']) {
|
|
foreach ($c in $sResp['value']) {
|
|
if ($c['displayName'].Trim() -eq $entry.Trim()) {
|
|
$objId = $c['id']; $label = $c['displayName']; $sFound = $true; break
|
|
}
|
|
}
|
|
}
|
|
if (-not $sFound) { 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 "'","''"
|
|
$safe = $safe -replace '%','%25'
|
|
$safe = $safe -replace '\+','%2B'
|
|
$safe = $safe -replace '#','%23'
|
|
$safe = $safe -replace '&','%26'
|
|
$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 "'","''"
|
|
$safe = $safe -replace '%','%25'
|
|
$safe = $safe -replace '\+','%2B'
|
|
$safe = $safe -replace '#','%23'
|
|
$safe = $safe -replace '&','%26'
|
|
$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 {
|
|
# $search fallback for special chars like /
|
|
$sEnc = [Uri]::EscapeDataString($entry)
|
|
$sUri = 'https://graph.microsoft.com/v1.0/groups?$search=%22displayName:' + $sEnc + '%22&$select=id,displayName&$top=50&$count=true'
|
|
$sHdr = @{ 'ConsistencyLevel' = 'eventual' }
|
|
$sResp = Invoke-MgGraphRequest -Method GET -Uri $sUri -Headers $sHdr
|
|
$sFound = $false
|
|
if ($sResp['value']) {
|
|
foreach ($c in $sResp['value']) {
|
|
if ($c['displayName'].Trim() -eq $entry.Trim()) {
|
|
$objId = $c['id']; $label = $c['displayName']; $sFound = $true; break
|
|
}
|
|
}
|
|
}
|
|
if (-not $sFound) { 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('TxtGDIManufacturer').Text = ''
|
|
$script:Window.FindName('TxtGDIModel').Text = ''
|
|
$script:Window.FindName('CmbGDICompliance').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 App Info
|
|
$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('ChkDADiscoveredApps').IsChecked = $true
|
|
$script:Window.FindName('ChkDAManagedApps').IsChecked = $true
|
|
$script:Window.FindName('BtnDARun').IsEnabled = $true
|
|
$script:Window.FindName('BtnDARunAll').IsEnabled = $true
|
|
$script:Window.FindName('BtnDAGetManagedAssignments').IsEnabled = $true
|
|
$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; $dg.Columns.Clear() }
|
|
Show-Panel 'PanelGetDiscoveredApps'
|
|
Hide-Notification
|
|
Write-VerboseLog 'Panel: Get App Assignments' -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 APP INFO - 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
|
|
ShowDiscovered = $script:Window.FindName('ChkDADiscoveredApps').IsChecked -eq $true
|
|
ShowManaged = $script:Window.FindName('ChkDAManagedApps').IsChecked -eq $true
|
|
}
|
|
|
|
# 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('BtnDAGetManagedAssignments').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 App Assignments: 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']
|
|
$showDiscovered = $p['ShowDiscovered']
|
|
$showManaged = $p['ShowManaged']
|
|
$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 "'","''"
|
|
$safe = $safe -replace '%','%25'
|
|
$safe = $safe -replace '\+','%2B'
|
|
$safe = $safe -replace '#','%23'
|
|
$safe = $safe -replace '&','%26'
|
|
$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 "'","''"
|
|
$safe = $safe -replace '%','%25'
|
|
$safe = $safe -replace '\+','%2B'
|
|
$safe = $safe -replace '#','%23'
|
|
$safe = $safe -replace '&','%26'
|
|
$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']
|
|
} else {
|
|
# $search fallback for special chars like /
|
|
$sEnc = [Uri]::EscapeDataString($safe)
|
|
$sUri = 'https://graph.microsoft.com/v1.0/groups?$search=%22displayName:' + $sEnc + '%22&$select=id,displayName&$top=50&$count=true'
|
|
$sHdr = @{ 'ConsistencyLevel' = 'eventual' }
|
|
$sResp = Invoke-MgGraphRequest -Method GET -Uri $sUri -Headers $sHdr
|
|
if ($sResp['value']) {
|
|
foreach ($c in $sResp['value']) {
|
|
if ($c['displayName'].Trim() -eq $safe.Trim()) { $groupId = [string]$c['id']; break }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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 apps per device ──
|
|
$allRows = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
|
|
# Intent and state mappings for managed apps
|
|
$intentMap = @{
|
|
'available' = 'Available'
|
|
'notAvailable' = 'Not Available'
|
|
'requiredInstall' = 'Required Install'
|
|
'requiredUninstall' = 'Required Uninstall'
|
|
'requiredAndAvailableInstall' = 'Required and Available Install'
|
|
'availableInstallWithoutEnrollment' = 'Available (No Enrollment)'
|
|
'exclude' = 'Exclude'
|
|
}
|
|
$stateMap = @{
|
|
'installed' = 'Installed'
|
|
'failed' = 'Failed'
|
|
'notInstalled' = 'Not Installed'
|
|
'uninstallFailed'= 'Uninstall Failed'
|
|
'pendingInstall' = 'Pending Install'
|
|
'unknown' = 'Unknown'
|
|
'notApplicable' = 'Not Applicable'
|
|
}
|
|
|
|
# User ID cache (UPN -> GUID) to avoid redundant lookups
|
|
$userIdCache = @{}
|
|
|
|
# App description cache (applicationId -> description) to avoid redundant lookups
|
|
$appDescCache = @{}
|
|
|
|
$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'
|
|
|
|
$collectTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
|
|
|
# ── 3A: Discovered Apps (detectedApps API - per device) ──
|
|
if ($showDiscovered) {
|
|
try {
|
|
$appsUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($rec['IntuneId'])/detectedApps?`$select=displayName,version,platform,publisher"
|
|
$apps = Get-GraphAll $appsUri
|
|
DLog " Discovered: $($apps.Count) app(s)" 'Info'
|
|
|
|
foreach ($app in $apps) {
|
|
$appName = [string]$app['displayName']
|
|
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']
|
|
DeviceOwnership = [string]$rec['Ownership']
|
|
AppName = $appName
|
|
AppVersion = [string]$app['version']
|
|
AppPublisher = [string]$app['publisher']
|
|
ResolvedIntent = ''
|
|
InstallationStatus = ''
|
|
AppDescription = ''
|
|
AppSource = 'Discovered'
|
|
CollectedAt = $collectTime
|
|
})
|
|
}
|
|
} catch {
|
|
DLog " Discovered apps fetch failed for $($rec['DeviceName']): $($_.Exception.Message)" 'Warning'
|
|
}
|
|
}
|
|
|
|
# ── 3B: Managed Apps (via mobileAppIntentAndStates direct query per device) ──
|
|
if ($showManaged) {
|
|
$upn = [string]$rec['UPN']
|
|
$intuneId = [string]$rec['IntuneId']
|
|
|
|
if ([string]::IsNullOrWhiteSpace($upn)) {
|
|
DLog " Managed: skipped (no primary user UPN)" 'Warning'
|
|
} else {
|
|
try {
|
|
# Resolve UPN to user GUID (cached)
|
|
if (-not $userIdCache.ContainsKey($upn)) {
|
|
$encUpn = [Uri]::EscapeDataString($upn)
|
|
$userResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users/${encUpn}?`$select=id"
|
|
$userIdCache[$upn] = [string]$userResp['id']
|
|
}
|
|
$userId = $userIdCache[$upn]
|
|
|
|
# Direct query: get all managed apps + intent + state for this device
|
|
$intentUri = "https://graph.microsoft.com/beta/users/${userId}/mobileAppIntentAndStates/${intuneId}"
|
|
$intentResp = Invoke-MgGraphRequest -Method GET -Uri $intentUri
|
|
$mobileAppList = @()
|
|
if ($intentResp.ContainsKey('mobileAppList')) {
|
|
$mobileAppList = @($intentResp['mobileAppList'])
|
|
}
|
|
DLog " Managed: $($mobileAppList.Count) app(s)" 'Info'
|
|
|
|
foreach ($mApp in $mobileAppList) {
|
|
$appName = [string]$mApp['displayName']
|
|
if (-not [string]::IsNullOrWhiteSpace($keyword)) {
|
|
if ($appName -notlike "*$keyword*") { continue }
|
|
}
|
|
# Fetch app description (cached)
|
|
$appId = [string]$mApp['applicationId']
|
|
if ($appId -and -not $appDescCache.ContainsKey($appId)) {
|
|
try {
|
|
$appDetail = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/${appId}?`$select=description"
|
|
$appDescCache[$appId] = [string]$appDetail['description']
|
|
} catch {
|
|
$appDescCache[$appId] = ''
|
|
}
|
|
}
|
|
$appDesc = if ($appId -and $appDescCache.ContainsKey($appId)) { $appDescCache[$appId] } else { '' }
|
|
|
|
$rawIntent = [string]$mApp['mobileAppIntent']
|
|
$rawState = [string]$mApp['installState']
|
|
$friendlyIntent = if ($intentMap.ContainsKey($rawIntent)) { $intentMap[$rawIntent] } else { $rawIntent }
|
|
$friendlyState = if ($stateMap.ContainsKey($rawState)) { $stateMap[$rawState] } else { $rawState }
|
|
|
|
$allRows.Add([PSCustomObject]@{
|
|
DeviceName = [string]$rec['DeviceName']
|
|
EntraDeviceId = [string]$rec['EntraDeviceId']
|
|
PrimaryUserUPN = [string]$rec['UPN']
|
|
OSVersion = [string]$rec['OSVersion']
|
|
DeviceOwnership = [string]$rec['Ownership']
|
|
AppName = $appName
|
|
AppVersion = [string]$mApp['displayVersion']
|
|
AppPublisher = ''
|
|
ResolvedIntent = $friendlyIntent
|
|
InstallationStatus = $friendlyState
|
|
AppDescription = $appDesc
|
|
AppSource = 'Managed'
|
|
CollectedAt = $collectTime
|
|
})
|
|
}
|
|
} catch {
|
|
DLog " Managed apps 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 App Info: $($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('BtnDAGetManagedAssignments').IsEnabled = $true
|
|
$script:Window.FindName('PnlDAProgress').Visibility = 'Collapsed'
|
|
|
|
$errMsg = $entry['Error']
|
|
if ($errMsg) {
|
|
Write-VerboseLog "Get App Info 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 App Info: 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.Columns.Clear()
|
|
foreach ($prop in ($rows[0].PSObject.Properties)) {
|
|
$col = [System.Windows.Controls.DataGridTextColumn]::new()
|
|
$col.Header = $prop.Name
|
|
$col.Binding = [System.Windows.Data.Binding]::new($prop.Name)
|
|
if ($prop.Name -in @('AppDescription','Description')) {
|
|
$col.MaxWidth = 250
|
|
$col.Width = [System.Windows.Controls.DataGridLength]::new(200)
|
|
$style = [System.Windows.Style]::new([System.Windows.Controls.TextBlock])
|
|
$style.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.TextBlock]::TextWrappingProperty, [System.Windows.TextWrapping]::NoWrap))
|
|
$style.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.TextBlock]::TextTrimmingProperty, [System.Windows.TextTrimming]::CharacterEllipsis))
|
|
$col.ElementStyle = $style
|
|
}
|
|
$dg.Columns.Add($col)
|
|
}
|
|
$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 App Info: $($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 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
|
|
ShowDiscovered = $script:Window.FindName('ChkDADiscoveredApps').IsChecked -eq $true
|
|
ShowManaged = $script:Window.FindName('ChkDAManagedApps').IsChecked -eq $true
|
|
}
|
|
|
|
# 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('BtnDAGetManagedAssignments').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 App Assignments (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']
|
|
$showDiscovered = $p['ShowDiscovered']
|
|
$showManaged = $p['ShowManaged']
|
|
|
|
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
|
|
}
|
|
|
|
|
|
|
|
|
|
# ── STEP 3: Fetch apps per device ──
|
|
$allRows = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
|
|
# Intent and state mappings for managed apps
|
|
$intentMap = @{
|
|
'available' = 'Available'
|
|
'notAvailable' = 'Not Available'
|
|
'requiredInstall' = 'Required Install'
|
|
'requiredUninstall' = 'Required Uninstall'
|
|
'requiredAndAvailableInstall' = 'Required and Available Install'
|
|
'availableInstallWithoutEnrollment' = 'Available (No Enrollment)'
|
|
'exclude' = 'Exclude'
|
|
}
|
|
$stateMap = @{
|
|
'installed' = 'Installed'
|
|
'failed' = 'Failed'
|
|
'notInstalled' = 'Not Installed'
|
|
'uninstallFailed'= 'Uninstall Failed'
|
|
'pendingInstall' = 'Pending Install'
|
|
'unknown' = 'Unknown'
|
|
'notApplicable' = 'Not Applicable'
|
|
}
|
|
|
|
# User ID cache (UPN -> GUID) to avoid redundant lookups
|
|
$userIdCache = @{}
|
|
|
|
# App description cache (applicationId -> description) to avoid redundant lookups
|
|
$appDescCache = @{}
|
|
|
|
$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'
|
|
|
|
$collectTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
|
|
|
# ── 3A: Discovered Apps (detectedApps API - per device) ──
|
|
if ($showDiscovered) {
|
|
try {
|
|
$appsUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($rec['IntuneId'])/detectedApps?`$select=displayName,version,platform,publisher"
|
|
$apps = Get-GraphAll $appsUri
|
|
DLog " Discovered: $($apps.Count) app(s)" 'Info'
|
|
|
|
foreach ($app in $apps) {
|
|
$appName = [string]$app['displayName']
|
|
$allRows.Add([PSCustomObject]@{
|
|
DeviceName = [string]$rec['DeviceName']
|
|
EntraDeviceId = [string]$rec['EntraDeviceId']
|
|
PrimaryUserUPN = [string]$rec['UPN']
|
|
OSVersion = [string]$rec['OSVersion']
|
|
DeviceOwnership = [string]$rec['Ownership']
|
|
AppName = $appName
|
|
AppVersion = [string]$app['version']
|
|
AppPublisher = [string]$app['publisher']
|
|
ResolvedIntent = ''
|
|
InstallationStatus = ''
|
|
AppDescription = ''
|
|
AppSource = 'Discovered'
|
|
CollectedAt = $collectTime
|
|
})
|
|
}
|
|
} catch {
|
|
DLog " Discovered apps fetch failed for $($rec['DeviceName']): $($_.Exception.Message)" 'Warning'
|
|
}
|
|
}
|
|
|
|
# ── 3B: Managed Apps (via mobileAppIntentAndStates direct query per device) ──
|
|
if ($showManaged) {
|
|
$upn = [string]$rec['UPN']
|
|
$intuneId = [string]$rec['IntuneId']
|
|
|
|
if ([string]::IsNullOrWhiteSpace($upn)) {
|
|
DLog " Managed: skipped (no primary user UPN)" 'Warning'
|
|
} else {
|
|
try {
|
|
# Resolve UPN to user GUID (cached)
|
|
if (-not $userIdCache.ContainsKey($upn)) {
|
|
$encUpn = [Uri]::EscapeDataString($upn)
|
|
$userResp = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users/${encUpn}?`$select=id"
|
|
$userIdCache[$upn] = [string]$userResp['id']
|
|
}
|
|
$userId = $userIdCache[$upn]
|
|
|
|
# Direct query: get all managed apps + intent + state for this device
|
|
$intentUri = "https://graph.microsoft.com/beta/users/${userId}/mobileAppIntentAndStates/${intuneId}"
|
|
$intentResp = Invoke-MgGraphRequest -Method GET -Uri $intentUri
|
|
$mobileAppList = @()
|
|
if ($intentResp.ContainsKey('mobileAppList')) {
|
|
$mobileAppList = @($intentResp['mobileAppList'])
|
|
}
|
|
DLog " Managed: $($mobileAppList.Count) app(s)" 'Info'
|
|
|
|
foreach ($mApp in $mobileAppList) {
|
|
$appName = [string]$mApp['displayName']
|
|
$rawIntent = [string]$mApp['mobileAppIntent']
|
|
# Fetch app description (cached)
|
|
$appId = [string]$mApp['applicationId']
|
|
if ($appId -and -not $appDescCache.ContainsKey($appId)) {
|
|
try {
|
|
$appDetail = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/${appId}?`$select=description"
|
|
$appDescCache[$appId] = [string]$appDetail['description']
|
|
} catch {
|
|
$appDescCache[$appId] = ''
|
|
}
|
|
}
|
|
$appDesc = if ($appId -and $appDescCache.ContainsKey($appId)) { $appDescCache[$appId] } else { '' }
|
|
|
|
$rawState = [string]$mApp['installState']
|
|
$friendlyIntent = if ($intentMap.ContainsKey($rawIntent)) { $intentMap[$rawIntent] } else { $rawIntent }
|
|
$friendlyState = if ($stateMap.ContainsKey($rawState)) { $stateMap[$rawState] } else { $rawState }
|
|
|
|
$allRows.Add([PSCustomObject]@{
|
|
DeviceName = [string]$rec['DeviceName']
|
|
EntraDeviceId = [string]$rec['EntraDeviceId']
|
|
PrimaryUserUPN = [string]$rec['UPN']
|
|
OSVersion = [string]$rec['OSVersion']
|
|
DeviceOwnership = [string]$rec['Ownership']
|
|
AppName = $appName
|
|
AppVersion = [string]$mApp['displayVersion']
|
|
AppPublisher = ''
|
|
ResolvedIntent = $friendlyIntent
|
|
InstallationStatus = $friendlyState
|
|
AppDescription = $appDesc
|
|
AppSource = 'Managed'
|
|
CollectedAt = $collectTime
|
|
})
|
|
}
|
|
} catch {
|
|
DLog " Managed apps 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 App Info (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('BtnDAGetManagedAssignments').IsEnabled = $true
|
|
$script:Window.FindName('PnlDAProgress').Visibility = 'Collapsed'
|
|
|
|
$errMsg = $entry['Error']
|
|
if ($errMsg) {
|
|
Write-VerboseLog "Get App Info (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 App Info (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.Columns.Clear()
|
|
foreach ($prop in ($rows[0].PSObject.Properties)) {
|
|
$col = [System.Windows.Controls.DataGridTextColumn]::new()
|
|
$col.Header = $prop.Name
|
|
$col.Binding = [System.Windows.Data.Binding]::new($prop.Name)
|
|
if ($prop.Name -in @('AppDescription','Description')) {
|
|
$col.MaxWidth = 250
|
|
$col.Width = [System.Windows.Controls.DataGridLength]::new(200)
|
|
$style = [System.Windows.Style]::new([System.Windows.Controls.TextBlock])
|
|
$style.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.TextBlock]::TextWrappingProperty, [System.Windows.TextWrapping]::NoWrap))
|
|
$style.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.TextBlock]::TextTrimmingProperty, [System.Windows.TextTrimming]::CharacterEllipsis))
|
|
$col.ElementStyle = $style
|
|
}
|
|
$dg.Columns.Add($col)
|
|
}
|
|
$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 App Info (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()
|
|
})
|
|
|
|
|
|
# -- BtnDAGetManagedAssignments: Tenant-wide Managed App Assignments --
|
|
$script:Window.FindName('BtnDAGetManagedAssignments').Add_Click({
|
|
Hide-Notification
|
|
|
|
# Respect OS Platform checkboxes and App Keyword filter
|
|
$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') }
|
|
|
|
$script:DAParams = @{
|
|
ManagedAssignments = $true
|
|
PlatformFilter = $platFilter
|
|
Keyword = [string]$script:Window.FindName('TxtDAKeyword').Text
|
|
}
|
|
|
|
# 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 managed app assignments...'
|
|
$script:Window.FindName('TxtDAProgressDetail').Text = ''
|
|
|
|
$script:Window.FindName('BtnDARun').IsEnabled = $false
|
|
$script:Window.FindName('BtnDARunAll').IsEnabled = $false
|
|
$script:Window.FindName('BtnDAGetManagedAssignments').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 '--- All Managed App Assignments: 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
|
|
}
|
|
|
|
$typeMap = @{
|
|
'#microsoft.graph.win32LobApp' = 'Win32 LOB'
|
|
'#microsoft.graph.windowsMobileMSI' = 'Windows MSI'
|
|
'#microsoft.graph.microsoftStoreForBusinessApp'= 'MS Store for Business'
|
|
'#microsoft.graph.winGetApp' = 'WinGet'
|
|
'#microsoft.graph.officeSuiteApp' = 'Microsoft 365 Apps'
|
|
'#microsoft.graph.windowsWebApp' = 'Windows Web App'
|
|
'#microsoft.graph.windowsUniversalAppX' = 'Windows AppX/MSIX'
|
|
'#microsoft.graph.windowsStoreApp' = 'Windows Store App'
|
|
'#microsoft.graph.webApp' = 'Web Link'
|
|
'#microsoft.graph.iosVppApp' = 'iOS VPP'
|
|
'#microsoft.graph.iosStoreApp' = 'iOS Store'
|
|
'#microsoft.graph.iosLobApp' = 'iOS LOB'
|
|
'#microsoft.graph.managedIOSStoreApp' = 'Managed iOS Store'
|
|
'#microsoft.graph.managedIOSLobApp' = 'Managed iOS LOB'
|
|
'#microsoft.graph.managedAndroidStoreApp' = 'Managed Android Store'
|
|
'#microsoft.graph.managedAndroidLobApp' = 'Managed Android LOB'
|
|
'#microsoft.graph.androidStoreApp' = 'Android Store'
|
|
'#microsoft.graph.androidLobApp' = 'Android LOB'
|
|
'#microsoft.graph.androidManagedStoreApp' = 'Android Enterprise'
|
|
'#microsoft.graph.androidForWorkApp' = 'Android for Work'
|
|
'#microsoft.graph.macOSLobApp' = 'macOS LOB'
|
|
'#microsoft.graph.macOSDmgApp' = 'macOS DMG'
|
|
'#microsoft.graph.macOSPkgApp' = 'macOS PKG'
|
|
'#microsoft.graph.macOSMicrosoftEdgeApp' = 'macOS Edge'
|
|
'#microsoft.graph.macOSMicrosoftDefenderApp' = 'macOS Defender'
|
|
'#microsoft.graph.macOSOfficeSuiteApp' = 'macOS Office Suite'
|
|
'#microsoft.graph.macOSWebClip' = 'macOS Web Clip'
|
|
}
|
|
$platMap = @{
|
|
'#microsoft.graph.win32LobApp'='Windows';'#microsoft.graph.windowsMobileMSI'='Windows';'#microsoft.graph.microsoftStoreForBusinessApp'='Windows'
|
|
'#microsoft.graph.winGetApp'='Windows';'#microsoft.graph.officeSuiteApp'='Windows';'#microsoft.graph.windowsWebApp'='Windows'
|
|
'#microsoft.graph.windowsUniversalAppX'='Windows';'#microsoft.graph.windowsStoreApp'='Windows';'#microsoft.graph.webApp'='Cross-platform'
|
|
'#microsoft.graph.iosVppApp'='iOS';'#microsoft.graph.iosStoreApp'='iOS';'#microsoft.graph.iosLobApp'='iOS'
|
|
'#microsoft.graph.managedIOSStoreApp'='iOS';'#microsoft.graph.managedIOSLobApp'='iOS'
|
|
'#microsoft.graph.managedAndroidStoreApp'='Android';'#microsoft.graph.managedAndroidLobApp'='Android'
|
|
'#microsoft.graph.androidStoreApp'='Android';'#microsoft.graph.androidLobApp'='Android'
|
|
'#microsoft.graph.androidManagedStoreApp'='Android';'#microsoft.graph.androidForWorkApp'='Android'
|
|
'#microsoft.graph.macOSLobApp'='macOS';'#microsoft.graph.macOSDmgApp'='macOS';'#microsoft.graph.macOSPkgApp'='macOS'
|
|
'#microsoft.graph.macOSMicrosoftEdgeApp'='macOS';'#microsoft.graph.macOSMicrosoftDefenderApp'='macOS'
|
|
'#microsoft.graph.macOSOfficeSuiteApp'='macOS';'#microsoft.graph.macOSWebClip'='macOS'
|
|
}
|
|
|
|
try {
|
|
$p = $DaParams
|
|
$platFilter = $p['PlatformFilter']
|
|
$keyword = [string]$p['Keyword']
|
|
$groupCache = @{}
|
|
$filterCache = @{}
|
|
|
|
function Resolve-GroupName([string]$GroupId) {
|
|
if ($groupCache.ContainsKey($GroupId)) { return $groupCache[$GroupId] }
|
|
try {
|
|
$gr = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId`?`$select=displayName"
|
|
$gn = [string]$gr['displayName']; $groupCache[$GroupId] = $gn; return $gn
|
|
} catch { $groupCache[$GroupId] = $GroupId; return $GroupId }
|
|
}
|
|
|
|
function Resolve-FilterName([string]$FilterId) {
|
|
if (-not $FilterId -or $FilterId -eq '00000000-0000-0000-0000-000000000000') { return '' }
|
|
if ($filterCache.ContainsKey($FilterId)) { return $filterCache[$FilterId] }
|
|
try {
|
|
$fr = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/deviceManagement/assignmentFilters/$FilterId`?`$select=displayName"
|
|
$fn = [string]$fr['displayName']; $filterCache[$FilterId] = $fn; return $fn
|
|
} catch { $filterCache[$FilterId] = $FilterId; return $FilterId }
|
|
}
|
|
|
|
# -- STEP 1: Fetch all managed apps --
|
|
DLog "Step 1/3: Fetching managed apps from Intune..." 'Action'
|
|
$DaQueue.Enqueue(@{ Type='progress'; Msg="Step 1/3: Fetching managed apps..."; Detail='' })
|
|
$mobileApps = Get-GraphAll "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$select=id,displayName,publisher,description,createdDateTime,lastModifiedDateTime&`$top=999"
|
|
DLog "Step 1 complete: $($mobileApps.Count) managed app(s)" 'Info'
|
|
|
|
if ($mobileApps.Count -eq 0) {
|
|
$DaQueue.Enqueue(@{ Type='done'; Rows=@(); Error='No managed apps found in Intune.' })
|
|
return
|
|
}
|
|
|
|
# -- STEP 2: Apply platform filter --
|
|
$DaQueue.Enqueue(@{ Type='progress'; Msg="Step 2/3: Filtering by platform..."; Detail='' })
|
|
$filteredApps = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($app in $mobileApps) {
|
|
$odataType = [string]$app['@odata.type']
|
|
$appPlat = if ($platMap.ContainsKey($odataType)) { $platMap[$odataType] } else { 'Unknown' }
|
|
if ($platFilter.Count -gt 0 -and $appPlat -ne 'Cross-platform' -and $appPlat -notin $platFilter) { continue }
|
|
if (-not [string]::IsNullOrWhiteSpace($keyword)) { if ([string]$app['displayName'] -notlike "*$keyword*") { continue } }
|
|
$filteredApps.Add($app)
|
|
}
|
|
DLog "Step 2 complete: $($filteredApps.Count) app(s) after platform/keyword filter" 'Info'
|
|
|
|
if ($filteredApps.Count -eq 0) {
|
|
$DaQueue.Enqueue(@{ Type='done'; Rows=@(); Error='No managed apps matched the platform/keyword filter.' })
|
|
return
|
|
}
|
|
|
|
# -- STEP 3: Fetch assignments per app --
|
|
$allRows = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
$appIdx = 0
|
|
|
|
foreach ($app in $filteredApps) {
|
|
if ($DaStop['Stop']) { DLog 'Stop requested - halting.' 'Warning'; break }
|
|
$appIdx++
|
|
$appId = [string]$app['id']
|
|
$appName = [string]$app['displayName']
|
|
$odataType = [string]$app['@odata.type']
|
|
$appType = if ($typeMap.ContainsKey($odataType)) { $typeMap[$odataType] } else { $odataType -replace '^#microsoft\.graph\.','' }
|
|
$appPlat = if ($platMap.ContainsKey($odataType)) { $platMap[$odataType] } else { 'Unknown' }
|
|
$appPub = [string]$app['publisher']
|
|
$appDesc = [string]$app['description']
|
|
$appVersion = [string]$app['displayVersion']
|
|
$appCreated = if ($app['createdDateTime']) { ([DateTime]$app['createdDateTime']).ToString('yyyy-MM-dd HH:mm') } else { '' }
|
|
$appModified = if ($app['lastModifiedDateTime']) { ([DateTime]$app['lastModifiedDateTime']).ToString('yyyy-MM-dd HH:mm') } else { '' }
|
|
|
|
if ($appIdx % 25 -eq 1 -or $appIdx -eq $filteredApps.Count) {
|
|
$DaQueue.Enqueue(@{ Type='progress'; Msg="Step 3/3: Fetching assignments ($appIdx of $($filteredApps.Count))..."; Detail=$appName })
|
|
}
|
|
|
|
try {
|
|
$assignUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$appId/assignments"
|
|
$assignResp = Invoke-MgGraphRequest -Method GET -Uri $assignUri
|
|
$assignments = @($assignResp['value'])
|
|
|
|
if ($assignments.Count -eq 0) {
|
|
$allRows.Add([PSCustomObject]@{
|
|
AppName = $appName
|
|
AppType = $appType
|
|
Platform = $appPlat
|
|
Publisher = $appPub
|
|
AppVersion = $appVersion
|
|
Description = $appDesc
|
|
DateCreated = $appCreated
|
|
DateModified = $appModified
|
|
Intent = '(Unassigned)'
|
|
IncludedGroup = ''
|
|
ExcludedGroup = ''
|
|
IncludeFilter = ''
|
|
ExcludeFilter = ''
|
|
})
|
|
continue
|
|
}
|
|
|
|
foreach ($asn in $assignments) {
|
|
$rawIntent = [string]$asn['intent']
|
|
$intent = switch ($rawIntent) {
|
|
'available' { 'Available' }
|
|
'required' { 'Required' }
|
|
'uninstall' { 'Uninstall' }
|
|
'availableWithoutEnrollment' { 'Available (No Enrollment)' }
|
|
default { $rawIntent }
|
|
}
|
|
|
|
$target = $asn['target']
|
|
if (-not $target) { continue }
|
|
$otype = [string]$target['@odata.type']
|
|
$isExclude = $otype -match 'exclusionGroup'
|
|
|
|
$groupId = [string]$target['groupId']
|
|
$targetName = ''
|
|
if ($groupId) {
|
|
$targetName = Resolve-GroupName -GroupId $groupId
|
|
} elseif ($otype -match 'allDevices') {
|
|
$targetName = '[All Devices]'
|
|
} elseif ($otype -match 'allLicensedUsers|allUsers') {
|
|
$targetName = '[All Users]'
|
|
}
|
|
|
|
$filterId = [string]$target['deviceAndAppManagementAssignmentFilterId']
|
|
$filterType = [string]$target['deviceAndAppManagementAssignmentFilterType']
|
|
$filterName = Resolve-FilterName -FilterId $filterId
|
|
$inclFilter = if ($filterType -eq 'include') { $filterName } else { '' }
|
|
$exclFilter = if ($filterType -eq 'exclude') { $filterName } else { '' }
|
|
|
|
$allRows.Add([PSCustomObject]@{
|
|
AppName = $appName
|
|
AppType = $appType
|
|
Platform = $appPlat
|
|
Publisher = $appPub
|
|
AppVersion = $appVersion
|
|
Description = $appDesc
|
|
DateCreated = $appCreated
|
|
DateModified = $appModified
|
|
Intent = $intent
|
|
IncludedGroup = if (-not $isExclude) { $targetName } else { '' }
|
|
ExcludedGroup = if ($isExclude) { $targetName } else { '' }
|
|
IncludeFilter = $inclFilter
|
|
ExcludeFilter = $exclFilter
|
|
})
|
|
}
|
|
} catch {
|
|
DLog " Assignment fetch failed for ${appName}: $($_.Exception.Message)" 'Warning'
|
|
}
|
|
}
|
|
|
|
DLog "Completed: $($allRows.Count) assignment record(s) across $($filteredApps.Count) app(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 "Managed App Assignments: $($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('BtnDAGetManagedAssignments').IsEnabled = $true
|
|
$script:Window.FindName('PnlDAProgress').Visibility = 'Collapsed'
|
|
|
|
$errMsg = $entry['Error']
|
|
if ($errMsg) {
|
|
Write-VerboseLog "Managed App Assignments 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 'Managed App Assignments: no results.' -Level Warning
|
|
$script:Window.FindName('TxtDANoResults').Visibility = 'Visible'
|
|
Show-Notification 'No managed app assignments found.' -BgColor '#FFF3CD' -FgColor '#7A4800'
|
|
Update-StatusBar -Text 'Connected'
|
|
return
|
|
}
|
|
|
|
$dg = $script:Window.FindName('DgDAResults')
|
|
$dg.Columns.Clear()
|
|
foreach ($prop in ($rows[0].PSObject.Properties)) {
|
|
$col = [System.Windows.Controls.DataGridTextColumn]::new()
|
|
$col.Header = $prop.Name
|
|
$col.Binding = [System.Windows.Data.Binding]::new($prop.Name)
|
|
if ($prop.Name -in @('AppDescription','Description')) {
|
|
$col.MaxWidth = 250
|
|
$col.Width = [System.Windows.Controls.DataGridLength]::new(200)
|
|
$style = [System.Windows.Style]::new([System.Windows.Controls.TextBlock])
|
|
$style.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.TextBlock]::TextWrappingProperty, [System.Windows.TextWrapping]::NoWrap))
|
|
$style.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.TextBlock]::TextTrimmingProperty, [System.Windows.TextTrimming]::CharacterEllipsis))
|
|
$col.ElementStyle = $style
|
|
}
|
|
$dg.Columns.Add($col)
|
|
}
|
|
$dg.ItemsSource = [System.Collections.ObjectModel.ObservableCollection[PSCustomObject]]::new($rows)
|
|
$script:Window.FindName('TxtDACount').Text = "$($rows.Count) managed app assignment(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 "Managed App Assignments: $($rows.Count) record(s) loaded." -Level Success
|
|
Show-Notification "$($rows.Count) managed app assignment(s) ready." -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({
|
|
# Determine export context from DataGrid content
|
|
$dg = $script:Window.FindName('DgDAResults')
|
|
$firstItem = ($dg.ItemsSource | Select-Object -First 1)
|
|
$hasManagedCols = ($null -ne $firstItem) -and ($firstItem.PSObject.Properties.Name -contains 'AssignmentIntent')
|
|
$exportPrefix = if ($hasManagedCols) { 'ManagedAppAssignments' } else { 'AppAssignments' }
|
|
$wsName = if ($hasManagedCols) { 'ManagedAssignments' } else { 'AppAssignments' }
|
|
$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 App Results'
|
|
$dlg.Filter = 'Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*'
|
|
$dlg.FileName = "${exportPrefix}_$(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 $wsName -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' }
|
|
}
|
|
|
|
$mfgFilter = $script:Window.FindName('TxtGDIManufacturer').Text.Trim()
|
|
$modelFilter = $script:Window.FindName('TxtGDIModel').Text.Trim()
|
|
$complianceSel = $script:Window.FindName('CmbGDICompliance').SelectedItem.Content
|
|
$complianceFilter = switch ($complianceSel) {
|
|
'Compliant' { 'compliant' }
|
|
'Non-Compliant' { 'noncompliant' }
|
|
default { 'All' }
|
|
}
|
|
|
|
$script:GDIParams = @{
|
|
InputTxt = $inputTxt
|
|
InputType = $inputTypeSel
|
|
PlatformFilter = $platFilter
|
|
OwnershipFilter = $ownershipFilter
|
|
ManufacturerFilter = $mfgFilter
|
|
ModelFilter = $modelFilter
|
|
ComplianceFilter = $complianceFilter
|
|
}
|
|
|
|
# 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']
|
|
$mfgFilterVal = $params['ManufacturerFilter']
|
|
$modelFilterVal = $params['ModelFilter']
|
|
$compFilterVal = $params['ComplianceFilter']
|
|
$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'
|
|
GLog "Manufacturer filter: $(if ($mfgFilterVal) { $mfgFilterVal } else { 'All' })" 'Info'
|
|
GLog "Model filter: $(if ($modelFilterVal) { $modelFilterVal } else { 'All' })" 'Info'
|
|
GLog "Compliance filter: $compFilterVal" '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'
|
|
GLog "Manufacturer filter: $(if ($mfgFilterVal) { $mfgFilterVal } else { 'All' })" 'Info'
|
|
GLog "Model filter: $(if ($modelFilterVal) { $modelFilterVal } else { 'All' })" 'Info'
|
|
GLog "Compliance filter: $compFilterVal" '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 "'","''"
|
|
$safe = $safe -replace '%','%25'
|
|
$safe = $safe -replace '\+','%2B'
|
|
$safe = $safe -replace '#','%23'
|
|
$safe = $safe -replace '&','%26'
|
|
$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 "'","''"
|
|
$safe = $safe -replace '%','%25'
|
|
$safe = $safe -replace '\+','%2B'
|
|
$safe = $safe -replace '#','%23'
|
|
$safe = $safe -replace '&','%26'
|
|
$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 "'","''"
|
|
$safe = $safe -replace '%','%25'
|
|
$safe = $safe -replace '\+','%2B'
|
|
$safe = $safe -replace '#','%23'
|
|
$safe = $safe -replace '&','%26'
|
|
$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']
|
|
} else {
|
|
# $search fallback for special chars like /
|
|
$sEnc = [Uri]::EscapeDataString($safe)
|
|
$sUri = 'https://graph.microsoft.com/v1.0/groups?$search=%22displayName:' + $sEnc + '%22&$select=id,displayName&$top=50&$count=true'
|
|
$sHdr = @{ 'ConsistencyLevel' = 'eventual' }
|
|
$sResp = Invoke-MgGraphRequest -Method GET -Uri $sUri -Headers $sHdr
|
|
if ($sResp['value']) {
|
|
foreach ($c in $sResp['value']) {
|
|
if ($c['displayName'].Trim() -eq $safe.Trim()) { $groupId = [string]$c['id']; break }
|
|
}
|
|
}
|
|
}
|
|
} 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) {
|
|
$GdiQueue.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'
|
|
}
|
|
}
|
|
|
|
# ── Manufacturer filter ──
|
|
if ($mfgFilterVal -ne '' -and $intuneDev) {
|
|
$devMfg = [string]$intuneDev['manufacturer']
|
|
if ($devMfg -notmatch [regex]::Escape($mfgFilterVal)) {
|
|
GLog " Skipped (manufacturer '$devMfg' does not match '$mfgFilterVal')" 'Info'
|
|
continue
|
|
}
|
|
}
|
|
|
|
# ── Model filter ──
|
|
if ($modelFilterVal -ne '' -and $intuneDev) {
|
|
$devModel = [string]$intuneDev['model']
|
|
if ($devModel -notmatch [regex]::Escape($modelFilterVal)) {
|
|
GLog " Skipped (model '$devModel' does not match '$modelFilterVal')" 'Info'
|
|
continue
|
|
}
|
|
}
|
|
|
|
# ── Compliance filter ──
|
|
if ($compFilterVal -ne 'All' -and $intuneDev) {
|
|
$devComp = [string]$intuneDev['complianceState']
|
|
if ($devComp -ne $compFilterVal) {
|
|
GLog " Skipped (compliance '$devComp' not matching '$compFilterVal')" 'Info'
|
|
continue
|
|
}
|
|
}
|
|
|
|
# ── 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
|
|
|
|
# ── Pre-flight: prompt user for output file (CSV) ──
|
|
$msgResult = [System.Windows.MessageBox]::Show(
|
|
'Output cannot be displayed in the tool due to the volume of data generated. Please select a path to save the output file.',
|
|
'Large Dataset Export',
|
|
[System.Windows.MessageBoxButton]::OKCancel,
|
|
[System.Windows.MessageBoxImage]::Information)
|
|
if ($msgResult -ne [System.Windows.MessageBoxResult]::OK) {
|
|
Write-VerboseLog 'Get All Device Info: user cancelled at pre-flight prompt.' -Level Info
|
|
return
|
|
}
|
|
$sfd = [Microsoft.Win32.SaveFileDialog]::new()
|
|
$sfd.Title = 'Save Device Info Export'
|
|
$sfd.Filter = 'CSV File (*.csv)|*.csv'
|
|
$sfd.FileName = "DeviceInfo_All_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
|
|
$initDir = try { if ($script:ExportOutPath) { Split-Path $script:ExportOutPath -Parent } else { $null } } catch { $null }
|
|
$sfd.InitialDirectory = if ($initDir -and (Test-Path $initDir)) { $initDir } else { [Environment]::GetFolderPath('Desktop') }
|
|
if ($sfd.ShowDialog() -ne $true) {
|
|
Write-VerboseLog 'Get All Device Info: user cancelled file selection.' -Level Info
|
|
return
|
|
}
|
|
$script:GDIExportPath = $sfd.FileName
|
|
$script:ExportOutPath = $sfd.FileName # remember for next time
|
|
Write-VerboseLog "Get All Device Info: export target = $script:GDIExportPath" -Level Action
|
|
|
|
|
|
# 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' }
|
|
}
|
|
|
|
$mfgFilter = $script:Window.FindName('TxtGDIManufacturer').Text.Trim()
|
|
$modelFilter = $script:Window.FindName('TxtGDIModel').Text.Trim()
|
|
$complianceSel = $script:Window.FindName('CmbGDICompliance').SelectedItem.Content
|
|
$complianceFilter = switch ($complianceSel) {
|
|
'Compliant' { 'compliant' }
|
|
'Non-Compliant' { 'noncompliant' }
|
|
default { 'All' }
|
|
}
|
|
|
|
$script:GDIParams = @{
|
|
AllDevices = $true
|
|
PlatformFilter = $platFilter
|
|
OwnershipFilter = $ownershipFilter
|
|
ManufacturerFilter = $mfgFilter
|
|
ModelFilter = $modelFilter
|
|
ComplianceFilter = $complianceFilter
|
|
ExportPath = $script:GDIExportPath
|
|
}
|
|
|
|
# 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({
|
|
# -- Buffered file log (open once, flush periodically) --
|
|
# V0.73: replaces per-call Add-Content (which opened/closed file handle every line).
|
|
# Author: Satish Singhi
|
|
$script:GdiLogWriter = $null
|
|
$script:GdiLogLineCount = 0
|
|
if ($LogFile) {
|
|
try {
|
|
$script:GdiLogWriter = [System.IO.StreamWriter]::new($LogFile, $true, [System.Text.UTF8Encoding]::new($false))
|
|
$script:GdiLogWriter.AutoFlush = $false
|
|
} catch { $script:GdiLogWriter = $null }
|
|
}
|
|
# -- GLog: enqueue + buffered file write. -Quiet skips RichTextBox enqueue (large-batch flows) --
|
|
# V0.73: $Quiet=$true bypasses GdiQueue enqueue to keep RichTextBox FlowDocument small.
|
|
# Per-device messages in BtnGDIRunAll use Quiet=$true; only summaries/errors enqueue.
|
|
function GLog {
|
|
param([string]$Msg, [string]$Level = 'Info', [switch]$Quiet)
|
|
$prefix = switch ($Level) {
|
|
"Success" { "$([char]0x2713) " } "Warning" { "[!] " } "Error" { "$([char]0x2717) " }
|
|
'Action' { '> ' } default { ' ' }
|
|
}
|
|
$line = "[$(Get-Date -Format 'HH:mm:ss')] $prefix$Msg"
|
|
# Errors and warnings ALWAYS enqueue to RichTextBox; routine logs respect $Quiet
|
|
if (-not $Quiet -or $Level -eq 'Error' -or $Level -eq 'Warning') {
|
|
$GdiQueue.Enqueue(@{ Type='log'; Line=$line; Level=$Level })
|
|
}
|
|
# Buffered file write (always)
|
|
if ($script:GdiLogWriter) {
|
|
try {
|
|
$script:GdiLogWriter.WriteLine($line)
|
|
$script:GdiLogLineCount++
|
|
if (($script:GdiLogLineCount % 1000) -eq 0) { $script:GdiLogWriter.Flush() }
|
|
} 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']
|
|
$mfgFilterVal = $params['ManufacturerFilter']
|
|
$modelFilterVal = $params['ModelFilter']
|
|
$compFilterVal = $params['ComplianceFilter']
|
|
$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'
|
|
GLog "Manufacturer filter: $(if ($mfgFilterVal) { $mfgFilterVal } else { 'All' })" 'Info'
|
|
GLog "Model filter: $(if ($modelFilterVal) { $modelFilterVal } else { 'All' })" 'Info'
|
|
GLog "Compliance filter: $compFilterVal" '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'
|
|
GLog "Manufacturer filter: $(if ($mfgFilterVal) { $mfgFilterVal } else { 'All' })" 'Info'
|
|
GLog "Model filter: $(if ($modelFilterVal) { $modelFilterVal } else { 'All' })" 'Info'
|
|
GLog "Compliance filter: $compFilterVal" '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 "'","''"
|
|
$safe = $safe -replace '%','%25'
|
|
$safe = $safe -replace '\+','%2B'
|
|
$safe = $safe -replace '#','%23'
|
|
$safe = $safe -replace '&','%26'
|
|
$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 "'","''"
|
|
$safe = $safe -replace '%','%25'
|
|
$safe = $safe -replace '\+','%2B'
|
|
$safe = $safe -replace '#','%23'
|
|
$safe = $safe -replace '&','%26'
|
|
$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 "'","''"
|
|
$safe = $safe -replace '%','%25'
|
|
$safe = $safe -replace '\+','%2B'
|
|
$safe = $safe -replace '#','%23'
|
|
$safe = $safe -replace '&','%26'
|
|
$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']
|
|
} else {
|
|
# $search fallback for special chars like /
|
|
$sEnc = [Uri]::EscapeDataString($safe)
|
|
$sUri = 'https://graph.microsoft.com/v1.0/groups?$search=%22displayName:' + $sEnc + '%22&$select=id,displayName&$top=50&$count=true'
|
|
$sHdr = @{ 'ConsistencyLevel' = 'eventual' }
|
|
$sResp = Invoke-MgGraphRequest -Method GET -Uri $sUri -Headers $sHdr
|
|
if ($sResp['value']) {
|
|
foreach ($c in $sResp['value']) {
|
|
if ($c['displayName'].Trim() -eq $safe.Trim()) { $groupId = [string]$c['id']; break }
|
|
}
|
|
}
|
|
}
|
|
} 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) {
|
|
$GdiQueue.Enqueue(@{ Type='done'; Rows=@(); Error='No devices resolved from the provided input.' })
|
|
return
|
|
}
|
|
|
|
# ── Process each Entra device ──
|
|
# ── Streaming CSV export setup ──
|
|
$exportPath = [string]$params['ExportPath']
|
|
$csvWriter = $null
|
|
$csvHeader = 'Entra_DeviceName,DeviceType,Entra_DeviceID,Entra_ObjectID,Intune_DeviceID,Entra_JoinType,Entra_Ownership,Intune_Ownership,Entra_OSPlatform,Intune_OSPlatform,Entra_OSVersion,Intune_OSVersion,Windows_Release,Entra_DeviceEnabled,Entra_LastSignIn,Entra_ActivityRange,Intune_LastCheckIn,Intune_ActivityRange,Intune_OEM,Intune_Model,Intune_SerialNumber,Intune_EnrolledDate,Intune_ComplianceState,Entra_DeviceOwner,Entra_PrimaryUserUPN,Entra_UserAccountEnabled,Entra_UserCountry,Entra_UserCity,Entra_UserJobTitle,Autopilot_GroupTag,Autopilot_ProfileStatus'
|
|
$csvProps = @('Entra_DeviceName','DeviceType','Entra_DeviceID','Entra_ObjectID','Intune_DeviceID','Entra_JoinType','Entra_Ownership','Intune_Ownership','Entra_OSPlatform','Intune_OSPlatform','Entra_OSVersion','Intune_OSVersion','Windows_Release','Entra_DeviceEnabled','Entra_LastSignIn','Entra_ActivityRange','Intune_LastCheckIn','Intune_ActivityRange','Intune_OEM','Intune_Model','Intune_SerialNumber','Intune_EnrolledDate','Intune_ComplianceState','Entra_DeviceOwner','Entra_PrimaryUserUPN','Entra_UserAccountEnabled','Entra_UserCountry','Entra_UserCity','Entra_UserJobTitle','Autopilot_GroupTag','Autopilot_ProfileStatus')
|
|
# CSV-escape helper: wrap in quotes if value contains comma/quote/newline, double internal quotes
|
|
function Csv-Escape([object]$v) {
|
|
if ($null -eq $v) { return '' }
|
|
$s = [string]$v
|
|
if ($s -match '[,"`r`n]') { return '"' + $s.Replace('"', '""') + '"' }
|
|
return $s
|
|
}
|
|
try {
|
|
$outDir = Split-Path $exportPath -Parent
|
|
if ($outDir -and -not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }
|
|
$csvWriter = [System.IO.StreamWriter]::new($exportPath, $false, [System.Text.UTF8Encoding]::new($false))
|
|
$csvWriter.WriteLine($csvHeader)
|
|
GLog "CSV writer opened: $exportPath" 'Success'
|
|
} catch {
|
|
$GdiQueue.Enqueue(@{ Type='done'; Rows=@(); Error="Failed to open export file: $($_.Exception.Message)"; ExportPath=$exportPath; RowCount=0 })
|
|
return
|
|
}
|
|
$processed = 0
|
|
$written = 0
|
|
$startTime = [datetime]::UtcNow
|
|
$lastUiTick = [datetime]::UtcNow
|
|
$totalPlanned = $deviceObjIds.Count
|
|
|
|
foreach ($objId in $deviceObjIds) {
|
|
if ($GdiStop['Stop']) { GLog 'Stop requested - halting.' 'Warning'; break }
|
|
if ($entraIdSet.Contains($objId)) { continue } # deduplicate
|
|
$null = $entraIdSet.Add($objId)
|
|
|
|
$processed++
|
|
# V0.73: Summary checkpoint every 500 devices - bypasses -Quiet so user sees periodic progress in log
|
|
if (($processed % 500) -eq 0) {
|
|
GLog ("Checkpoint: {0:N0} devices processed, {1:N0} rows written to CSV" -f $processed, $written) 'Info'
|
|
}
|
|
# ── Throttled UI progress update ──
|
|
$nowTick = [datetime]::UtcNow
|
|
$throttleInterval = if ($processed -le 50) { 10 } else { 100 }
|
|
$elapsedSinceTick = ($nowTick - $lastUiTick).TotalSeconds
|
|
if (($processed % $throttleInterval) -eq 0 -or $elapsedSinceTick -ge 2.0 -or $processed -eq 1) {
|
|
$elapsedTotal = ($nowTick - $startTime).TotalSeconds
|
|
$etaMinutes = $null
|
|
if ($processed -gt 50 -and $elapsedTotal -gt 0) {
|
|
$avgSec = $elapsedTotal / $processed
|
|
$remaining = $totalPlanned - $processed
|
|
if ($remaining -gt 0) {
|
|
$etaMinutes = [int][Math]::Ceiling(($avgSec * $remaining) / 60.0)
|
|
} else { $etaMinutes = 0 }
|
|
}
|
|
$GdiQueue.Enqueue(@{
|
|
Type = 'progress'
|
|
Current = $processed
|
|
Total = $totalPlanned
|
|
Written = $written
|
|
Remaining = [Math]::Max(0, $totalPlanned - $processed)
|
|
ETAMin = $etaMinutes
|
|
})
|
|
$lastUiTick = $nowTick
|
|
}
|
|
|
|
# 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' -Quiet
|
|
continue
|
|
}
|
|
|
|
$devName = [string]$entraRaw['displayName']
|
|
$entraDevId = [string]$entraRaw['deviceId'] # azureADDeviceId in Intune
|
|
$entraOS = Normalize-OS ([string]$entraRaw['operatingSystem'])
|
|
|
|
GLog "[$processed] $devName [$entraOS]" 'Info' -Quiet
|
|
|
|
# ── Platform filter ──
|
|
if ($platFilter.Count -gt 0 -and $entraOS -notin $platFilter) {
|
|
GLog " Skipped (platform $entraOS not in filter)" 'Info' -Quiet
|
|
continue
|
|
}
|
|
|
|
# ── Ownership filter ──
|
|
$rawOwner = [string]$entraRaw['deviceOwnership']
|
|
if ($ownerFilter -ne 'All' -and $rawOwner -ne $ownerFilter) {
|
|
GLog " Skipped (ownership $rawOwner not in filter)" 'Info' -Quiet
|
|
continue
|
|
}
|
|
|
|
# ── Intune lookup ──
|
|
$intuneDev = Get-IntuneByEntraId -EntraId $entraDevId -Cache $intuneCache
|
|
if ($intuneDev) {
|
|
GLog " Intune: $([string]$intuneDev['id'])" 'Success' -Quiet
|
|
} else {
|
|
GLog " Intune: No Intune Record" 'Warning' -Quiet
|
|
}
|
|
|
|
# ── 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' -Quiet
|
|
if ($userCache.ContainsKey($uid)) {
|
|
$userInfo = $userCache[$uid]
|
|
} else {
|
|
$userInfo = Get-UserInfo -UserId $uid
|
|
$userCache[$uid] = $userInfo
|
|
}
|
|
} else {
|
|
GLog " No registered owner found" 'Info' -Quiet
|
|
}
|
|
} catch {
|
|
GLog " Owner fetch error: $($_.Exception.Message)" 'Warning' -Quiet
|
|
}
|
|
|
|
# ── 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' -Quiet
|
|
} else {
|
|
GLog ' Autopilot: not registered' 'Info' -Quiet
|
|
}
|
|
}
|
|
|
|
# ── Manufacturer filter ──
|
|
if ($mfgFilterVal -ne '' -and $intuneDev) {
|
|
$devMfg = [string]$intuneDev['manufacturer']
|
|
if ($devMfg -notmatch [regex]::Escape($mfgFilterVal)) {
|
|
GLog " Skipped (manufacturer '$devMfg' does not match '$mfgFilterVal')" 'Info' -Quiet
|
|
continue
|
|
}
|
|
}
|
|
|
|
# ── Model filter ──
|
|
if ($modelFilterVal -ne '' -and $intuneDev) {
|
|
$devModel = [string]$intuneDev['model']
|
|
if ($devModel -notmatch [regex]::Escape($modelFilterVal)) {
|
|
GLog " Skipped (model '$devModel' does not match '$modelFilterVal')" 'Info' -Quiet
|
|
continue
|
|
}
|
|
}
|
|
|
|
# ── Compliance filter ──
|
|
if ($compFilterVal -ne 'All' -and $intuneDev) {
|
|
$devComp = [string]$intuneDev['complianceState']
|
|
if ($devComp -ne $compFilterVal) {
|
|
GLog " Skipped (compliance '$devComp' not matching '$compFilterVal')" 'Info' -Quiet
|
|
continue
|
|
}
|
|
}
|
|
|
|
# ── Build row ──
|
|
$row = Build-Row -EntraDev $entraRaw -IntuneDev $intuneDev -UserInfo $userInfo -OwnerUPN $ownerUPN -AutopilotInfo $autopilotInfo
|
|
# ── Stream row to CSV ──
|
|
try {
|
|
$vals = foreach ($p in $csvProps) { Csv-Escape $row.$p }
|
|
$csvWriter.WriteLine($vals -join ',')
|
|
$written++
|
|
if (($written % 1000) -eq 0) { $csvWriter.Flush() }
|
|
} catch {
|
|
GLog " CSV write error at row $processed : $($_.Exception.Message)" 'Error' -Quiet
|
|
}
|
|
}
|
|
|
|
GLog "Processed $processed device(s). Wrote $written row(s) to CSV." 'Success'
|
|
try { if ($csvWriter) { $csvWriter.Flush(); $csvWriter.Dispose() } } catch {}
|
|
try { if ($script:GdiLogWriter) { $script:GdiLogWriter.Flush(); $script:GdiLogWriter.Dispose(); $script:GdiLogWriter=$null } } catch {}
|
|
$GdiQueue.Enqueue(@{ Type='done'; Rows=@(); Error=$null; ExportPath=$exportPath; RowCount=$written; Processed=$processed; Total=$totalPlanned })
|
|
|
|
} catch {
|
|
try { if ($script:GdiLogWriter) { $script:GdiLogWriter.Flush(); $script:GdiLogWriter.Dispose(); $script:GdiLogWriter=$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
|
|
# V0.73: Hard-cap drain at 25 entries per tick. Prevents UI thread from being trapped in
|
|
# unbounded dequeue loop when producer floods queue. Dispatcher gets to pump other messages.
|
|
$drainCount = 0
|
|
while ($drainCount -lt 25 -and $script:GdiQueue.TryDequeue([ref]$entry)) {
|
|
$drainCount++
|
|
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)
|
|
# V0.73: Cap FlowDocument at 500 paragraphs to prevent layout cost growth and freeze
|
|
if ($script:RtbLog.Document.Blocks.Count -gt 500) {
|
|
$script:RtbLog.Document.Blocks.Remove($script:RtbLog.Document.Blocks.FirstBlock)
|
|
}
|
|
$script:RtbLog.ScrollToEnd()
|
|
} catch {}
|
|
}
|
|
'progress' {
|
|
$cur = $entry['Current']; $tot = $entry['Total']
|
|
$rem = $entry['Remaining']; $eta = $entry['ETAMin']; $wrt = $entry['Written']
|
|
$script:Window.FindName('PnlGDIProgress').Visibility = 'Visible'
|
|
$script:Window.FindName('TxtGDIProgressMsg').Text = 'Fetching device data... (writing to CSV)'
|
|
$script:Window.FindName('TxtGDIMetricTotal').Text = '{0:N0}' -f [int]$tot
|
|
$script:Window.FindName('TxtGDIMetricProcessed').Text = '{0:N0}' -f [int]$cur
|
|
$script:Window.FindName('TxtGDIMetricRemaining').Text = '{0:N0}' -f [int]$rem
|
|
if ($null -eq $eta) {
|
|
$script:Window.FindName('TxtGDIMetricETA').Text = 'Calculating...'
|
|
} else {
|
|
$script:Window.FindName('TxtGDIMetricETA').Text = if ([int]$eta -eq 1) { '1 minute' } else { ('{0:N0} minutes' -f [int]$eta) }
|
|
}
|
|
if ([int]$tot -gt 0) {
|
|
$pct = [Math]::Min(100, [Math]::Round((100.0 * [int]$cur / [int]$tot), 1))
|
|
$script:Window.FindName('PbGDIProgress').Value = $pct
|
|
$script:Window.FindName('TxtGDIProgressDetail').Text = ('Rows written to CSV: {0:N0}' -f [int]$wrt)
|
|
}
|
|
Update-StatusBar -Text ("Get Device Info: {0:N0} / {1:N0} devices..." -f [int]$cur, [int]$tot)
|
|
}
|
|
'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
|
|
}
|
|
|
|
|
|
|
|
# ── CSV export completion path ──
|
|
$exportPath = [string]$entry['ExportPath']
|
|
$rowCount = [int]$entry['RowCount']
|
|
$processed = [int]$entry['Processed']
|
|
$planned = [int]$entry['Total']
|
|
# Results panel stays collapsed - data is on disk, not in grid
|
|
$script:Window.FindName('PnlGDIResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('TxtGDINoResults').Visibility = 'Collapsed'
|
|
if ($rowCount -eq 0) {
|
|
Write-VerboseLog "Get All Device Info: no rows written to CSV (processed $processed)." -Level Warning
|
|
Show-Notification 'No devices matched the filters - empty CSV was created.' -BgColor '#FFF3CD' -FgColor '#7A4800'
|
|
Update-StatusBar -Text 'Connected'
|
|
return
|
|
}
|
|
$leaf = try { Split-Path $exportPath -Leaf } catch { $exportPath }
|
|
Write-VerboseLog ("Get All Device Info complete: {0:N0} row(s) written to {1}" -f $rowCount, $exportPath) -Level Success
|
|
Write-VerboseLog (" Devices processed: {0:N0} of {1:N0} planned" -f $processed, $planned) -Level Info
|
|
Show-Notification ('Export complete: {0:N0} row(s) -> {1}' -f $rowCount, $leaf) -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'
|
|
}
|
|
})
|
|
|
|
|
|
# -- Missing BitLocker Keys: Panel open -----------------------------------------------
|
|
$script:Window.FindName('BtnOpMissingBitlocker').Add_Click({
|
|
$script:Window.FindName('CmbMBKOwnership').SelectedIndex = 0
|
|
$script:Window.FindName('CmbMBKExclude').SelectedIndex = 0
|
|
$script:Window.FindName('CmbMBKEnrollment').SelectedIndex = 0
|
|
$script:Window.FindName('TxtMBKOlderDays').Text = ''
|
|
$script:Window.FindName('BtnMBKRunOlder').IsEnabled = $true
|
|
$script:Window.FindName('PnlMBKProgress').Visibility = 'Collapsed'
|
|
$script:Window.FindName('PnlMBKResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('TxtMBKNoResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('BtnMBKCopyValue').IsEnabled = $false
|
|
$script:Window.FindName('BtnMBKCopyRow').IsEnabled = $false
|
|
$script:Window.FindName('BtnMBKCopyAll').IsEnabled = $false
|
|
$script:Window.FindName('TxtMBKFilter').IsEnabled = $false
|
|
$script:Window.FindName('BtnMBKFilter').IsEnabled = $false
|
|
$script:Window.FindName('BtnMBKFilterClear').IsEnabled = $false
|
|
$script:Window.FindName('TxtMBKFilter').Text = ''
|
|
$script:MBKAllData = $null
|
|
$script:Window.FindName('BtnMBKExportXlsx').IsEnabled = $false
|
|
$dg = $script:Window.FindName('DgMBKResults')
|
|
if ($dg) { $dg.ItemsSource = $null }
|
|
Show-Panel 'PanelMissingBitlocker'
|
|
Hide-Notification
|
|
Write-VerboseLog 'Panel: Missing BitLocker Keys' -Level Info
|
|
})
|
|
|
|
# -- Missing BitLocker Keys: Run -------------------------------------------------------
|
|
$script:Window.FindName('BtnMBKRun').Add_Click({
|
|
$script:Window.FindName('BtnMBKRun').IsEnabled = $false
|
|
$script:Window.FindName('PnlMBKProgress').Visibility = 'Visible'
|
|
$script:Window.FindName('PnlMBKResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('TxtMBKNoResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('PbMBKProgress').IsIndeterminate = $true
|
|
$script:Window.FindName('TxtMBKProgressMsg').Text = 'Starting...'
|
|
$script:Window.FindName('TxtMBKProgressDetail').Text = ''
|
|
|
|
$ownerFilter = $script:Window.FindName('CmbMBKOwnership').SelectedItem.Content
|
|
$excludeFilter = $script:Window.FindName('CmbMBKExclude').SelectedItem.Content
|
|
$enrollFilter = $script:Window.FindName('CmbMBKEnrollment').SelectedItem.Content
|
|
# Default to MissingOnly; if BtnMBKRunOlder set pending state right before
|
|
# raising Click, honour those instead.
|
|
if ($script:mbkPendingMode) {
|
|
$mbkMode = $script:mbkPendingMode
|
|
$mbkDaysThreshold = $script:mbkPendingDays
|
|
$script:mbkActiveBtn = $script:Window.FindName('BtnMBKRunOlder')
|
|
$script:mbkPendingMode = $null
|
|
$script:mbkPendingDays = $null
|
|
} else {
|
|
$mbkMode = 'MissingOnly'
|
|
$mbkDaysThreshold = 0
|
|
$script:mbkActiveBtn = $script:Window.FindName('BtnMBKRun')
|
|
}
|
|
$script:mbkLastMode = $mbkMode
|
|
$script:mbkLastDays = $mbkDaysThreshold
|
|
$script:Window.FindName('BtnMBKRunOlder').IsEnabled = $false
|
|
|
|
$mbkQueue = [System.Collections.Concurrent.ConcurrentQueue[hashtable]]::new()
|
|
$mbkState = [hashtable]::Synchronized(@{ Done = $false; Error = $null; Total = 0; Processed = 0; Phase = 'Starting' })
|
|
|
|
$rs = [System.Management.Automation.PowerShell]::Create()
|
|
$rs.AddScript({
|
|
param($OwnerFilter, $ExcludeFilter, $EnrollFilter, $Mode, $DaysThreshold, $Queue, $State)
|
|
|
|
# Import the authentication module into this runspace so Invoke-MgGraphRequest
|
|
# picks up the existing Connect-MgGraph session (token + headers are process-wide).
|
|
Import-Module Microsoft.Graph.Authentication -ErrorAction SilentlyContinue
|
|
|
|
# ConsistencyLevel header is required for $count and certain $filter queries on directoryObjects.
|
|
# Invoke-MgGraphRequest reuses the existing Connect-MgGraph session token (no raw token handling).
|
|
$hdrs = @{ 'ConsistencyLevel' = 'eventual' }
|
|
# ── Activity range helper (matches Get-Device-Info exactly) ──
|
|
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' }
|
|
}
|
|
|
|
# ── Paged Graph GET via Invoke-MgGraphRequest (returns hashtables) ──
|
|
function Invoke-GraphGet {
|
|
param([string]$Url, [hashtable]$Hdrs = @{})
|
|
$allResults = [System.Collections.Generic.List[object]]::new()
|
|
$nextLink = $Url
|
|
while ($nextLink) {
|
|
$resp = Invoke-MgGraphRequest -Method GET -Uri $nextLink -Headers $Hdrs -ErrorAction Stop
|
|
if ($resp['value']) { foreach ($v in $resp['value']) { $allResults.Add($v) } }
|
|
$nextLink = if ($resp.ContainsKey('@odata.nextLink')) { $resp['@odata.nextLink'] } else { $null }
|
|
}
|
|
return $allResults
|
|
}
|
|
|
|
try {
|
|
$State.Phase = 'Fetching Windows devices from Entra...'
|
|
$devUrl = "https://graph.microsoft.com/v1.0/devices?`$filter=operatingSystem eq 'Windows'&`$select=id,deviceId,displayName,operatingSystem,operatingSystemVersion,accountEnabled,approximateLastSignInDateTime,trustType,deviceOwnership,registeredOwners,model&`$expand=registeredOwners(`$select=id,userPrincipalName)&`$top=999&`$count=true"
|
|
$entraDevices = Invoke-GraphGet -Url $devUrl -Hdrs $hdrs
|
|
$State.Phase = "Found $($entraDevices.Count) Windows devices."
|
|
|
|
# Apply OwnerFilter first to shrink the per-device-call working set.
|
|
$candidates = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($d in $entraDevices) { $candidates.Add($d) }
|
|
if ($OwnerFilter -eq 'Corporate only') { $candidates = [System.Collections.Generic.List[object]]($candidates | Where-Object { [string]$_.deviceOwnership -eq 'Company' }) }
|
|
elseif ($OwnerFilter -eq 'Personal only') { $candidates = [System.Collections.Generic.List[object]]($candidates | Where-Object { [string]$_.deviceOwnership -eq 'Personal' }) }
|
|
|
|
$State.Phase = "After ownership filter: $($candidates.Count) device(s). Fetching Intune records..."
|
|
|
|
$intuneUrl = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$select=id,deviceName,azureADDeviceId,operatingSystem,osVersion,lastSyncDateTime,manufacturer,model,serialNumber,managedDeviceOwnerType,enrolledDateTime,userPrincipalName,complianceState&`$top=999"
|
|
$intuneDevices = Invoke-GraphGet -Url $intuneUrl -Hdrs $hdrs
|
|
|
|
$intuneLookup = @{}
|
|
foreach ($md in $intuneDevices) { $adid = [string]$md.azureADDeviceId; if ($adid -and $adid -ne '00000000-0000-0000-0000-000000000000') { $intuneLookup[$adid] = $md } }
|
|
|
|
# Apply EnrollFilter against the (already ownership-filtered) candidates.
|
|
if ($EnrollFilter -eq 'Intune enrolled only') { $candidates = [System.Collections.Generic.List[object]]($candidates | Where-Object { $intuneLookup.ContainsKey([string]$_.deviceId) }) }
|
|
elseif ($EnrollFilter -eq 'Not enrolled only') { $candidates = [System.Collections.Generic.List[object]]($candidates | Where-Object { -not $intuneLookup.ContainsKey([string]$_.deviceId) }) }
|
|
|
|
# Apply ExcludeFilter (AVDs / Physical Devices). Resolution:
|
|
# Intune.model first, Entra device.model fallback, else '' (unknown -> Physical).
|
|
if ($ExcludeFilter -eq 'AVDs' -or $ExcludeFilter -eq 'Physical Devices') {
|
|
$VM = 'Virtual Machine'
|
|
$filtered = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($d in $candidates) {
|
|
$did = [string]$d.deviceId
|
|
$resolvedModel = ''
|
|
if ($intuneLookup.ContainsKey($did)) {
|
|
$im = [string]$intuneLookup[$did].model
|
|
if ($im -and $im.Trim()) { $resolvedModel = $im.Trim() }
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($resolvedModel)) {
|
|
$em = [string]$d.model
|
|
if ($em -and $em.Trim()) { $resolvedModel = $em.Trim() }
|
|
}
|
|
$isVm = ([string]::Equals($resolvedModel, $VM, [System.StringComparison]::OrdinalIgnoreCase))
|
|
if ($ExcludeFilter -eq 'AVDs') {
|
|
if (-not $isVm) { $filtered.Add($d) }
|
|
} else {
|
|
# Physical Devices: keep only VMs
|
|
if ($isVm) { $filtered.Add($d) }
|
|
}
|
|
}
|
|
$candidates = $filtered
|
|
$State.Phase = "After exclude filter ($ExcludeFilter): $($candidates.Count) device(s)."
|
|
}
|
|
|
|
$State.Total = $candidates.Count
|
|
$State.Phase = "Checking $($candidates.Count) device(s) for OS-volume BitLocker key (per-device, reliable)..."
|
|
|
|
# Per-device check via Microsoft Graph $batch (Patch 3 - parallel batching).
|
|
# Up to 20 child requests per HTTP round trip; server fans out internally.
|
|
# See https://learn.microsoft.com/graph/json-batching for protocol details.
|
|
$keyCreatedById = @{} # deviceId -> [datetime] earliest OS key createdDateTime
|
|
$keyAgeById = @{} # deviceId -> [int] age in whole days
|
|
$missingDevices = [System.Collections.Generic.List[object]]::new()
|
|
|
|
# Local helper: classify a single device's recoveryKeys response value[]
|
|
$classifyValueArr = {
|
|
param($did, $arr)
|
|
$hasOsKey = $false
|
|
$osKeyCreated = $null
|
|
if ($arr) {
|
|
foreach ($k in $arr) {
|
|
$vt = [string]$k['volumeType']
|
|
if ($vt -eq '1' -or $vt -eq 'operatingSystemVolume') {
|
|
$hasOsKey = $true
|
|
$cdRaw = [string]$k['createdDateTime']
|
|
if ($cdRaw) {
|
|
try {
|
|
$cd = [datetime]::Parse($cdRaw,
|
|
[System.Globalization.CultureInfo]::InvariantCulture,
|
|
[System.Globalization.DateTimeStyles]::RoundtripKind)
|
|
if ($null -eq $osKeyCreated -or $cd -lt $osKeyCreated) { $osKeyCreated = $cd }
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return @{ HasOsKey = $hasOsKey; OsKeyCreated = $osKeyCreated }
|
|
}
|
|
|
|
# Build (id, deviceId, deviceObj) triples - one per candidate.
|
|
$items = [System.Collections.Generic.List[object]]::new()
|
|
$seq = 0
|
|
foreach ($d in $candidates) {
|
|
$did = [string]$d.deviceId
|
|
if (-not $did) { continue }
|
|
$items.Add(@{ Id = [string]$seq; DeviceId = $did; Device = $d }); $seq++
|
|
}
|
|
|
|
$totalItems = $items.Count
|
|
$checked = 0
|
|
$batchSize = 20
|
|
$batchUri = 'https://graph.microsoft.com/v1.0/$batch'
|
|
|
|
# Helper: build the JSON envelope for a list of items.
|
|
$buildEnvelope = {
|
|
param($itemBatch)
|
|
$reqs = @()
|
|
foreach ($it in $itemBatch) {
|
|
$relUrl = "/informationProtection/bitlocker/recoveryKeys?`$filter=deviceId eq '$($it.DeviceId)'"
|
|
$reqs += [ordered]@{
|
|
id = $it.Id
|
|
method = 'GET'
|
|
url = $relUrl
|
|
}
|
|
}
|
|
return @{ requests = $reqs }
|
|
}
|
|
|
|
# Per-item classification dispatcher - called once we know the response.
|
|
$applyResult = {
|
|
param($did, $deviceObj, $resBody)
|
|
$arr = $null
|
|
if ($resBody -and $resBody['value']) { $arr = $resBody['value'] }
|
|
$cls = & $classifyValueArr $did $arr
|
|
|
|
if ($cls.HasOsKey -and $cls.OsKeyCreated) {
|
|
$keyCreatedById[$did] = $cls.OsKeyCreated
|
|
$ageDays = [int][Math]::Floor(([datetime]::UtcNow - $cls.OsKeyCreated.ToUniversalTime()).TotalDays)
|
|
if ($ageDays -lt 0) { $ageDays = 0 }
|
|
$keyAgeById[$did] = $ageDays
|
|
}
|
|
|
|
$include = $false
|
|
if ($Mode -eq 'OlderThanDays') {
|
|
if ($cls.HasOsKey -and $cls.OsKeyCreated) {
|
|
if ($keyAgeById[$did] -gt [int]$DaysThreshold) { $include = $true }
|
|
}
|
|
} else {
|
|
if (-not $cls.HasOsKey) { $include = $true }
|
|
}
|
|
if ($include) { $missingDevices.Add($deviceObj) }
|
|
}
|
|
|
|
# Walk through $items in groups of $batchSize.
|
|
for ($bi = 0; $bi -lt $items.Count; $bi += $batchSize) {
|
|
$end = [Math]::Min($bi + $batchSize - 1, $items.Count - 1)
|
|
$slice = $items[$bi..$end]
|
|
|
|
# Track pending children (initially all of $slice). Each retry pass
|
|
# narrows this to just the 429-throttled ones.
|
|
$pending = @($slice)
|
|
$retryCount = 0
|
|
$maxRetries = 3
|
|
|
|
while ($pending.Count -gt 0 -and $retryCount -le $maxRetries) {
|
|
$env = & $buildEnvelope $pending
|
|
$envJson = $env | ConvertTo-Json -Depth 10 -Compress
|
|
|
|
$batchResp = $null
|
|
try {
|
|
$batchResp = Invoke-MgGraphRequest -Method POST -Uri $batchUri `
|
|
-Headers $hdrs -ContentType 'application/json' `
|
|
-Body $envJson -ErrorAction Stop
|
|
} catch {
|
|
# Envelope failed entirely (malformed, auth gone, network blip).
|
|
# Fall back: log + skip the whole pending slice for this batch.
|
|
Write-VerboseLog " Batch POST failed: $($_.Exception.Message). Skipping $($pending.Count) device(s) in this batch." -Level Warning
|
|
$checked += $pending.Count
|
|
$State.Processed = $checked
|
|
$pending = @()
|
|
break
|
|
}
|
|
|
|
# Map response.id back to its pending item for O(1) lookup.
|
|
$pendingById = @{}
|
|
foreach ($p in $pending) { $pendingById[$p.Id] = $p }
|
|
|
|
$nextPending = [System.Collections.Generic.List[object]]::new()
|
|
$maxRetryAfter = 0
|
|
|
|
foreach ($r in @($batchResp['responses'])) {
|
|
$rid = [string]$r['id']
|
|
$status = [int]($r['status'])
|
|
$item = $pendingById[$rid]
|
|
if (-not $item) { continue } # stray id - skip defensively
|
|
|
|
if ($status -ge 200 -and $status -lt 300) {
|
|
& $applyResult $item.DeviceId $item.Device $r['body']
|
|
$checked++
|
|
} elseif ($status -eq 429) {
|
|
# Throttled - park for retry. Honour Retry-After header if present.
|
|
$hdr = $r['headers']
|
|
$ra = 0
|
|
if ($hdr) {
|
|
$raStr = [string]$hdr['Retry-After']
|
|
if ($raStr -and [int]::TryParse($raStr, [ref]$ra)) {} else { $ra = 0 }
|
|
}
|
|
if ($ra -le 0) { $ra = 5 } # default 5 sec
|
|
if ($ra -gt $maxRetryAfter) { $maxRetryAfter = $ra }
|
|
$nextPending.Add($item)
|
|
} else {
|
|
# Other 4xx/5xx - treat as per-device error, skip the device.
|
|
$msg = ''
|
|
try { $msg = [string]$r['body']['error']['message'] } catch {}
|
|
Write-VerboseLog " Skip $($item.DeviceId) - batch child status $status. $msg" -Level Warning
|
|
$checked++
|
|
}
|
|
}
|
|
|
|
# Update progress after each batch round trip (per-batch granularity).
|
|
$State.Processed = $checked
|
|
if ($Mode -eq 'OlderThanDays') {
|
|
$State.Phase = "Checked $checked of $totalItems. Old keys (> $DaysThreshold d) so far: $($missingDevices.Count)"
|
|
} else {
|
|
$State.Phase = "Checked $checked of $totalItems. Missing OS key so far: $($missingDevices.Count)"
|
|
}
|
|
|
|
if ($nextPending.Count -gt 0) {
|
|
$retryCount++
|
|
if ($retryCount -le $maxRetries) {
|
|
$sleep = $maxRetryAfter
|
|
# Exponential backoff cap: min(Retry-After * 2^(retry-1), 30 sec)
|
|
$sleep = [Math]::Min($sleep * [Math]::Pow(2, $retryCount - 1), 30)
|
|
if ($sleep -lt 1) { $sleep = 1 }
|
|
Write-VerboseLog " Batch retry $retryCount/$maxRetries - $($nextPending.Count) child(ren) throttled, sleeping $sleep sec..." -Level Warning
|
|
Start-Sleep -Seconds $sleep
|
|
$pending = @($nextPending)
|
|
} else {
|
|
# Exhausted retries - skip the remaining throttled children.
|
|
foreach ($p in $nextPending) {
|
|
Write-VerboseLog " Skip $($p.DeviceId) - still throttled after $maxRetries retries." -Level Warning
|
|
$checked++
|
|
}
|
|
$State.Processed = $checked
|
|
$pending = @()
|
|
}
|
|
} else {
|
|
$pending = @()
|
|
}
|
|
}
|
|
}
|
|
if ($Mode -eq 'OlderThanDays') {
|
|
$State.Phase = "Per-device check complete. $($missingDevices.Count) device(s) with OS key older than $DaysThreshold day(s)."
|
|
} else {
|
|
$State.Phase = "Per-device check complete. $($missingDevices.Count) device(s) missing OS drive key."
|
|
}
|
|
$State.Total = $missingDevices.Count
|
|
|
|
$ownerIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
|
foreach ($d in $missingDevices) { if ($d.registeredOwners) { foreach ($o in $d.registeredOwners) { if ($o.id) { [void]$ownerIds.Add([string]$o.id) } } } }
|
|
$userLookup = @{}
|
|
$ownerIdList = [string[]]$ownerIds
|
|
for ($bi = 0; $bi -lt $ownerIdList.Count; $bi += 20) {
|
|
$batch = $ownerIdList[$bi..([Math]::Min($bi + 19, $ownerIdList.Count - 1))]
|
|
$filterParts = ($batch | ForEach-Object { "id eq '$_'" }) -join ' or '
|
|
$uUrl = "https://graph.microsoft.com/v1.0/users?`$filter=$filterParts&`$select=id,userPrincipalName,accountEnabled,city,country,jobTitle&`$top=999"
|
|
try { $users = Invoke-GraphGet -Url $uUrl -Hdrs $hdrs; foreach ($u in $users) { $userLookup[[string]$u.id] = $u } } catch { }
|
|
}
|
|
|
|
$State.Phase = "Building result rows..."
|
|
$processed = 0
|
|
foreach ($d in $missingDevices) {
|
|
$devId = [string]$d.deviceId; $objId = [string]$d.id; $devName = [string]$d.displayName
|
|
$ownerUPN = $null; $userInfo = $null
|
|
if ($d.registeredOwners -and $d.registeredOwners.Count -gt 0) {
|
|
$ownerId = [string]$d.registeredOwners[0].id; $ownerUPN = [string]$d.registeredOwners[0].userPrincipalName
|
|
if ($userLookup.ContainsKey($ownerId)) { $userInfo = $userLookup[$ownerId] }
|
|
}
|
|
$intuneDev = if ($intuneLookup.ContainsKey($devId)) { $intuneLookup[$devId] } else { $null }
|
|
$NoRec = 'No Intune Record'
|
|
$entraJoin = switch ([string]$d.trustType) { 'AzureAd' { 'Entra Joined' } 'ServerAd' { 'Hybrid Joined' } 'Workplace' { 'Registered' } default { [string]$d.trustType } }
|
|
if ($intuneDev) {
|
|
$intuneId = [string]$intuneDev.id; $intuneOS = [string]$intuneDev.operatingSystem; $intuneOSVer = [string]$intuneDev.osVersion
|
|
$intuneSync = [string]$intuneDev.lastSyncDateTime
|
|
$intuneSyncFmt = if ($intuneSync) { try { ([datetime]$intuneSync).ToString('yyyy-MM-dd HH:mm') } catch { $intuneSync } } else { $NoRec }
|
|
$intuneActivity = if ($intuneSync) { $r = Get-ActivityRange $intuneSync; if ($r) { $r } else { '-' } } else { $NoRec }
|
|
$intuneOEM = [string]$intuneDev.manufacturer; $intuneModel = [string]$intuneDev.model; $intuneSerial = [string]$intuneDev.serialNumber
|
|
$intuneOwner = [string]$intuneDev.managedDeviceOwnerType
|
|
$intuneEnrolled = try { ([datetime][string]$intuneDev.enrolledDateTime).ToString('yyyy-MM-dd') } catch { '-' }
|
|
$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
|
|
}
|
|
$entraSignIn = [string]$d.approximateLastSignInDateTime
|
|
$entraSignInFmt = if ($entraSignIn) { try { ([datetime]$entraSignIn).ToString('yyyy-MM-dd HH:mm') } catch { $entraSignIn } } else { '-' }
|
|
$entraActivity = if ($entraSignIn) { $r = Get-ActivityRange $entraSignIn; if ($r) { $r } else { '-' } } else { '-' }
|
|
$winRelease = '-'
|
|
$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 }
|
|
# Per-key columns - filled when the key exists (OlderThanDays path),
|
|
# otherwise '-' (MissingOnly path - no OS key by definition).
|
|
$keyCreatedFmt = '-'
|
|
$keyAgeFmt = '-'
|
|
if ($keyCreatedById.ContainsKey($devId)) {
|
|
try { $keyCreatedFmt = ([datetime]$keyCreatedById[$devId]).ToString('yyyy-MM-dd') } catch {}
|
|
}
|
|
if ($keyAgeById.ContainsKey($devId)) {
|
|
$keyAgeFmt = [string]$keyAgeById[$devId]
|
|
}
|
|
|
|
$row = @{
|
|
Entra_DeviceName = $devName; DeviceType = if ($devName -match 'YOURPC') { 'Desktop' } else { 'Laptop' }
|
|
Entra_DeviceID = $devId; Entra_ObjectID = $objId; Intune_DeviceID = $intuneId
|
|
Entra_JoinType = $entraJoin; Entra_Ownership = [string]$d.deviceOwnership; Intune_Ownership = $intuneOwner
|
|
Entra_OSPlatform = [string]$d.operatingSystem; Intune_OSPlatform = $intuneOS
|
|
Entra_OSVersion = [string]$d.operatingSystemVersion; Intune_OSVersion = $intuneOSVer; Windows_Release = $winRelease
|
|
Entra_DeviceEnabled = [string]$d.accountEnabled; 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
|
|
Key_CreatedDate = $keyCreatedFmt; Key_AgeDays = $keyAgeFmt
|
|
Entra_DeviceOwner = $ownerUPN; Entra_PrimaryUserUPN = $resolvedUPN
|
|
Entra_UserAccountEnabled = $uEnabled; Entra_UserCountry = $uCountry; Entra_UserCity = $uCity; Entra_UserJobTitle = $uTitle
|
|
Autopilot_GroupTag = '-'; Autopilot_ProfileStatus = '-'
|
|
}
|
|
$Queue.Enqueue(@{ Type = 'Row'; Data = $row })
|
|
$processed++; $State.Processed = $processed
|
|
}
|
|
|
|
$Queue.Enqueue(@{ Type = 'Done'; Count = $missingDevices.Count })
|
|
$State.Done = $true
|
|
} catch {
|
|
$State.Error = $_.Exception.Message; $State.Done = $true
|
|
$Queue.Enqueue(@{ Type = 'Error'; Message = $_.Exception.Message })
|
|
}
|
|
}).AddArgument($ownerFilter).AddArgument($excludeFilter).AddArgument($enrollFilter).AddArgument($mbkMode).AddArgument($mbkDaysThreshold).AddArgument($mbkQueue).AddArgument($mbkState) | Out-Null
|
|
|
|
$mbkAsync = $rs.BeginInvoke()
|
|
|
|
$script:MBKAllData = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
$script:mbkRS = $rs
|
|
$script:mbkAsync = $mbkAsync
|
|
$script:mbkQueue = $mbkQueue
|
|
$script:mbkState = $mbkState
|
|
|
|
$mbkTimer = [System.Windows.Threading.DispatcherTimer]::new()
|
|
$mbkTimer.Interval = [TimeSpan]::FromMilliseconds(200)
|
|
$mbkTimer.Add_Tick({
|
|
$dg = $script:Window.FindName('DgMBKResults')
|
|
$drained = 0; $item = $null
|
|
while ($drained -lt 50 -and $script:mbkQueue.TryDequeue([ref]$item)) {
|
|
$drained++
|
|
switch ($item.Type) {
|
|
'Row' { $row = [PSCustomObject]$item.Data; $script:MBKAllData.Add($row) }
|
|
'Done' {
|
|
$script:Window.FindName('PbMBKProgress').IsIndeterminate = $false
|
|
$script:Window.FindName('PbMBKProgress').Value = 100
|
|
$completeLabel = if ($script:mbkLastMode -eq 'OlderThanDays') {
|
|
"Complete: $($item.Count) device(s) with OS key older than $($script:mbkLastDays) day(s)"
|
|
} else {
|
|
"Complete: $($item.Count) device(s) missing OS drive BitLocker key"
|
|
}
|
|
$script:Window.FindName('TxtMBKProgressMsg').Text = $completeLabel
|
|
$script:Window.FindName('TxtMBKProgressDetail').Text = ''
|
|
if ($script:MBKAllData.Count -gt 0) {
|
|
$dg.ItemsSource = $script:MBKAllData
|
|
$script:Window.FindName('PnlMBKResults').Visibility = 'Visible'
|
|
$script:Window.FindName('TxtMBKCount').Text = "$($script:MBKAllData.Count) device(s)"
|
|
$script:Window.FindName('BtnMBKCopyAll').IsEnabled = $true
|
|
$script:Window.FindName('BtnMBKExportXlsx').IsEnabled = $true
|
|
$script:Window.FindName('TxtMBKFilter').IsEnabled = $true
|
|
$script:Window.FindName('BtnMBKFilter').IsEnabled = $true
|
|
} else {
|
|
if ($script:mbkLastMode -eq 'OlderThanDays') {
|
|
$script:Window.FindName('TxtMBKNoResults').Text = "No devices have an OS drive BitLocker key older than $($script:mbkLastDays) day(s)."
|
|
} else {
|
|
$script:Window.FindName('TxtMBKNoResults').Text = 'All devices have OS drive BitLocker recovery keys in Entra.'
|
|
}
|
|
$script:Window.FindName('TxtMBKNoResults').Visibility = 'Visible'
|
|
}
|
|
$script:Window.FindName('BtnMBKRun').IsEnabled = $true
|
|
$script:Window.FindName('BtnMBKRunOlder').IsEnabled = $true
|
|
try { $script:mbkRS.EndInvoke($script:mbkAsync) } catch { }
|
|
try { $script:mbkRS.Dispose() } catch { }
|
|
$this.Stop()
|
|
Write-VerboseLog "MBK: Complete - $($script:MBKAllData.Count) device(s) found" -Level Success
|
|
}
|
|
'Error' {
|
|
$script:Window.FindName('PbMBKProgress').IsIndeterminate = $false
|
|
$script:Window.FindName('TxtMBKProgressMsg').Text = "Error: $($item.Message)"
|
|
$script:Window.FindName('BtnMBKRun').IsEnabled = $true
|
|
$script:Window.FindName('BtnMBKRunOlder').IsEnabled = $true
|
|
Show-Notification "BitLocker check failed: $($item.Message)" -BgColor '#F8D7DA' -FgColor '#721C24'
|
|
try { $script:mbkRS.EndInvoke($script:mbkAsync) } catch { }
|
|
try { $script:mbkRS.Dispose() } catch { }
|
|
$this.Stop()
|
|
Write-VerboseLog "MBK Error: $($item.Message)" -Level Error
|
|
}
|
|
}
|
|
}
|
|
$script:Window.FindName('TxtMBKProgressMsg').Text = [string]$script:mbkState.Phase
|
|
$script:Window.FindName('TxtMBKProgressDetail').Text = "Processed: $($script:mbkState.Processed) / $($script:mbkState.Total)"
|
|
})
|
|
$mbkTimer.Start()
|
|
})
|
|
|
|
# -- TxtMBKOlderDays: numeric-only input filter (0-9 chars; range check on click) ------
|
|
$script:Window.FindName('TxtMBKOlderDays').Add_PreviewTextInput({
|
|
param($sender, $e)
|
|
if ($e.Text -notmatch '^[0-9]+$') { $e.Handled = $true }
|
|
})
|
|
|
|
# -- BtnMBKRunOlder: launch the same shared runspace in OlderThanDays mode -------------
|
|
$script:Window.FindName('BtnMBKRunOlder').Add_Click({
|
|
Hide-Notification
|
|
$daysTxt = ($script:Window.FindName('TxtMBKOlderDays').Text).Trim()
|
|
$daysVal = 0
|
|
if (-not [int]::TryParse($daysTxt, [ref]$daysVal) -or $daysVal -lt 0 -or $daysVal -gt 60) {
|
|
Show-Notification 'Enter a number between 0 and 60.' -BgColor '#F8D7DA' -FgColor '#721C24'
|
|
return
|
|
}
|
|
|
|
$script:Window.FindName('BtnMBKRunOlder').IsEnabled = $false
|
|
$script:Window.FindName('BtnMBKRun').IsEnabled = $false
|
|
$script:Window.FindName('PnlMBKProgress').Visibility = 'Visible'
|
|
$script:Window.FindName('PnlMBKResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('TxtMBKNoResults').Visibility = 'Collapsed'
|
|
$script:Window.FindName('PbMBKProgress').IsIndeterminate = $true
|
|
$script:Window.FindName('TxtMBKProgressMsg').Text = 'Starting...'
|
|
$script:Window.FindName('TxtMBKProgressDetail').Text = ''
|
|
|
|
# Clear previous results so the report only shows OlderThanDays rows.
|
|
$script:MBKAllData = $null
|
|
$dg = $script:Window.FindName('DgMBKResults')
|
|
if ($dg) { $dg.ItemsSource = $null }
|
|
$script:Window.FindName('TxtMBKCount').Text = ''
|
|
$script:Window.FindName('BtnMBKCopyValue').IsEnabled = $false
|
|
$script:Window.FindName('BtnMBKCopyRow').IsEnabled = $false
|
|
$script:Window.FindName('BtnMBKCopyAll').IsEnabled = $false
|
|
$script:Window.FindName('BtnMBKExportXlsx').IsEnabled = $false
|
|
$script:Window.FindName('TxtMBKFilter').IsEnabled = $false
|
|
$script:Window.FindName('BtnMBKFilter').IsEnabled = $false
|
|
$script:Window.FindName('BtnMBKFilterClear').IsEnabled = $false
|
|
$script:Window.FindName('TxtMBKFilter').Text = ''
|
|
|
|
# Set the pending mode/threshold, then dispatch BtnMBKRun.Click. The
|
|
# existing click handler reads these script-scope variables and runs
|
|
# the shared work block in OlderThanDays mode. This keeps the heavy
|
|
# runspace setup in exactly one place and avoids cloning ~200 lines.
|
|
$script:mbkPendingMode = 'OlderThanDays'
|
|
$script:mbkPendingDays = $daysVal
|
|
$btn = $script:Window.FindName('BtnMBKRun')
|
|
$btn.RaiseEvent([System.Windows.RoutedEventArgs]::new([System.Windows.Controls.Button]::ClickEvent))
|
|
})
|
|
|
|
# -- DgMBKResults: cell selection -------------------------------------------------------
|
|
$script:Window.FindName('DgMBKResults').Add_SelectedCellsChanged({
|
|
$dg = $script:Window.FindName('DgMBKResults')
|
|
$hasCell = ($dg.SelectedCells.Count -gt 0)
|
|
$script:Window.FindName('BtnMBKCopyValue').IsEnabled = $hasCell
|
|
$script:Window.FindName('BtnMBKCopyRow').IsEnabled = $hasCell
|
|
})
|
|
|
|
# -- MBK Filter -----------------------------------------------------------------------
|
|
$script:Window.FindName('BtnMBKFilter').Add_Click({
|
|
$dg = $script:Window.FindName('DgMBKResults')
|
|
$keyword = $script:Window.FindName('TxtMBKFilter').Text.Trim()
|
|
if (-not $keyword -or -not $script:MBKAllData) { return }
|
|
$filtered = $script:MBKAllData | Where-Object { $row = $_; $match = $false; foreach ($prop in $row.PSObject.Properties) { if ([string]$prop.Value -like "*$keyword*") { $match = $true; break } }; $match }
|
|
$list = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
foreach ($r in $filtered) { $list.Add($r) }
|
|
$dg.ItemsSource = $list
|
|
$script:Window.FindName('TxtMBKCount').Text = "$($list.Count) of $($script:MBKAllData.Count) device(s)"
|
|
$script:Window.FindName('BtnMBKFilterClear').IsEnabled = $true
|
|
})
|
|
|
|
# -- MBK Filter Clear -----------------------------------------------------------------
|
|
$script:Window.FindName('BtnMBKFilterClear').Add_Click({
|
|
$dg = $script:Window.FindName('DgMBKResults')
|
|
$dg.ItemsSource = $script:MBKAllData
|
|
$script:Window.FindName('TxtMBKFilter').Text = ''
|
|
$script:Window.FindName('TxtMBKCount').Text = "$($script:MBKAllData.Count) device(s)"
|
|
$script:Window.FindName('BtnMBKFilterClear').IsEnabled = $false
|
|
})
|
|
|
|
# -- MBK Copy Value -------------------------------------------------------------------
|
|
$script:Window.FindName('BtnMBKCopyValue').Add_Click({
|
|
$dg = $script:Window.FindName('DgMBKResults')
|
|
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 "MBK: Copied cell value to clipboard." -Level Success
|
|
})
|
|
|
|
# -- MBK Copy Row ---------------------------------------------------------------------
|
|
$script:Window.FindName('BtnMBKCopyRow').Add_Click({
|
|
$dg = $script:Window.FindName('DgMBKResults')
|
|
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 "`r`n")
|
|
Show-Notification "$($rowObjs.Count) row(s) copied to clipboard." -BgColor '#D4EDDA' -FgColor '#155724'
|
|
Write-VerboseLog "MBK: Copied $($rowObjs.Count) row(s) to clipboard." -Level Success
|
|
# Highlight full rows after copy (matches GDI behaviour)
|
|
$dg.UnselectAllCells()
|
|
foreach ($r in $rowObjs) { $null = $dg.SelectedItems.Add($r) }
|
|
})
|
|
|
|
# -- MBK Copy All ---------------------------------------------------------------------
|
|
$script:Window.FindName('BtnMBKCopyAll').Add_Click({
|
|
$dg = $script:Window.FindName('DgMBKResults')
|
|
$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 "MBK: Copied all $($allItems.Count) row(s) to clipboard." -Level Success
|
|
})
|
|
|
|
# -- MBK Export XLSX -------------------------------------------------------------------
|
|
$script:Window.FindName('BtnMBKExportXlsx').Add_Click({
|
|
$dg = $script:Window.FindName('DgMBKResults')
|
|
$allItems = @($dg.ItemsSource)
|
|
if ($allItems.Count -eq 0) {
|
|
Show-Notification 'No results to export.' -BgColor '#FFF3CD' -FgColor '#856404'
|
|
return
|
|
}
|
|
# Capture DataGrid column header/binding pairs so the exported file uses the
|
|
# friendly UI column names rather than raw hashtable keys.
|
|
$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 Missing BitLocker Keys Results'
|
|
$dlg.Filter = 'Excel Workbook (*.xlsx)|*.xlsx|CSV File (*.csv)|*.csv|All Files (*.*)|*.*'
|
|
$dlg.FileName = "MissingBitLockerKeys_$(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 'MissingBitLockerKeys' -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 "MBK: Exported $($allItems.Count) row(s) to: $outPath" -Level Success
|
|
} catch {
|
|
Write-VerboseLog "MBK 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__#>
|