mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 10:55:38 +02:00
412 lines
18 KiB
PowerShell
412 lines
18 KiB
PowerShell
# Avalonia port of the Copy dialog from UI/WPF/Extensions/IntuneManagerUI.ps1
|
|
# (Copy-IntuneManagerPolicy). New file because Copy is a self-contained
|
|
# subsystem and the WPF original sits in the giant CoreUI/IntuneManagerUI
|
|
# files we're trying not to grow further (architecture rule R9).
|
|
#
|
|
# Slice 3b1: core copy without scope tags.
|
|
# Slice 3b2: scope-tag dual-list. Panels in CopyDialog.axaml shown only
|
|
# when the source policy type defines a ScopeTagProperty; mirrors the
|
|
# WPF impl with the canonical assigned-ids HashSet + ObservableCollections
|
|
# and re-seeds by NAME on tenant change so cross-tenant copies pre-pick
|
|
# the same-named tags in the destination.
|
|
|
|
function Copy-IntuneManagerPolicy
|
|
{
|
|
$ui = $script:UIProvider
|
|
if (-not $script:dgIntuneManagerObjects -or -not $script:dgIntuneManagerObjects.SelectedItem) {
|
|
$ui.ShowMessageBox("No object selected`n`nSelect the $($script:IntuneManagerSelectedObject.Title) item you want to copy", "Error", "OK", "Error")
|
|
return
|
|
}
|
|
|
|
$sourceRow = $script:dgIntuneManagerObjects.SelectedItem
|
|
$sourceItem = $sourceRow.Source
|
|
if (-not $sourceItem) {
|
|
Write-Log "Copy: selected row carries no policy object" 3
|
|
$ui.ShowMessageBox("Cannot copy: the selected item has no underlying policy object.", "Copy", "OK", "Error")
|
|
return
|
|
}
|
|
|
|
$copyForm = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/CopyDialog.axaml'))
|
|
if (-not $copyForm) { return }
|
|
|
|
$hostType = Get-AvaloniaHost
|
|
$cbDestTenant = $hostType::FindByName($copyForm, 'cbDestinationTenant')
|
|
$txtName = $hostType::FindByName($copyForm, 'txtObjectName')
|
|
$txtDesc = $hostType::FindByName($copyForm, 'txtObjectDescription')
|
|
$btnOk = $hostType::FindByName($copyForm, 'btnOk')
|
|
$btnCancel = $hostType::FindByName($copyForm, 'btnCancel')
|
|
$pnlScopeTagsLabel = $hostType::FindByName($copyForm, 'pnlCopyScopeTagsLabel')
|
|
$grdScopeTags = $hostType::FindByName($copyForm, 'grdCopyScopeTags')
|
|
$lstAvailable = $hostType::FindByName($copyForm, 'lstCopyAvailableScopeTags')
|
|
$lstAssigned = $hostType::FindByName($copyForm, 'lstCopyAssignedScopeTags')
|
|
$btnAssign = $hostType::FindByName($copyForm, 'btnCopyScopeTagAssign')
|
|
$btnUnassign = $hostType::FindByName($copyForm, 'btnCopyScopeTagUnassign')
|
|
|
|
# Default new-name pattern. PolicyType.CopyDefaultName allows %Name%-style
|
|
# placeholders against properties of the source row; mirror the WPF
|
|
# substitution loop. Falls back to "Original - Copy".
|
|
$newName = "$($sourceRow.Name) - Copy"
|
|
$copyDefault = $null
|
|
try {
|
|
if ($sourceItem.PolicyType -and $sourceItem.PolicyType.CopyDefaultName) {
|
|
$copyDefault = $sourceItem.PolicyType.CopyDefaultName
|
|
}
|
|
} catch { }
|
|
if ($copyDefault) {
|
|
$newName = $copyDefault
|
|
foreach ($p in $sourceRow.PSObject.Properties) {
|
|
$val = [string]$sourceRow.$($p.Name)
|
|
$newName = $newName -replace "%$($p.Name)%", $val
|
|
}
|
|
}
|
|
if ($txtName) { $txtName.Text = $newName }
|
|
|
|
# Description gating: HasDescription=$false on the type means the policy
|
|
# has no description field at all; greyed out so the user knows it won't
|
|
# round-trip.
|
|
$hasDescription = $true
|
|
try {
|
|
if ($sourceItem.HasDescription -eq $false) { $hasDescription = $false }
|
|
} catch { }
|
|
if ($txtDesc) {
|
|
if ($hasDescription) {
|
|
try { $txtDesc.Text = [string]$sourceItem.Description } catch { }
|
|
} else {
|
|
$txtDesc.IsEnabled = $false
|
|
}
|
|
}
|
|
|
|
# Tenant combo: project Get-TokenInfo entries into CopyDialogTenantItem so
|
|
# the ItemTemplate can bind to TenantNameEx (Avalonia binding doesn't
|
|
# walk PSObject NoteProperty — see [[avalonia-binding-needs-clr-types]]).
|
|
$tenantItems = @()
|
|
foreach ($t in (Get-TokenInfo)) {
|
|
if (-not $t) { continue }
|
|
$name = [string]$t.TenantName
|
|
if ($t.IsDefault) { $name = "$name (Current)" }
|
|
$tenantItems += [CopyDialogTenantItem]@{
|
|
TenantNameEx = $name
|
|
Id = if ($null -ne $t.Id) { [int]$t.Id } else { 0 }
|
|
IsDefault = [bool]$t.IsDefault
|
|
Source = $t
|
|
}
|
|
}
|
|
if ($cbDestTenant) {
|
|
$cbDestTenant.ItemsSource = $tenantItems
|
|
$defaultItem = $tenantItems | Where-Object { $_.IsDefault } | Select-Object -First 1
|
|
if (-not $defaultItem -and $tenantItems.Count -gt 0) { $defaultItem = $tenantItems[0] }
|
|
if ($defaultItem) { $cbDestTenant.SelectedItem = $defaultItem }
|
|
}
|
|
|
|
# ---- Scope tags (dual-list) ----------------------------------------------
|
|
# Mirrors WPF UI/WPF/Extensions/IntuneManagerUI.ps1 ~lines 1947-2114. Single
|
|
# source of truth = the AssignedIds HashSet on $state; ObservableCollections
|
|
# are re-derived on every change. Cross-tenant assignment is matched by
|
|
# NAME so picking a different tenant still pre-checks same-named tags.
|
|
$state = @{
|
|
ScopeTagPropName = $null
|
|
SourceScopeTagNames = @()
|
|
AllScopeTags = @()
|
|
AssignedIds = [System.Collections.Generic.HashSet[string]]::new()
|
|
AvailableCollection = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
|
|
AssignedCollection = [System.Collections.ObjectModel.ObservableCollection[object]]::new()
|
|
LstAvail = $lstAvailable
|
|
LstAssigned = $lstAssigned
|
|
}
|
|
# Event handlers must reach this through $script: - closure captures do
|
|
# not survive ConvertTo-AvaloniaEventScriptBlock (NewBoundScriptBlock
|
|
# rebinds to the module and drops the .GetNewClosure() state).
|
|
$script:_copyDialogTagState = $state
|
|
|
|
try {
|
|
if ($sourceItem.PolicyType -and $sourceItem.PolicyType.ScopeTagProperty) {
|
|
$state.ScopeTagPropName = [string]$sourceItem.PolicyType.ScopeTagProperty
|
|
}
|
|
} catch { }
|
|
|
|
if ($state.ScopeTagPropName) {
|
|
# Capture source tag NAMES so cross-tenant re-seeds work.
|
|
$sourceTagIds = @()
|
|
try {
|
|
if ($sourceItem.Object -and $sourceItem.Object.PSObject.Properties[$state.ScopeTagPropName]) {
|
|
$sourceTagIds = @($sourceItem.Object.($state.ScopeTagPropName) | ForEach-Object { [string]$_ })
|
|
}
|
|
} catch { }
|
|
try {
|
|
$srcDeps = Get-GraphDependencySourceObjects $sourceItem -DefaultPoliciesOnly
|
|
if ($srcDeps -and $srcDeps.ContainsKey('ScopeTags')) {
|
|
foreach ($id in $sourceTagIds) {
|
|
$tag = @($srcDeps['ScopeTags']) | Where-Object { [string]$_.Id -eq $id } | Select-Object -First 1
|
|
if ($tag) { $state.SourceScopeTagNames += [string]$tag.Name }
|
|
}
|
|
}
|
|
} catch { Write-LogDebug "Source scope-tag name capture failed: $($_.Exception.Message)" }
|
|
|
|
if ($pnlScopeTagsLabel) { $pnlScopeTagsLabel.IsVisible = $true }
|
|
if ($grdScopeTags) { $grdScopeTags.IsVisible = $true }
|
|
|
|
|
|
if ($lstAvailable) {
|
|
$lstAvailable.ItemsSource = $state.AvailableCollection
|
|
}
|
|
if ($lstAssigned) {
|
|
$lstAssigned.ItemsSource = $state.AssignedCollection
|
|
}
|
|
|
|
# All three helpers live in $script: and read $script: state only -
|
|
# local captures would be lost the moment an event handler runs them.
|
|
$script:_copyDialogSyncTagLists = {
|
|
$st = $script:_copyDialogTagState
|
|
if (-not $st) { return }
|
|
$st.AvailableCollection.Clear()
|
|
$st.AssignedCollection.Clear()
|
|
foreach ($tag in $st.AllScopeTags) {
|
|
if ($st.AssignedIds.Contains($tag.Id)) {
|
|
[void]$st.AssignedCollection.Add($tag)
|
|
} else {
|
|
[void]$st.AvailableCollection.Add($tag)
|
|
}
|
|
}
|
|
}
|
|
|
|
# Reload destination tenant's scope tags. Cache lookup first; live
|
|
# fetch on cold cache. Dedupes by Id (cache may carry both the
|
|
# synthetic Default and a real Default tag).
|
|
$script:_copyDialogReloadTags = {
|
|
param([int]$DestTokenId)
|
|
|
|
$st = $script:_copyDialogTagState
|
|
if (-not $st) { return }
|
|
|
|
$destTags = @()
|
|
try {
|
|
$destTokenInfo = Get-TokenInfo $DestTokenId
|
|
if ($destTokenInfo) {
|
|
$cacheId = "DependencyObjects_$($destTokenInfo.TenantId)"
|
|
$deps = Get-CacheObject $cacheId
|
|
if ($deps -and $deps.ContainsKey('ScopeTags')) {
|
|
$destTags = @($deps['ScopeTags'])
|
|
}
|
|
}
|
|
} catch { Write-LogDebug "Copy scope tags: cache lookup failed: $($_.Exception.Message)" }
|
|
|
|
if ($destTags.Count -eq 0) {
|
|
try {
|
|
$destTags = @(Get-GraphPolicies -PolicyType 'ScopeTags' -TokenId $DestTokenId)
|
|
$destTags = @([PSCustomObject]@{ ID = 0; Name = 'Default' }) + $destTags
|
|
} catch { Write-LogError "Failed to load scope tags for destination tenant" $_.Exception }
|
|
}
|
|
|
|
$dedup = @()
|
|
$seenIds = [System.Collections.Generic.HashSet[string]]::new()
|
|
foreach ($tag in $destTags) {
|
|
$idStr = [string]$tag.Id
|
|
if (-not $seenIds.Add($idStr)) { continue }
|
|
$dedup += [CopyDialogScopeTagItem]@{ Id = $idStr; Name = [string]$tag.Name }
|
|
}
|
|
$st.AllScopeTags = @($dedup | Sort-Object Name)
|
|
|
|
$st.AssignedIds.Clear()
|
|
$seedNames = [System.Collections.Generic.HashSet[string]]::new()
|
|
foreach ($n in $st.SourceScopeTagNames) { [void]$seedNames.Add($n) }
|
|
foreach ($tag in $st.AllScopeTags) {
|
|
if ($seedNames.Contains($tag.Name)) { [void]$st.AssignedIds.Add($tag.Id) }
|
|
}
|
|
|
|
& $script:_copyDialogSyncTagLists
|
|
}
|
|
|
|
if ($cbDestTenant -and $cbDestTenant.SelectedItem) {
|
|
& $script:_copyDialogReloadTags ([int]$cbDestTenant.SelectedItem.Id)
|
|
}
|
|
|
|
if ($cbDestTenant) {
|
|
$cbDestTenant.add_SelectionChanged((ConvertTo-AvaloniaEventScriptBlock {
|
|
param($s, $e)
|
|
if ($s.SelectedItem) { & $script:_copyDialogReloadTags ([int]$s.SelectedItem.Id) }
|
|
}))
|
|
}
|
|
|
|
$script:_copyDialogMoveTags = {
|
|
param([string]$Action, [string[]]$Ids)
|
|
$st = $script:_copyDialogTagState
|
|
if (-not $st -or $Ids.Count -eq 0) { return }
|
|
if ($Action -eq 'assign') {
|
|
foreach ($id in $Ids) { [void]$st.AssignedIds.Add([string]$id) }
|
|
} else {
|
|
foreach ($id in $Ids) { [void]$st.AssignedIds.Remove([string]$id) }
|
|
}
|
|
& $script:_copyDialogSyncTagLists
|
|
}
|
|
|
|
if ($btnAssign) {
|
|
$btnAssign.add_Click((ConvertTo-AvaloniaEventScriptBlock {
|
|
try {
|
|
$st = $script:_copyDialogTagState
|
|
if (-not $st -or -not $st.LstAvail -or $st.LstAvail.SelectedItems.Count -eq 0) { return }
|
|
$ids = @($st.LstAvail.SelectedItems | ForEach-Object { [string]$_.Id })
|
|
& $script:_copyDialogMoveTags 'assign' $ids
|
|
} catch { Write-LogError "Copy scope-tag assign handler failed" $_.Exception }
|
|
}))
|
|
}
|
|
if ($btnUnassign) {
|
|
$btnUnassign.add_Click((ConvertTo-AvaloniaEventScriptBlock {
|
|
try {
|
|
$st = $script:_copyDialogTagState
|
|
if (-not $st -or -not $st.LstAssigned -or $st.LstAssigned.SelectedItems.Count -eq 0) { return }
|
|
$ids = @($st.LstAssigned.SelectedItems | ForEach-Object { [string]$_.Id })
|
|
& $script:_copyDialogMoveTags 'unassign' $ids
|
|
} catch { Write-LogError "Copy scope-tag unassign handler failed" $_.Exception }
|
|
}))
|
|
}
|
|
|
|
# Avalonia's MouseDoubleClick equivalent on ListBox is DoubleTapped.
|
|
if ($lstAvailable) {
|
|
$lstAvailable.add_DoubleTapped((ConvertTo-AvaloniaEventScriptBlock {
|
|
try {
|
|
$st = $script:_copyDialogTagState
|
|
if (-not $st -or -not $st.LstAvail -or $st.LstAvail.SelectedItems.Count -eq 0) { return }
|
|
$ids = @($st.LstAvail.SelectedItems | ForEach-Object { [string]$_.Id })
|
|
& $script:_copyDialogMoveTags 'assign' $ids
|
|
} catch { Write-LogError "Copy scope-tag double-tap (available) failed" $_.Exception }
|
|
}))
|
|
}
|
|
if ($lstAssigned) {
|
|
$lstAssigned.add_DoubleTapped((ConvertTo-AvaloniaEventScriptBlock {
|
|
try {
|
|
$st = $script:_copyDialogTagState
|
|
if (-not $st -or -not $st.LstAssigned -or $st.LstAssigned.SelectedItems.Count -eq 0) { return }
|
|
$ids = @($st.LstAssigned.SelectedItems | ForEach-Object { [string]$_.Id })
|
|
& $script:_copyDialogMoveTags 'unassign' $ids
|
|
} catch { Write-LogError "Copy scope-tag double-tap (assigned) failed" $_.Exception }
|
|
}))
|
|
}
|
|
}
|
|
|
|
# Result box captured by the button closures — they set Confirmed before
|
|
# tearing down the modal.
|
|
# ShowModalForm is non-blocking, so the copy runs from the OK/Enter handlers
|
|
# (Invoke-IntuneManagerCopyExecute) while state is live - stashed in script
|
|
# scope so the module-scope helper can read the controls after dispatch.
|
|
$script:_imCopyFormState = @{
|
|
SourceRow = $sourceRow
|
|
SourceItem = $sourceItem
|
|
TxtName = $txtName
|
|
TxtDesc = $txtDesc
|
|
CbDestTenant = $cbDestTenant
|
|
BtnOk = $btnOk
|
|
State = $state
|
|
}
|
|
|
|
if ($btnOk) {
|
|
$btnOk.add_Click((ConvertTo-AvaloniaEventScriptBlock {
|
|
try { Invoke-IntuneManagerCopyExecute } catch { Write-LogError "Copy execution failed" $_.Exception }
|
|
Show-ModalObject
|
|
$script:_imCopyFormState = $null
|
|
$script:_copyDialogTagState = $null
|
|
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
|
|
}.GetNewClosure()))
|
|
}
|
|
if ($btnCancel) {
|
|
$btnCancel.add_Click((ConvertTo-AvaloniaEventScriptBlock {
|
|
Show-ModalObject
|
|
$script:_imCopyFormState = $null
|
|
$script:_copyDialogTagState = $null
|
|
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
|
|
}.GetNewClosure()))
|
|
}
|
|
|
|
# Avalonia has no IsDefault/IsCancel on Button — handle Enter/Escape.
|
|
$copyForm.add_KeyDown((ConvertTo-AvaloniaEventScriptBlock {
|
|
param($s, $e)
|
|
$stCopy = $script:_imCopyFormState
|
|
if ($e.Key -eq [Avalonia.Input.Key]::Enter -and $stCopy -and $stCopy.BtnOk -and $stCopy.BtnOk.IsEnabled) {
|
|
try { Invoke-IntuneManagerCopyExecute } catch { Write-LogError "Copy execution failed" $_.Exception }
|
|
Show-ModalObject
|
|
$script:_imCopyFormState = $null
|
|
$script:_copyDialogTagState = $null
|
|
$e.Handled = $true
|
|
} elseif ($e.Key -eq [Avalonia.Input.Key]::Escape) {
|
|
Show-ModalObject
|
|
$script:_imCopyFormState = $null
|
|
$script:_copyDialogTagState = $null
|
|
$e.Handled = $true
|
|
}
|
|
}.GetNewClosure()))
|
|
|
|
$ui.ShowModalForm("Copy object", $copyForm, $true)
|
|
|
|
# Focus + select the name so the user can type a new value immediately.
|
|
if ($txtName) {
|
|
try { $txtName.Focus() } catch { }
|
|
try { $txtName.SelectAll() } catch { }
|
|
}
|
|
|
|
}
|
|
|
|
function Invoke-IntuneManagerCopyExecute
|
|
{
|
|
# Runs the copy for the live $script:_imCopyFormState. Called from the OK/Enter
|
|
# handlers (ShowModalForm does not block, so it can't run after it returns).
|
|
$st = $script:_imCopyFormState
|
|
if (-not $st) { return }
|
|
$ui = $script:UIProvider
|
|
$sourceRow = $st.SourceRow
|
|
$sourceItem = $st.SourceItem
|
|
$txtName = $st.TxtName
|
|
$txtDesc = $st.TxtDesc
|
|
$cbDestTenant = $st.CbDestTenant
|
|
$state = $st.State
|
|
|
|
$finalName = if ($txtName) { [string]$txtName.Text } else { '' }
|
|
if ([string]::IsNullOrWhiteSpace($finalName)) {
|
|
Write-Log "New name cannot be empty. Copy object skipped" 2
|
|
Write-Status ""
|
|
return
|
|
}
|
|
|
|
$finalDesc = $null
|
|
if ($txtDesc -and $txtDesc.IsEnabled) {
|
|
$finalDesc = [string]$txtDesc.Text
|
|
}
|
|
|
|
$tokenId = $null
|
|
if ($cbDestTenant -and $cbDestTenant.SelectedItem) {
|
|
$tokenId = $cbDestTenant.SelectedItem.Id
|
|
}
|
|
|
|
Write-Status "Copy $($sourceRow.Name)"
|
|
|
|
$copyArgs = @{
|
|
InputObject = $sourceItem
|
|
Name = $finalName
|
|
Description = $finalDesc
|
|
}
|
|
if ($null -ne $tokenId) { $copyArgs['TokenId'] = $tokenId }
|
|
|
|
# Forward scope-tag picks (when section was shown). Pass even an empty
|
|
# array so the new copy explicitly reflects user intent — Copy-GraphPolicy
|
|
# only overrides the cloned JSON when this is non-null. Read from the
|
|
# canonical AssignedIds set rather than the UI-derived collection.
|
|
if ($state.ScopeTagPropName -and $null -ne $state.AssignedIds) {
|
|
$copyArgs['ScopeTagIds'] = @($state.AssignedIds)
|
|
}
|
|
|
|
$newPolicies = $null
|
|
try {
|
|
$newPolicies = Copy-GraphPolicy @copyArgs
|
|
} catch {
|
|
Write-LogError "Copy-GraphPolicy failed" $_.Exception
|
|
$ui.ShowMessageBox("Copy failed: $($_.Exception.Message)", "Error", "OK", "Error")
|
|
Write-Status ""
|
|
return
|
|
}
|
|
|
|
if ($newPolicies -and (Get-SettingValue "RefreshObjectsAfterCopy") -eq $true) {
|
|
Invoke-IntuneActivateObject $script:IntuneManagerSelectedObject
|
|
}
|
|
|
|
Write-Status ""
|
|
if ($script:dgIntuneManagerObjects) { $script:dgIntuneManagerObjects.Focus() }
|
|
}
|