Files

1084 lines
45 KiB
PowerShell

Add-AppEvent "IntuneManagerUISelectedMenuItemChanged"
Add-AppEvent "IntuneManagerUISelectedItemChanged"
Add-AppEventHandler "AppInitialized" "Invoke-IntuneUIAppInitialized"
Add-AppEventHandler "SettingsUpdated" "Invoke-IntuneUIEventSettingsUpdated"
function Get-IntuneViewItems
{
[OutputType([PSCustomObject[]])]
param()
# ToDo: Fix support for PolicyGroup OR PolicyType
$viewType = Get-SettingValue "ObjectViewType" # "Type"
if($viewType -eq "Type") {
$viewItems = $script:IntuneTypes | Sort-Object -Property Title
}
else {
$viewItems = $script:IntuneGroups | Sort-Object -Property Title
}
# Read-only "Intune Info" group (Baseline Templates, Tenant Settings, etc.) is
# opt-in: hidden unless enabled via the gear menu's "Show read-only Info". Drop
# the group (Group view) or its member types (API view).
if((Get-SettingStoreValue "IntuneManager" "ShowIntuneInfo" "false") -ne "true") {
if($viewType -eq "Type") {
$viewItems = @($viewItems | Where-Object { -not ($_.PolicyGroup -and $_.PolicyGroup.Id -eq "IntuneInfo") })
}
else {
$viewItems = @($viewItems | Where-Object { $_.Id -ne "IntuneInfo" })
}
}
# Stamp AccessType / AccessInfo from the token's granted scopes so the
# ListBox triggers in MainWindow.xaml can colour restricted rows (orange =
# read-only, red = no access). Works for both view modes: groups aggregate
# their member types. No-op when there is no token to diff against.
Update-IntuneAccessLevels
if((Get-SettingValue "HideNoAccess"))
{
# Drop rows the token cannot use at all. Groups aggregate to None only
# when every member type is unusable, so a partially usable group is
# never hidden - it stays visible and orange.
$viewItems = @($viewItems | Where-Object { $_.AccessType -ne [APIAccess]::None })
}
# MenuLabel is the bound display string for each menu row (template uses
# `Text="{Binding MenuLabel}"`). Default to Title; Update-IntuneViewItemCounts
# later overrides with "Title (N)" when the gear's "Show item counts"
# toggle is on. Always reset to Title here so a view-mode switch can't
# leave a stale "(N)" attached when counts are off.
$showCounts = (Get-SettingStoreValue "IntuneManager" "ShowMenuItemCounts" "false") -eq "true"
foreach($item in $viewItems) {
$item | Add-Member -NotePropertyName "MenuLabel" -NotePropertyValue $item.Title -Force
}
# Defer to Show-ViewMenu's post-bind hook to populate counts (it has access
# to $script:lstMenuItems after the bind happens). $showCounts only here
# so the variable is referenced — actual count population happens later.
$script:_intuneShowItemCounts = $showCounts
return ($viewItems)
}
# Batched type-count fetcher. Runs once per Refresh / first count-enable;
# cached results power both API view (1 count per row) and Group view (sum
# of child-type counts per group row). Skips silently if there's no signed-
# in token — counts will populate the next time the menu rebuilds.
function Update-IntuneViewItemCounts
{
param([switch]$Force)
if(-not $script:lstMenuItems -or -not $script:lstMenuItems.ItemsSource) { return }
$tokenId = $null
try { $tokenId = Get-DefaultTokenId } catch { }
if($null -eq $tokenId) {
Write-LogDebug "Update-IntuneViewItemCounts: no token id available - skipping"
return
}
# Fetch ALL type counts (not just the visible rows) so Group view can roll
# up without a second pass. The fetch + session cache live in
# Internal/IntuneManager.ps1 (Get-IntuneTypeCounts) and are shared with the
# Avalonia backend; -Force invalidates.
[void](Get-IntuneTypeCounts -Force:$Force -TokenId $tokenId)
# Project counts onto the currently-bound MenuLabels. Type rows: direct
# lookup. Group rows: sum of every child type's count (skips types with
# no count so a single 400 doesn't void the whole group).
foreach($it in $script:lstMenuItems.ItemsSource) {
$count = $null
if($it -is [IntunePolicyTypeBase]) {
if($script:_intuneTypeCounts.ContainsKey($it.Id)) { $count = $script:_intuneTypeCounts[$it.Id] }
}
elseif($it -is [IntunePolicyGroupBase]) {
$sum = 0; $hasAny = $false
foreach($child in @($it.PolicyTypes)) {
if($child -and $script:_intuneTypeCounts.ContainsKey($child.Id)) {
$sum += $script:_intuneTypeCounts[$child.Id]; $hasAny = $true
}
}
if($hasAny) { $count = $sum }
}
$label = if($null -ne $count) { "$($it.Title) ($count)" } else { $it.Title }
$it | Add-Member -NotePropertyName "MenuLabel" -NotePropertyValue $label -Force
}
try { $script:lstMenuItems.Items.Refresh() } catch { }
}
function Get-IntuneManagementViewPanel
{
$panel = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\IntuneManagerPanel.xaml"))
$script:dgIntuneManagerObjects = $panel.FindName("dgIntuneManagerObjects")
$script:spIntuneManagerButtons = $panel.FindName("spSubMenu")
$script:IntuneManagementFilterTextBox = $panel.FindName("txtFilter")
$script:IntuneManagementObjectsCount = $panel.FindName("txtObjectsCount")
$script:UIProvider.AddXamlEvent($panel, "btnCopy", "Add_Click", {
Copy-IntuneManagerPolicy
})
$script:UIProvider.AddXamlEvent($panel, "btnView", "Add_Click", {
Show-IntuneManagerDetailedView
})
$script:UIProvider.AddXamlEvent($panel, "btnCompare", "Add_Click", {
$selectedItems = @($script:dgIntuneManagerObjects.ItemsSource | Where-Object IsSelected -eq $true)
if($selectedItems.Count -eq 0)
{
$sel = $script:dgIntuneManagerObjects.SelectedItem
if($sel) { $selectedItems = @($sel) }
}
if($selectedItems.Count -gt 0) { Show-GraphCompareForm $selectedItems }
})
$script:UIProvider.AddXamlEvent($panel, "btnExport", "Add_Click", {
Show-IntuneManagerExportForm
})
$script:UIProvider.AddXamlEvent($panel, "btnImport", "Add_Click", {
Show-IntuneManagerImportForm
})
$script:UIProvider.AddXamlEvent($panel, "btnDelete", "Add_Click", {
Remove-GraphObjectsUI
})
$script:UIProvider.AddXamlEvent($panel, "btnDocument", "Add_Click", {
# Prefer IsSelected-checked rows; fall back to highlighted row; fall back
# to all currently-visible rows (matches the OLD project's btnDocument
# behaviour where clicking with nothing selected documented the view).
$items = @($script:dgIntuneManagerObjects.ItemsSource | Where-Object IsSelected -eq $true)
if ($items.Count -eq 0 -and $null -ne $script:dgIntuneManagerObjects.SelectedItem) {
$items = @($script:dgIntuneManagerObjects.SelectedItem)
}
if ($items.Count -eq 0) {
$items = @($script:dgIntuneManagerObjects.ItemsSource)
}
if ($items.Count -eq 0) { return }
Show-IntuneManagerDocumentForm -PolicyObject $items
})
$script:UIProvider.AddXamlEvent($panel, "txtFilter", "Add_TextChanged", {
Invoke-FilterBoxChanged $this $script:dgIntuneManagerObjects -ObjectsCountTextBox $script:IntuneManagementObjectsCount
})
$dpd = [System.ComponentModel.DependencyPropertyDescriptor]::FromProperty([System.Windows.Controls.ItemsControl]::ItemsSourceProperty, [System.Windows.Controls.DataGrid])
if($dpd)
{
$dpd.AddValueChanged($script:dgIntuneManagerObjects, {
$script:UIProvider.SetXamlProperty($this.Parent, "txtFilter", "Text", "")
# Clearing txtFilter only fires TextChanged when the text actually
# changes; for an already-empty filter that no-ops, leaving the
# objects-count label stale (or blank on first load). Refresh
# explicitly with -ForceUpdate so the count tracks every ItemsSource
# change — initial load, view switch, refresh, all paths.
Invoke-FilterBoxChanged $script:IntuneManagementFilterTextBox $script:dgIntuneManagerObjects -ForceUpdate -ObjectsCountTextBox $script:IntuneManagementObjectsCount
})
}
$script:dgIntuneManagerObjects.add_selectionChanged({
$hasSelectedItems = ($script:dgIntuneManagerObjects.ItemsSource | Measure-Object).Count -gt 0 -and (($script:dgIntuneManagerObjects.ItemsSource | Where-Object IsSelected -eq $true) -or ($null -ne $script:dgIntuneManagerObjects.SelectedItem))
$script:UIProvider.SetXamlProperty($script:spIntuneManagerButtons, "btnView", "IsEnabled", $hasSelectedItems)
$script:UIProvider.SetXamlProperty($script:spIntuneManagerButtons, "btnCompare", "IsEnabled", $hasSelectedItems)
$script:UIProvider.SetXamlProperty($script:spIntuneManagerButtons, "btnCopy", "IsEnabled", $hasSelectedItems)
$script:UIProvider.SetXamlProperty($script:spIntuneManagerButtons, "btnDelete", "IsEnabled", $hasSelectedItems)
$script:UIProvider.SetXamlProperty($this.Parent, "btnExport", "IsEnabled", $hasSelectedItems)
# btnDocument stays enabled whenever any items are loaded — clicking
# with nothing selected documents the entire view (matches OLD project).
$script:UIProvider.SetXamlProperty($script:spIntuneManagerButtons, "btnDocument", "IsEnabled", (($script:dgIntuneManagerObjects.ItemsSource | Measure-Object).Count -gt 0))
Invoke-AppEvent "IntuneManagerUISelectedItemChanged" $hasSelectedItems
})
$btnRefresh = $script:UIProvider.GetXamlObject(($script:AppUIRootFolder + "\Xaml\RefreshButton.xaml"))
if($btnRefresh)
{
$grdTitle = $panel.FindName("grdTitle")
$btnRefresh.SetValue([System.Windows.Controls.Grid]::ColumnProperty, $grdTitle.ColumnDefinitions.Count - 1)
$btnRefresh.Margin = "0,0,5,3"
$btnRefresh.Cursor = "Hand"
$btnRefresh.Name = "btnRefresh"
$btnRefresh.Focusable = $false
$grdTitle.Children.Add($btnRefresh) | Out-Null
$tooltip = [System.Windows.Controls.ToolTip]::new()
$tooltip.Content = "Refresh all objects"
[System.Windows.Controls.ToolTipService]::SetToolTip($btnRefresh, $tooltip)
$panel.RegisterName($btnRefresh.Name, $btnRefresh)
$tooltip = [System.Windows.Controls.ToolTip]::new()
$tooltip.Content = "Refresh objects"
[System.Windows.Controls.ToolTipService]::SetToolTip($btnRefresh, $tooltip)
$btnRefresh.Add_Click({
Invoke-RefreshObjects
})
}
$script:UIProvider.AddXamlEvent($panel, "btnLoadAllPages", "add_click", {
Add-GraphPoliciesFromPaging "AllRemainingPages"
})
$script:UIProvider.AddXamlEvent($panel, "btnLoadNextPage", "add_click", {
Add-GraphPoliciesFromPaging "NextPage"
})
return $panel
}
function Add-GraphPoliciesFromPaging
{
param(
[ValidateSet("NextPage", "AllRemainingPages")]
[string]
$PagingType
)
Write-Status "Loading $($script:IntuneManagerSelectedObject.Title) objects"
Get-GraphPolicies -Paging $PagingType | Get-GraphPolicyForUIList | ForEach-Object {
$script:intuneManagerPolicyCollection.Add([PSObject]$_)
}
$script:dgIntuneManagerObjects.ItemsSource.CommitNew()
Set-GraphPagesButtonStatus
Invoke-FilterBoxChanged $script:IntuneManagementFilterTextBox $script:dgIntuneManagerObjects -ForceUpdate -ObjectsCountTextBox $script:IntuneManagementObjectsCount
Write-Status ""
}
function Set-GraphPagesButtonStatus
{
$IntuneManagerViewObject = Get-SingletonObject "IntuneViewObject"
$IntuneManagerPanael = $IntuneManagerViewObject.ViewPanel
$script:UIProvider.SetXamlProperty($IntuneManagerPanael, "btnLoadAllPages", "Visibility", (?: ($script:GraphPagingCache) "Visible" "Collapsed"))
$script:UIProvider.SetXamlProperty($IntuneManagerPanael, "btnLoadNextPage", "Visibility", (?: ($script:GraphPagingCache) "Visible" "Collapsed"))
}
function Clear-GraphObjects
{
$IntuneManagerViewObject = Get-SingletonObject "IntuneViewObject"
$IntuneManagerPanael = $IntuneManagerViewObject.ViewPanel
$script:UIProvider.SetXamlProperty($IntuneManagerPanael, "txtFormTitle", "Text", "")
$script:UIProvider.SetXamlProperty($IntuneManagerPanael, "txtObjectsCount", "Text", "")
# Keep grdTitle visible — the empty colored strip is the desired idle state so
# the right pane isn't an unframed expanse of background.
if($script:dgIntuneManagerObjects -and $script:dgIntuneManagerObjects.Children) {
$script:dgIntuneManagerObjects.Children.Clear()
$script:dgIntuneManagerObjects.ItemsSource = $null
}
[System.Windows.Forms.Application]::DoEvents()
}
function Invoke-RefreshObjects
{
if(-not $script:IntuneManagerSelectedObject) { return }
$txtFilterText = $null
$txtFilter = $this.Parent.FindName("txtFilter")
if($txtFilter) { $txtFilterText = $txtFilter.Text }
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject
if($txtFilterText -and $txtFilter)
{
$txtFilter.Text = $txtFilterText
Invoke-FilterBoxChanged $txtFilter $script:dgIntuneManagerObjects -ObjectsCountTextBox $script:IntuneManagementObjectsCount
}
Write-Status ""
}
function Invoke-IntuneActivatingView
{
param($PreviousActiveView)
Add-GraphBulkMenu
if($null -eq $PreviousActiveView) {
Set-SplashWindowText "Authenticating"
$obj = Get-SingletonObject "IntuneViewObject"
if($obj) { $obj.Authenticate() }
}
}
function Invoke-IntuneDeactivatingView
{
param($NewActiveView)
$tmp = $script:mnuMain.Items | Where-Object Name -eq "IntuneBulk"
if($tmp) { $script:mnuMain.Items.Remove($tmp) }
}
function Add-GraphBulkMenu
{
# Item order matches the original project: Export, Import, Delete, Compare,
# Copy, Documentation. Features that are new in this version (Scope Tags,
# Assignments) are appended after a separator.
$menuItem = [System.Windows.Controls.MenuItem]::new()
$menuItem.Header = "_Bulk"
$menuItem.Name = "IntuneBulk"
$subItem = [System.Windows.Controls.MenuItem]::new()
$subItem.Header = "_Export"
$subItem.Add_Click({Show-GraphBulkExportForm})
$menuItem.AddChild($subItem) | Out-Null
$subItem = [System.Windows.Controls.MenuItem]::new()
$subItem.Header = "_Import"
$subItem.Add_Click({Show-GraphBulkImportForm})
$menuItem.AddChild($subItem) | Out-Null
$subItem = [System.Windows.Controls.MenuItem]::new()
$subItem.Header = "_Delete"
$subItem.Name = "mnuBulkDelete"
$allowBulkDelete = Get-SettingValue "AllowBulkDelete"
# Add it hidden even if not enabled, the save settings will enable it
$subItem.Visibility = (?: ($allowBulkDelete -eq $true) "Visible" "Collapsed")
$subItem.Add_Click({Show-GraphBulkDeleteForm})
$menuItem.AddChild($subItem) | Out-Null
$subItem = [System.Windows.Controls.MenuItem]::new()
$subItem.Header = "C_ompare"
$subItem.Add_Click({ Show-GraphBulkCompareForm })
$menuItem.AddChild($subItem) | Out-Null
$subItem = [System.Windows.Controls.MenuItem]::new()
$subItem.Header = "Cop_y"
$subItem.Add_Click({Show-GraphBulkCopyForm})
$menuItem.AddChild($subItem) | Out-Null
$subItem = [System.Windows.Controls.MenuItem]::new()
$subItem.Header = "Doc_umentation"
$subItem.Add_Click({Show-GraphBulkDocumentationForm})
$menuItem.AddChild($subItem) | Out-Null
$menuItem.AddChild(([System.Windows.Controls.Separator]::new())) | Out-Null
$subItem = [System.Windows.Controls.MenuItem]::new()
$subItem.Header = "_Scope Tags"
$subItem.Add_Click({Show-GraphBulkScopeTagForm})
$menuItem.AddChild($subItem) | Out-Null
$subItem = [System.Windows.Controls.MenuItem]::new()
$subItem.Header = "_Assignments"
$subItem.Add_Click({Show-GraphBulkAssignmentsForm})
$menuItem.AddChild($subItem) | Out-Null
Add-MenuItem $menuItem 1
}
function Invoke-IntuneActivateObject
{
param($SelectedObject)
if($null -eq $SelectedObject) {
$script:dgIntuneManagerObjects.ItemsSource = $null
return
}
Write-Status "Activate $($SelectedObject.Title)"
$script:IntuneManagerSelectedObject = $SelectedObject
Clear-GraphObjects
Show-SelectedGraphPolicies -SinglePage -Restart
}
function Show-SelectedGraphPolicies
{
param(
[switch]
$AllPages,
[switch]
$SinglePage,
[switch]
$Restart
)
if($Restart -eq $true) {
$script:IntuneManagerCurrentPage = $null
}
if($null -eq $script:IntuneManagerSelectedObject) {
return
}
$params = @{}
if($script:IntuneManagerSelectedObject -is [IntunePolicyGroupBase]) {
$params.Add("PolicyGroup", $script:IntuneManagerSelectedObject.Id)
}
elseif($script:IntuneManagerSelectedObject -is [IntunePolicyTypeBase]) {
$params.Add("PolicyType", $script:IntuneManagerSelectedObject.Id)
}
if($SinglePage -eq $true -and (Get-SettingValue "GetAllPages") -ne $true) {
$params.Add("SinglePage", $true)
}
Write-Status "Loading $($script:IntuneManagerSelectedObject.Title) objects"
if($script:IntuneManagerSelectedObject.ShowForm -ne $false)
{
$IntuneManagerViewObject = Get-SingletonObject "IntuneViewObject"
$IntuneManagerPanael = $IntuneManagerViewObject.ViewPanel
$script:UIProvider.SetXamlProperty($IntuneManagerPanael, "txtFormTitle", "Text", $script:IntuneManagerSelectedObject.Title)
try {
$script:UIProvider.SetXamlProperty($IntuneManagerPanael, "ccIcon", "Content", $script:IntuneManagerSelectedObject.GetImage())
}
catch {
}
$script:UIProvider.SetXamlProperty($IntuneManagerPanael, "grdTitle", "Visibility", "Visible")
}
$graphObjects = Get-GraphPolicies @params | Get-GraphPolicyForUIList
$script:dgIntuneManagerObjects.AutoGenerateColumns = $false
$script:dgIntuneManagerObjects.Columns.Clear()
if($graphObjects)
{
$column = Get-GridCheckboxColumn "IsSelected"
$script:dgIntuneManagerObjects.Columns.Add($column)
$column.Header.add_Click({
foreach($Item in $script:dgIntuneManagerObjects.ItemsSource)
{
$Item.IsSelected = $this.IsChecked
}
$script:dgIntuneManagerObjects.Items.Refresh()
})
$additionalColumns = @()
$selectedType = Get-SelectedObjectTypeString
if(-not $selectedType) { return }
$additionalColsStr = Get-SettingStoreValue "IntuneManager\ObjectColumns\$selectedType" "$($script:IntuneManagerSelectedObject.Id)"
if($additionalColsStr)
{
$additionalColumns += $additionalColsStr.Split(',')
}
$columns = @()
if($additionalColumns.Count -eq 0 -or $additionalColumns[0] -ne "0")
{
$columns += ?? $script:IntuneManagerSelectedObject.ViewProperties @("Name","PolicyName","ID")
}
# Add custom columns
foreach($additionalCol in $additionalColumns)
{
if($additionalCol -eq "0" -or $additionalCol -eq "1") { continue }
$columns += $additionalCol
}
# Add columns
# One line per row: a multi-line value (store app descriptions, script
# bodies) otherwise makes the row as tall as its text and wrecks scrolling.
# The full text stays on the cell tooltip. Setting: ObjectListFirstLineOnly.
$firstLineOnly = ((Get-SettingValue "ObjectListFirstLineOnly") -eq $true)
# Character cap on top of the first line: one very long description
# otherwise widens its column until the others leave the screen.
$maxCellLength = 50
try { $maxCellLength = [int](Get-SettingValue "ObjectListMaxCellLength") } catch { }
foreach($columnInfo in $columns)
{
$bindingProp,$colHeader = $columnInfo.Split('=')
if(-not $colHeader) { $colHeader = $bindingProp.Split(".")[-1] }
$dgIntuneManagerObjects.Columns.Add((New-GridTextColumn -Path $bindingProp -Header $colHeader -FirstLineOnly:$firstLineOnly -MaxLength $maxCellLength))
}
}
else {
$graphObjects = @()
}
$script:intuneManagerPolicyCollection = [System.Collections.ObjectModel.ObservableCollection[PSObject]]::new($graphObjects)
$script:dgIntuneManagerObjects.ItemsSource = [System.Windows.Data.CollectionViewSource]::GetDefaultView($script:intuneManagerPolicyCollection)
$policyTypes = Get-IntuneManagerSelectedPolicyTypes
$allowDelete = Get-SettingValue "AllowDelete"
Set-IntuneManagerUIButtonStatus @("btnDelete") $policyTypes -ForceHide:($allowDelete -eq $false)
Set-IntuneManagerUIButtonStatus @("btnImport","btnView","btnExport","btnCompare","btnCopy","btnDocument") $policyTypes
$script:UIProvider.SetXamlProperty($script:spIntuneManagerButtons, "btnImport", "IsEnabled", $true) # Always allow Import if ObjectType allows it
Set-GraphPagesButtonStatus
Invoke-AppEvent "IntuneManagerUISelectedMenuItemChanged" $policyTypes
Write-Status ""
}
function Set-IntuneManagerUIButtonStatus
{
param($Buttons, $PolicyTypes, [switch]$ForceHide)
foreach($btn in $Buttons) {
$visibility = "Collapsed"
if($ForceHide -ne $true) {
foreach($policyType in $PolicyTypes) {
if(-not $policyType.ShowButtons -or ($policyType.ShowButtons | Where-Object { $btn -like "*$($_)" } )) {
$visibility = "Visible"
break
}
}
}
$script:UIProvider.SetXamlProperty($script:spIntuneManagerButtons, $btn, "Visibility", $visibility)
}
}
function Get-GraphPolicyForUIList
{
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[IntunePolicyBase[]]
$InputObject
)
Begin { $graphObjects = @() }
Process {
foreach($policy in $InputObject) {
$tmp = [PSCustomObject]$policy
if((-not $tmp.PSObject.Properties['JsonObject']) -or ($null -eq $tmp.JsonObject)) {
$tmp | Add-Member -NotePropertyName "JsonObject" -NotePropertyValue $policy.Object -Force
}
if((-not $tmp.PSObject.Properties['_TokenId']) -or ($null -eq $tmp._TokenId)) {
$tmp | Add-Member -NotePropertyName "_TokenId" -NotePropertyValue $policy.TokenId -Force
}
$tmp | Add-Member -NotePropertyName "IsSelected" -NotePropertyValue $false
$graphObjects += $tmp
}
}
End { $graphObjects | Sort-Object -Property Name }
}
function Get-SelectedObjectTypeString {
if($script:IntuneManagerSelectedObject -is [IntunePolicyGroupBase]) {
"PolicyGroup"
}
elseif($script:IntuneManagerSelectedObject -is [IntunePolicyTypeBase]) {
"PolicyType"
}
else {
""
}
}
function Get-IntuneManagerSelectedPolicyTypes
{
[CmdletBinding()]
param()
$policyTypes = @()
if($script:IntuneManagerSelectedObject -is [IntunePolicyGroupBase]) {
$policyTypes += $script:IntuneManagerSelectedObject.PolicyTypes
}
else {
$policyTypes += $script:IntuneManagerSelectedObject
}
return $policyTypes
}
function Invoke-IntuneUIAppInitialized
{
# Settings registrations moved out of this handler (2026-08-15):
# GraphPageSize -> Internal/IntuneManager.ps1 (engine-consumed);
# GetAllPages / ObjectViewType -> UI/Classes/UICommonSettings.ps1 (shared by
# both backends, registered once).
Add-ViewObject (Get-SingletonObject "IntuneViewObject")
Add-AppEventHandler "AuthenticatedNewToken" "Invoke-IntuneUIEventNewAuthentication"
Add-AppEventHandler "AuthenticationUserDisconnected" "Invoke-IntuneUIEventUserDisconnected"
Add-AppEventHandler "AuthenticationFailed" "Invoke-IntuneUIEventAuthenticationFailed"
}
function Invoke-IntuneUIEventSettingsUpdated
{
if((Get-SettingValue "ObjectViewType") -ne (Get-CacheObject "ObjectViewType-Cached")) {
Set-CacheObject "ObjectViewType-Cached" (Get-SettingValue "ObjectViewType")
Show-ViewMenu
}
Update-IntuneDeleteVisibility
}
# Called by Show-ViewMenu (CoreUI) whenever the IntuneView becomes the
# active view OR its menu is rebuilt. Makes the title-bar config gear
# visible, syncs all its checkable states to current settings, and
# one-shot wires the click handlers. If "show item counts" is on, also
# kicks the count fetcher to paint counts into the freshly-bound rows.
function Update-MenuTitleConfigForIntuneView
{
if(-not $script:btnMenuTitleConfig) { return }
$script:btnMenuTitleConfig.Visibility = "Visible"
$script:btnMenuTitleConfig.ToolTip = "Menu options: switch view, refresh"
$current = Get-SettingValue "ObjectViewType"
# "Group" is the default (matches the ObjectViewType DefaultValue registered in
# UI/Classes/UICommonSettings.ps1);
# anything not "Type" reads as Group so a missing/blank setting still
# ticks the Group option rather than leaving both unchecked.
if($script:mnuMenuTitleConfigGroup) { $script:mnuMenuTitleConfigGroup.IsChecked = ($current -ne "Type") }
if($script:mnuMenuTitleConfigType) { $script:mnuMenuTitleConfigType.IsChecked = ($current -eq "Type") }
# Read-only "Intune Info" group is opt-in (hidden by default). Sync the toggle.
if($script:mnuMenuTitleConfigShowInfo) { $script:mnuMenuTitleConfigShowInfo.IsChecked = ((Get-SettingStoreValue "IntuneManager" "ShowIntuneInfo" "false") -eq "true") }
# "Show item counts" is HIDDEN per user decision (2026-06-12) — the menu
# item is collapsed in MainWindow.xaml and the populate paths below are
# forced off so a previously-saved true setting can't resurrect it. The
# counts machinery (Get-IntuneTypeCounts + label projection) is kept for
# potential re-enable.
$showCounts = $false
if($script:mnuMenuTitleConfigShowCounts) { $script:mnuMenuTitleConfigShowCounts.IsChecked = $showCounts }
# Wire-once. The Click handlers persist for the window lifetime so a
# subsequent view switch + return doesn't re-register them.
if(-not $script:_menuTitleConfigWired) {
$script:_menuTitleConfigWired = $true
$script:mnuMenuTitleConfigGroup.Add_Click({ Set-IntuneMenuObjectViewType "Group" })
$script:mnuMenuTitleConfigType.Add_Click({ Set-IntuneMenuObjectViewType "Type" })
if($script:mnuMenuTitleConfigShowInfo) {
$script:mnuMenuTitleConfigShowInfo.Add_Click({
# IsCheckable items flip IsChecked before the handler runs. Persist +
# rebuild the nav so the read-only Info group appears/disappears.
$enabled = [bool]$script:mnuMenuTitleConfigShowInfo.IsChecked
Save-SettingStoreValue -SubPath "IntuneManager" -Key "ShowIntuneInfo" -Value ($enabled.ToString().ToLower())
Show-ViewMenu
})
}
if($script:mnuMenuTitleConfigRefresh) {
$script:mnuMenuTitleConfigRefresh.Add_Click({
# Re-bind the menu list (cheap — no Graph). Count re-fetch
# removed with the hidden "Show item counts" feature.
Show-ViewMenu
})
}
if($script:mnuMenuTitleConfigShowCounts) {
$script:mnuMenuTitleConfigShowCounts.Add_Click({
# IsCheckable items flip IsChecked before the handler runs.
$enabled = [bool]$script:mnuMenuTitleConfigShowCounts.IsChecked
Save-SettingStoreValue -SubPath "IntuneManager" -Key "ShowMenuItemCounts" -Value ($enabled.ToString().ToLower())
if($enabled) {
Update-IntuneViewItemCounts
}
else {
# Strip counts back to bare Title without a fresh batch.
if($script:lstMenuItems -and $script:lstMenuItems.ItemsSource) {
foreach($it in $script:lstMenuItems.ItemsSource) {
$it | Add-Member -NotePropertyName "MenuLabel" -NotePropertyValue $it.Title -Force
}
try { $script:lstMenuItems.Items.Refresh() } catch { }
}
}
})
}
if($script:mnuMenuTitleConfigFilterPlatforms -and $script:btnMenuTitleConfig.ContextMenu) {
# Populate on the parent ContextMenu's Opened (not the submenu's
# SubmenuOpened). A MenuItem with zero child items doesn't render
# the submenu arrow and never fires SubmenuOpened — so the items
# have to exist BEFORE the user hovers into the submenu. Cheap:
# in-memory scan of the already-bound right-pane rows, no Graph.
$script:btnMenuTitleConfig.ContextMenu.Add_Opened({ Update-IntuneFilterPlatformsSubmenu })
}
}
# First paint after rebind: if counts are on, populate them. Uses the
# cached $script:_intuneTypeCounts so a view-switch back doesn't trigger
# a fresh batch — only first activation or explicit Refresh does.
if($showCounts) {
Update-IntuneViewItemCounts
}
}
# Rebuilds the Filter Platforms submenu from the currently-bound right-pane
# rows. Each entry is an IsCheckable MenuItem keyed on the platform string
# (with "" sentinel for null/empty Platform). Currently-selected platforms
# are always present even if absent from the data, so the user can untick
# a filter they previously enabled but that no longer matches anything.
function Update-IntuneFilterPlatformsSubmenu
{
if(-not $script:mnuMenuTitleConfigFilterPlatforms) { return }
# 1. Collect platforms in the current grid
$available = [System.Collections.Generic.HashSet[string]]::new()
$hasNull = $false
if($script:dgIntuneManagerObjects -and $script:dgIntuneManagerObjects.ItemsSource) {
$src = $script:dgIntuneManagerObjects.ItemsSource
if($src -is [System.Windows.Data.ListCollectionView]) { $src = $src.SourceCollection }
if($src) {
foreach($it in $src) {
$plat = $it.Platform
if([string]::IsNullOrEmpty($plat)) { $hasNull = $true }
else { [void]$available.Add([string]$plat) }
}
}
}
# 2. Include currently-selected filters so the user can untick them even
# if the new view no longer has any matching rows.
if($script:_intunePlatformFilter) {
foreach($s in $script:_intunePlatformFilter) {
if($s -eq "") { $hasNull = $true }
else { [void]$available.Add($s) }
}
}
$script:mnuMenuTitleConfigFilterPlatforms.Items.Clear()
$sorted = @($available | Sort-Object)
if($sorted.Count -eq 0 -and -not $hasNull) {
$empty = [System.Windows.Controls.MenuItem]::new()
$empty.Header = "(no policies loaded)"
$empty.IsEnabled = $false
[void]$script:mnuMenuTitleConfigFilterPlatforms.Items.Add($empty)
return
}
# 3. One MenuItem per platform; "(no platform)" pinned at the bottom so
# it visually separates from named platforms.
foreach($plat in $sorted) {
$mi = [System.Windows.Controls.MenuItem]::new()
$mi.Header = $plat
$mi.IsCheckable = $true
$mi.IsChecked = ($script:_intunePlatformFilter -and $script:_intunePlatformFilter.Contains($plat))
# Tag stores the canonical key (== Header for named platforms; "" for
# the no-platform row). Read in the click handler so the same handler
# works for both.
$mi.Tag = $plat
$mi.Add_Click({ Switch-IntunePlatformFilter ([string]$this.Tag) ([bool]$this.IsChecked) })
[void]$script:mnuMenuTitleConfigFilterPlatforms.Items.Add($mi)
}
if($hasNull) {
$mi = [System.Windows.Controls.MenuItem]::new()
$mi.Header = "(no platform)"
$mi.IsCheckable = $true
$mi.IsChecked = ($script:_intunePlatformFilter -and $script:_intunePlatformFilter.Contains(""))
$mi.Tag = ""
$mi.Add_Click({ Switch-IntunePlatformFilter ([string]$this.Tag) ([bool]$this.IsChecked) })
[void]$script:mnuMenuTitleConfigFilterPlatforms.Items.Add($mi)
}
# 4. "Clear filter" footer when any filter is active.
if($script:_intunePlatformFilter -and $script:_intunePlatformFilter.Count -gt 0) {
[void]$script:mnuMenuTitleConfigFilterPlatforms.Items.Add([System.Windows.Controls.Separator]::new())
$clear = [System.Windows.Controls.MenuItem]::new()
$clear.Header = "Clear filter"
$clear.Add_Click({
if($script:_intunePlatformFilter) { $script:_intunePlatformFilter.Clear() }
if($script:dgIntuneManagerObjects) {
Invoke-FilterBoxChanged $script:IntuneManagementFilterTextBox $script:dgIntuneManagerObjects -ForceUpdate -ObjectsCountTextBox $script:IntuneManagementObjectsCount
}
})
[void]$script:mnuMenuTitleConfigFilterPlatforms.Items.Add($clear)
}
}
# Toggle a single platform on/off in $script:_intunePlatformFilter and
# re-evaluate the grid filter. Centralised so the submenu click handlers
# stay one-liners.
function Switch-IntunePlatformFilter
{
param([string]$PlatformKey, [bool]$Enable)
if($null -eq $script:_intunePlatformFilter) {
$script:_intunePlatformFilter = [System.Collections.Generic.HashSet[string]]::new()
}
if($Enable) { [void]$script:_intunePlatformFilter.Add($PlatformKey) }
else { [void]$script:_intunePlatformFilter.Remove($PlatformKey) }
if($script:dgIntuneManagerObjects) {
Invoke-FilterBoxChanged $script:IntuneManagementFilterTextBox $script:dgIntuneManagerObjects -ForceUpdate -ObjectsCountTextBox $script:IntuneManagementObjectsCount
}
}
# Single source of truth for switching the menu mode from the gear. Persists
# the setting so the choice survives a restart, keeps the SettingsUpdated
# cache key in sync so the Settings dialog won't fire a redundant rebuild,
# and rebuilds the menu list. Idempotent — selecting the same option twice
# is a no-op.
function Set-IntuneMenuObjectViewType
{
param([ValidateSet("Group","Type")][string]$ViewType)
$current = Get-SettingValue "ObjectViewType"
if($current -eq $ViewType) {
# Sync checkboxes anyway in case the user toggled one off — IsCheckable
# menu items uncheck on click before this handler runs.
if($script:mnuMenuTitleConfigGroup) { $script:mnuMenuTitleConfigGroup.IsChecked = ($ViewType -eq "Group") }
if($script:mnuMenuTitleConfigType) { $script:mnuMenuTitleConfigType.IsChecked = ($ViewType -eq "Type") }
return
}
Save-SettingStoreValue -SubPath "IntuneManager" -Key "ObjectViewType" -Value $ViewType
Set-CacheObject "ObjectViewType-Cached" $ViewType
if($script:mnuMenuTitleConfigGroup) { $script:mnuMenuTitleConfigGroup.IsChecked = ($ViewType -eq "Group") }
if($script:mnuMenuTitleConfigType) { $script:mnuMenuTitleConfigType.IsChecked = ($ViewType -eq "Type") }
Show-ViewMenu
}
function Invoke-IntuneUIEventShowMainWindow
{
Update-IntuneDeleteVisibility
}
function Invoke-IntuneUISelectedItemChanged
{
}
function Invoke-IntuneUIEventNewAuthentication
{
param($TokenInfo)
Update-IntuneDeleteVisibility
# Access marking (Internal/AccessLevel.ps1) needs the signed-in token, which
# only exists once login completes. The nav was built white before the token
# arrived, so rebuild it now to repaint the AccessType colours - otherwise the
# menu stays uncoloured until the next view switch. AccessType is a plain
# property with no change notification, so only a full ItemsSource rebuild
# re-runs the colour triggers; refreshing the object grid alone does nothing
# for the menu. Preserve and restore the selected menu item across the
# rebuild: setting ItemsSource nulls the selection, which fires
# OnItemChanged($null) and would blank a loaded object list. Restoring it
# reloads that list (the same effect the old Invoke-RefreshObjects had).
if(($script:ActiveView -and $script:ActiveView.ID -eq "IntuneManagement") -and
(Get-Command Show-ViewMenu -ErrorAction SilentlyContinue)) {
$selected = if($script:lstMenuItems) { $script:lstMenuItems.SelectedItem } else { $null }
Show-ViewMenu
if($selected -and $script:lstMenuItems) { $script:lstMenuItems.SelectedItem = $selected }
}
else {
Invoke-RefreshObjects
}
}
function Invoke-IntuneUIEventUserDisconnected
{
param($TokenInfo)
Update-IntuneDeleteVisibility
# Sign-out is the mirror of Invoke-IntuneUIEventNewAuthentication: with no
# token, Update-IntuneAccessLevels (run inside Show-ViewMenu -> GetViewItems)
# resets every type/group back to Full, clearing the orange/red marking. But
# AccessType is a plain property with no change notification, so the colours
# only clear when the menu's ItemsSource is rebuilt - rebuild it here.
# Unlike the login handler, do NOT restore the selection: letting it fall to
# null fires OnItemChanged($null), which clears the object list that the
# signed-out session can no longer load.
if(($script:ActiveView -and $script:ActiveView.ID -eq "IntuneManagement") -and
(Get-Command Show-ViewMenu -ErrorAction SilentlyContinue)) {
Show-ViewMenu
}
}
function Invoke-IntuneUIEventAuthenticationFailed
{
param($TokenInfo)
# AuthenticationFailed also fires on transient failures - this tenant's WAM
# broker fails every silent refresh while the token is still perfectly valid
# (see the SuppressFailedEvent handling in the auth core) - so rebuilding on
# every failure would wrongly wipe the marking mid-session. Only treat it as a
# sign-out when the default token is genuinely past expiry; Test-DefaultTokenExpired
# returns $false for still-valid and SDK-managed tokens. When it is expired, the
# reset is identical to an explicit disconnect, so reuse that handler (no
# selection restore -> the object grid clears too).
if(-not (Test-DefaultTokenExpired)) { return }
Invoke-IntuneUIEventUserDisconnected $TokenInfo
}
# Refresh the visibility of every place that exposes a delete action so that toggling
# 'AllowDelete' / 'AllowBulkDelete' in Settings or signing in to a new tenant takes effect
# without having to restart the UI or re-enter a view. Safe to call before the relevant
# controls exist -- each path checks for the control first.
function Update-IntuneDeleteVisibility
{
Update-IntuneManagerDeleteButton
Update-IntuneBulkDeleteMenu
}
function Update-IntuneManagerDeleteButton
{
if(-not $script:spIntuneManagerButtons) { return }
$btn = $script:spIntuneManagerButtons.FindName("btnDelete")
if(-not $btn) { return }
$allowDelete = (Get-SettingValue "AllowDelete") -eq $true
# Reuse the regular button-status path so the current policy type's ShowButtons rules
# still apply -- don't expose Delete when no compatible type is selected.
$policyTypes = @()
if(Get-Command Get-IntuneManagerSelectedPolicyTypes -ErrorAction SilentlyContinue)
{
$policyTypes = @(Get-IntuneManagerSelectedPolicyTypes)
}
Set-IntuneManagerUIButtonStatus @("btnDelete") $policyTypes -ForceHide:(-not $allowDelete)
}
function Update-IntuneBulkDeleteMenu
{
if(-not $script:mnuMain) { return }
$bulkMenu = $script:mnuMain.Items | Where-Object { $_.Name -eq "IntuneBulk" } | Select-Object -First 1
if(-not $bulkMenu) { return }
$deleteItem = $bulkMenu.Items | Where-Object { $_.Name -eq "mnuBulkDelete" } | Select-Object -First 1
if(-not $deleteItem) { return }
$allowBulkDelete = (Get-SettingValue "AllowBulkDelete") -eq $true
$deleteItem.Visibility = if($allowBulkDelete) { "Visible" } else { "Collapsed" }
}
#endregion
function Invoke-FilterBoxChanged
{
param($TxtBox, $DataSource, [switch]$ForceUpdate, $ObjectsCountTextBox)
if($DataSource.ItemsSource -is [System.Windows.Data.ListCollectionView])
{
# Capture the active text + platform filter into the predicate closure.
# The platform set lives at $script: so a separate code path (the gear's
# Filter Platforms submenu) can mutate it and then call this with
# -ForceUpdate to re-evaluate the bound rows.
$textValue = if($TxtBox) { [string]$TxtBox.Text } else { "" }
$platformSet = $script:_intunePlatformFilter
$hasText = -not [string]::IsNullOrEmpty($textValue)
$hasPlatform = ($null -ne $platformSet -and $platformSet.Count -gt 0)
if($hasText -or $hasPlatform) {
# Text match runs ONLY against the fields actually shown in this grid,
# derived from the grid's own bound columns (Name, Description, and
# whatever else the view/ObjectColumns configure). Deriving from
# $DataSource.Columns keeps this generic for every grid that calls this
# function AND avoids matching heavy off-grid note-properties - the row
# objects carry the full policy under 'JsonObject' plus '_TokenId', and
# regex-matching those on every row/keystroke was the source of the lag.
# Computed once here (not per row) and captured in the closure.
$columnPaths = @()
foreach($col in $DataSource.Columns) {
if($col -is [System.Windows.Controls.DataGridBoundColumn] -and $col.Binding -and $col.Binding.Path) {
$p = [string]$col.Binding.Path.Path
if($p -and $p -ne "IsSelected") { $columnPaths += $p }
}
}
# -like with the wildcard metacharacters escaped == a plain, fast,
# case-insensitive substring match (no regex engine per field).
$likePattern = if($hasText) { "*" + [System.Management.Automation.WildcardPattern]::Escape($textValue) + "*" } else { $null }
$DataSource.ItemsSource.Filter = {
param($Item)
# Platform filter — empty Platform maps to the "" sentinel so a
# "(no platform)" selection can target rows whose .Platform is
# null/empty without ambiguity.
if($hasPlatform) {
$plat = $Item.Platform
$key = if([string]::IsNullOrEmpty($plat)) { "" } else { [string]$plat }
if(-not $platformSet.Contains($key)) { return $false }
}
if($hasText) {
if($columnPaths.Count -gt 0) {
foreach($path in $columnPaths) {
# Resolve the (possibly dotted, e.g. "PolicyType.Title") bound path.
$val = $Item
foreach($seg in $path.Split('.')) {
if($null -eq $val) { break }
$val = $val.$seg
}
if($null -ne $val -and ([string]$val) -like $likePattern) { return $true }
}
return $false
}
# Defensive fallback (grid exposed no bound columns): scan
# properties but skip the internal/heavy ones so it can never
# regress to matching the whole policy object.
return ($null -ne ($Item.PSObject.Properties | Where-Object {
$_.Name -notin @("IsSelected","Object","ObjectType","JsonObject","_TokenId") -and
([string]$_.Value) -like $likePattern
}))
}
return $true
}.GetNewClosure()
}
else {
$DataSource.ItemsSource.Filter = $null
}
if($ForceUpdate)
{
$DataSource.ItemsSource.Refresh()
}
}
if($ObjectsCountTextBox)
{
# loadedCount = rows pulled from Graph and cached locally (across all loaded pages)
# visibleCount = rows currently rendered after the txtFilter predicate
# If $script:GraphPagingCache is non-empty, more pages are available on the server
# (the Load More / Load All buttons are visible). We mark loadedCount with a "+"
# in that case so the user can tell the count isn't the full set.
$loadedCount = 0
if($DataSource.ItemsSource.SourceCollection) {
$loadedCount = $DataSource.ItemsSource.SourceCollection.Count
}
$visibleCount = ($DataSource.ItemsSource | Measure-Object).Count
$morePages = [bool]$script:GraphPagingCache
$loadedLabel = if($morePages) { "$loadedCount+" } else { "$loadedCount" }
if($loadedCount -le 0) {
$ObjectsCountTextBox.Text = ""
}
elseif($visibleCount -lt $loadedCount) {
# Filter is active and hides some loaded rows.
$ObjectsCountTextBox.Text = "Showing $visibleCount of $loadedLabel"
}
else {
# No filter (or filter matches everything loaded).
$ObjectsCountTextBox.Text = if($morePages) {
"Objects: $loadedLabel (more available - click Load All)"
} else {
"Objects: $loadedCount"
}
}
}
}
#region Bulk Forms