#Requires -Modules ActiveDirectory <# .SYNOPSIS A PowerShell script with a GUI to browse Active Directory OUs across different domains, display enabled users within a selected OU (and sub-OUs), and allow editing of certain user attributes directly in a grid. .DESCRIPTION This script presents a two-panel interface: - Left Panel: A ComboBox to select/enter the target AD domain, a "Connect" button, and a TreeView to navigate AD Organizational Units of the connected domain. Includes buttons to toggle TreeView visibility and Top Panel visibility. - Right Panel: A DataGridView to display and edit user properties. Users are loaded from the selected OU (and its children) within the connected domain. Changes made in the grid can be saved back to Active Directory using the 'Save Changes' button. Includes horizontal and vertical scrollbars for the grid. .NOTES Version: 2.3 Author: Gemini Assistant (based on user prompts) Last Modified: 2025-04-10 (Fixed TreeView overlap, Fixed SplitterDistance setting) Location Context: Bexley, NSW, Australia Requires: Active Directory PowerShell Module, .NET Framework (usually included with Windows) Permissions: Needs permissions to read AD structure, read user properties, and modify user properties (Set-ADUser) potentially across multiple domains. Run with appropriate credentials. #> Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing # --- Global variables --- # Store originally loaded users for comparison $global:OriginalUsers = @{} # Store connection details for the selected domain $global:CurrentTargetDomainFQDN = $null $global:CurrentTargetDC = $null $global:CurrentDomainDN = $null # --- Form Setup --- $form = New-Object System.Windows.Forms.Form $form.Text = "Active Directory Multi-Domain User Editor" $form.Width = 950 $form.Height = 650 $form.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen $form.MinimumSize = New-Object System.Drawing.Size(800, 500) # --- SplitContainer for Layout --- $splitContainer = New-Object System.Windows.Forms.SplitContainer $splitContainer.Dock = [System.Windows.Forms.DockStyle]::Fill # REMOVED: $splitContainer.SplitterDistance = 350 # Initial setting removed, will be set in Form.Shown $splitContainer.BorderStyle = [System.Windows.Forms.BorderStyle]::Fixed3D $splitContainer.FixedPanel = [System.Windows.Forms.FixedPanel]::Panel1 $form.Controls.Add($splitContainer) # --- Panel 1: Domain Selection, TreeView, Load Button --- # --- INTERMEDIATE Layout Panel within SplitContainer.Panel1 --- # This panel will contain the Top, Bottom, and Fill elements for Panel 1 $layoutPanelP1 = New-Object System.Windows.Forms.Panel $layoutPanelP1.Dock = [System.Windows.Forms.DockStyle]::Fill $layoutPanelP1.BackColor = [System.Drawing.Color]::Transparent # Make it invisible # --- Panel for Domain Selection at the TOP of the layoutPanelP1 --- $topPanelP1 = New-Object System.Windows.Forms.Panel $topPanelP1.Height = 80 # Increased height $topPanelP1.Dock = [System.Windows.Forms.DockStyle]::Top # Docks to the TOP of layoutPanelP1 $topPanelP1.Padding = New-Object System.Windows.Forms.Padding(10, 10, 10, 5) $topPanelP1.BackColor = [System.Drawing.SystemColors]::Control # --- Panel for the Buttons at the BOTTOM of the layoutPanelP1 --- $bottomPanelP1 = New-Object System.Windows.Forms.Panel $bottomPanelP1.Height = 50 $bottomPanelP1.Dock = [System.Windows.Forms.DockStyle]::Bottom # Docks to the BOTTOM of layoutPanelP1 $bottomPanelP1.Padding = New-Object System.Windows.Forms.Padding(10, 10, 10, 10) $bottomPanelP1.BackColor = [System.Drawing.SystemColors]::Control # --- TreeView - Fills the space BETWEEN top and bottom panels in layoutPanelP1 --- $treeView = New-Object System.Windows.Forms.TreeView $treeView.Dock = [System.Windows.Forms.DockStyle]::Fill # Docks to FILL remaining space in layoutPanelP1 $treeView.BorderStyle = [System.Windows.Forms.BorderStyle]::FixedSingle $treeView.HideSelection = $false $treeView.Enabled = $false # Initially disabled until domain is connected $treeView.Visible = $false # Start hidden, will be shown via button/connect logic # --- Controls for Top Panel (Domain Selection) --- # $domainLabel = New-Object System.Windows.Forms.Label $domainLabel.Text = "Domain FQDN:" $domainLabel.Location = New-Object System.Drawing.Point(10, 13) # Adjusted Y slightly for new panel height $domainLabel.AutoSize = $true $topPanelP1.Controls.Add($domainLabel) $connectDomainButton = New-Object System.Windows.Forms.Button $connectDomainButton.Text = "Connect" $connectDomainButton.Width = 80 $connectDomainButton.Height = 25 $connectButtonY = $domainLabel.Bottom + 8 # Added slightly more vertical space $connectButtonX = $topPanelP1.ClientSize.Width - $connectDomainButton.Width - $topPanelP1.Padding.Right # Anchored Right $connectDomainButton.Location = New-Object System.Drawing.Point($connectButtonX, $connectButtonY) $connectDomainButton.Anchor = ([System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Right) # Anchor Top Right $domainComboBox = New-Object System.Windows.Forms.ComboBox $domainComboBox.Location = New-Object System.Drawing.Point($topPanelP1.Padding.Left, $connectButtonY) # Use same Y as button $domainComboBox.Height = $connectDomainButton.Height # Match button height $spaceBeforeButton = 10 # Gap between ComboBox and Button $comboWidth = $topPanelP1.ClientSize.Width - $topPanelP1.Padding.Left - $connectDomainButton.Width - $spaceBeforeButton - $topPanelP1.Padding.Right if ($comboWidth -lt 100) { $comboWidth = 100 } # Minimum width $domainComboBox.Width = $comboWidth $domainComboBox.Anchor = ([System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Left) # Anchor Top and Left $domainComboBox.DropDownStyle = [System.Windows.Forms.ComboBoxStyle]::DropDown $topPanelP1.Controls.Add($domainComboBox) $topPanelP1.Controls.Add($connectDomainButton) # --- End of Controls for Top Panel --- # --- Controls for Bottom Panel (Load Button, Toggle TreeView Button, Toggle Top Panel Button) --- $loadUsersButton = New-Object System.Windows.Forms.Button $loadUsersButton.Text = "Load Users" $loadUsersButton.Width = 90 $loadUsersButton.Height = 25 $controlY = [int](($bottomPanelP1.ClientSize.Height - $loadUsersButton.Height) / 2) $loadUsersButton.Location = New-Object System.Drawing.Point($bottomPanelP1.Padding.Left, $controlY) $loadUsersButton.Anchor = ([System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Left) $loadUsersButton.Enabled = $false $bottomPanelP1.Controls.Add($loadUsersButton) $toggleTreeViewButton = New-Object System.Windows.Forms.Button $toggleTreeViewButton.Name = "toggleTreeViewButton" $toggleTreeViewButton.Text = "Show OUs" $toggleTreeViewButton.Width = 90 $toggleTreeViewButton.Height = 25 $toggleButtonX = $loadUsersButton.Right + 6 $toggleTreeViewButton.Location = New-Object System.Drawing.Point($toggleButtonX, $controlY) $toggleTreeViewButton.Anchor = ([System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Left) $toggleTreeViewButton.Enabled = $false $bottomPanelP1.Controls.Add($toggleTreeViewButton) $toggleTopPanelButton = New-Object System.Windows.Forms.Button $toggleTopPanelButton.Name = "toggleTopPanelButton" $toggleTopPanelButton.Text = "Hide Domains" $toggleTopPanelButton.Width = 90 $toggleTopPanelButton.Height = 25 $toggleTopButtonX = $toggleTreeViewButton.Right + 6 $toggleTopPanelButton.Location = New-Object System.Drawing.Point($toggleTopButtonX, $controlY) $toggleTopPanelButton.Anchor = ([System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Left) $toggleTopPanelButton.Enabled = $true $bottomPanelP1.Controls.Add($toggleTopPanelButton) # --- End of Controls for Bottom Panel --- # --- Add Panels and TreeView to the INTERMEDIATE layoutPanelP1 in correct order --- # 1. Add Top Panel $layoutPanelP1.Controls.Add($topPanelP1) # 2. Add Bottom Panel $layoutPanelP1.Controls.Add($bottomPanelP1) # 3. Add TreeView (Fill) LAST $layoutPanelP1.Controls.Add($treeView) # --- Finally, add the layoutPanelP1 to the SplitContainer's Panel1 --- $splitContainer.Panel1.Controls.Add($layoutPanelP1) # --- Panel 2: DataGridView, Save Button, Status Label --- $bottomPanelP2 = New-Object System.Windows.Forms.Panel $bottomPanelP2.Height = 50 $bottomPanelP2.Dock = [System.Windows.Forms.DockStyle]::Bottom $bottomPanelP2.Padding = New-Object System.Windows.Forms.Padding(10) $bottomPanelP2.BackColor = [System.Drawing.SystemColors]::Control $splitContainer.Panel2.Controls.Add($bottomPanelP2) $dataGridView = New-Object System.Windows.Forms.DataGridView $dataGridView.Dock = [System.Windows.Forms.DockStyle]::Fill $dataGridView.MultiSelect = $false $dataGridView.AllowUserToAddRows = $false $dataGridView.AllowUserToDeleteRows = $false $dataGridView.BorderStyle = [System.Windows.Forms.BorderStyle]::Fixed3D $dataGridView.ScrollBars = [System.Windows.Forms.ScrollBars]::Both $dataGridView.ColumnHeadersDefaultCellStyle.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold) $dataGridView.DefaultCellStyle.Font = New-Object System.Drawing.Font("Segoe UI", 9) $dataGridView.AlternatingRowsDefaultCellStyle.BackColor = [System.Drawing.Color]::FromArgb(240, 240, 240) $splitContainer.Panel2.Controls.Add($dataGridView) $saveButton = New-Object System.Windows.Forms.Button $saveButton.Text = "Save Changes" $saveButton.Width = 120 $saveButton.Height = 30 $saveButtonY = [int](($bottomPanelP2.ClientSize.Height - $saveButton.Height) / 2) $saveButtonX = $bottomPanelP2.ClientSize.Width - $saveButton.Width - $bottomPanelP2.Padding.Right $saveButton.Location = New-Object System.Drawing.Point($saveButtonX, $saveButtonY) $saveButton.Anchor = ([System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Right) $saveButton.Enabled = $false $bottomPanelP2.Controls.Add($saveButton) $statusLabel = New-Object System.Windows.Forms.Label $statusLabel.Text = "Enter a domain FQDN and click 'Connect'." $statusLabelY = $saveButtonY + 3 $statusLabel.Location = New-Object System.Drawing.Point($bottomPanelP2.Padding.Left, $statusLabelY) $statusLabel.Anchor = ([System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Left -bor [System.Windows.Forms.AnchorStyles]::Right) $statusLabel.AutoSize = $false $statusLabel.Width = $bottomPanelP2.ClientSize.Width - $saveButton.Width - $bottomPanelP2.Padding.Horizontal - 10 $bottomPanelP2.Controls.Add($statusLabel) # --- Domain Discovery (Run Once on Script Load) --- function PopulateDomainList { $domainComboBox.Items.Clear() $discoveredDomains = [System.Collections.Generic.List[string]]::new() try { $currentDomain = Get-ADDomain -ErrorAction SilentlyContinue if ($currentDomain) { $discoveredDomains.Add($currentDomain.DNSRoot) $domainComboBox.Text = $currentDomain.DNSRoot } } catch { Write-Warning "Could not get current AD domain: $($_.Exception.Message)" } try { $currentForest = Get-ADForest -ErrorAction SilentlyContinue if ($currentForest) { $forestDomains = $currentForest.Domains if ($forestDomains) { foreach ($domain in $forestDomains) { if (-not $discoveredDomains.Contains($domain)) { $discoveredDomains.Add($domain) } } } } else { Write-Warning "Could not retrieve AD Forest information." } } catch { Write-Warning "Could not get AD forest domains: $($_.Exception.Message)" } $discoveredDomains | Sort-Object | ForEach-Object { $domainComboBox.Items.Add($_) | Out-Null } Write-Host "Domain discovery finished. Found $($discoveredDomains.Count) domains." } PopulateDomainList # --- TreeView Population Logic --- function LoadSubOUs { param ( [Parameter(Mandatory=$true)] $node, [Parameter(Mandatory=$true)] [string]$TargetDC ) $parentOU = $node.Tag $node.Nodes.Clear() Write-Host "DEBUG: Loading OUs under '$parentOU' using DC '$TargetDC'" try { $subOUs = Get-ADOrganizationalUnit -Filter * -SearchBase $parentOU -SearchScope OneLevel ` -Server $TargetDC -Property DistinguishedName, Name -ErrorAction Stop | Select-Object DistinguishedName, Name | Sort-Object Name if ($subOUs -ne $null) { foreach ($OU in $subOUs) { $subNode = New-Object System.Windows.Forms.TreeNode $subNode.Text = $OU.Name $subNode.Tag = $OU.DistinguishedName $node.Nodes.Add($subNode) $hasGrandChildren = Get-ADOrganizationalUnit -Filter * -SearchBase $OU.DistinguishedName -SearchScope OneLevel -Server $TargetDC -ResultSetSize 1 -ErrorAction SilentlyContinue if ($hasGrandChildren) { $subNode.Nodes.Add("") } } } else { Write-Host "DEBUG: No sub-OUs found under '$parentOU'" } } catch { Write-Warning "Error loading OUs under '$parentOU' on DC '$TargetDC': $($_.Exception.Message)" $errorNode = New-Object System.Windows.Forms.TreeNode("Error: $($_.Exception.Message)") $errorNode.ForeColor = [System.Drawing.Color]::Red $node.Nodes.Add($errorNode) } } $treeView.add_BeforeExpand({ param($sender, $e) if ([string]::IsNullOrEmpty($global:CurrentTargetDC)) { $e.Cancel = $true; return } $expandedNode = $e.Node if (($expandedNode.Nodes.Count -eq 1 -and $expandedNode.Nodes[0].Text -eq "") ` -or ($expandedNode.Nodes.Count -eq 1 -and $expandedNode.Nodes[0].Text -like "Error:*")) { $treeView.BeginUpdate() LoadSubOUs -node $expandedNode -TargetDC $global:CurrentTargetDC $treeView.EndUpdate() } }) # --- Connect Domain Button Action --- $connectDomainButton.Add_Click({ $selectedDomain = $domainComboBox.Text.Trim() if ([string]::IsNullOrWhiteSpace($selectedDomain)) { [System.Windows.Forms.MessageBox]::Show("Please enter or select a valid domain FQDN.", "Input Required", [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning) return } $statusLabel.Text = "Connecting to domain '$selectedDomain'..." $form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor $connectDomainButton.Enabled = $false $loadUsersButton.Enabled = $false $saveButton.Enabled = $false $toggleTreeViewButton.Enabled = $false # Disable toggle button # Top panel toggle remains enabled $treeView.Enabled = $false $treeView.Visible = $false # Ensure hidden during connect $treeView.Nodes.Clear() $dataGridView.DataSource = $null $dataGridView.Rows.Clear() $dataGridView.Columns.Clear() $global:OriginalUsers.Clear() $global:CurrentTargetDomainFQDN = $null $global:CurrentTargetDC = $null $global:CurrentDomainDN = $null # Ensure Top Panel is visible when connecting if (-not $topPanelP1.Visible) { $topPanelP1.Visible = $true $toggleTopPanelButton.Text = "Hide Top" } try { Write-Host "Attempting to find DC for domain: $selectedDomain" $targetDCInfo = Get-ADDomainController -DomainName $selectedDomain -Discover -ForceDiscover -ErrorAction Stop if ($targetDCInfo) { $firstDC = $targetDCInfo | Select-Object -First 1 $hostnameValue = $firstDC.HostName if ($hostnameValue -ne $null) { $global:CurrentTargetDC = [string]$hostnameValue if ([string]::IsNullOrWhiteSpace($global:CurrentTargetDC)) { throw "Failed to retrieve a valid HostName string from discovered Domain Controller for '$selectedDomain'." } } else { throw "Discovered Domain Controller object for '$selectedDomain' does not have a valid HostName property." } } else { throw "Get-ADDomainController did not return information for '$selectedDomain'." } Write-Host "Attempting to get domain info for: $selectedDomain using DC: '$($global:CurrentTargetDC)'" $domainInfo = Get-ADDomain -Identity $selectedDomain -Server $global:CurrentTargetDC -ErrorAction Stop $global:CurrentTargetDomainFQDN = $domainInfo.DNSRoot $global:CurrentDomainDN = $domainInfo.DistinguishedName Write-Host "Successfully connected to domain '$($global:CurrentTargetDomainFQDN)' via DC '$($global:CurrentTargetDC)'" $statusLabel.Text = "Connected to '$($global:CurrentTargetDomainFQDN)'. Select an OU." $rootNodeText = if (-not [string]::IsNullOrWhiteSpace($domainInfo.NetBIOSName)) { "$($domainInfo.NetBIOSName) ($($global:CurrentDomainDN))" } else { $global:CurrentDomainDN } $domainNode = New-Object System.Windows.Forms.TreeNode($rootNodeText) $domainNode.Tag = $global:CurrentDomainDN $treeView.Nodes.Add($domainNode) $hasRootOUs = Get-ADOrganizationalUnit -Filter * -SearchBase $global:CurrentDomainDN -SearchScope OneLevel -Server $global:CurrentTargetDC -ResultSetSize 1 -ErrorAction SilentlyContinue if ($hasRootOUs) { $domainNode.Nodes.Add("") } $treeView.Enabled = $true $treeView.Visible = $true # Show TreeView on successful connect $toggleTreeViewButton.Enabled = $true # Enable toggle button $toggleTreeViewButton.Text = "Hide OUs" # Set button text $treeView.Focus() } catch { $errMsg = "Failed to connect to domain '$selectedDomain'. Error: $($_.Exception.Message)" Write-Error $errMsg [System.Windows.Forms.MessageBox]::Show("$errMsg`n`nPlease check the domain name and network connectivity/permissions.", "Connection Error", [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error) $statusLabel.Text = "Connection failed. Enter domain and click 'Connect'." $treeView.Nodes.Clear() $treeView.Visible = $false # Ensure hidden on error $toggleTreeViewButton.Enabled = $false # Disable toggle button $toggleTreeViewButton.Text = "Show OUs" # Reset button text } finally { $connectDomainButton.Enabled = $true $form.Cursor = [System.Windows.Forms.Cursors]::Default } }) # --- Toggle TreeView Button Action --- $toggleTreeViewButton.Add_Click({ if ($treeView.Visible) { $treeView.Visible = $false $toggleTreeViewButton.Text = "Show OUs" } else { $treeView.Visible = $true $treeView.Refresh() # Refresh just in case $toggleTreeViewButton.Text = "Hide OUs" } }) # --- Toggle Top Panel Button Action --- $toggleTopPanelButton.Add_Click({ if ($topPanelP1.Visible) { $topPanelP1.Visible = $false $toggleTopPanelButton.Text = "Show Top" } else { $topPanelP1.Visible = $true $toggleTopPanelButton.Text = "Hide Top" } # Optional: Force redraw of container panel if needed # $splitContainer.Panel1.Refresh() # Usually not needed with this layout }) # --- TreeView Node Selection Change --- $treeView.Add_AfterSelect({ param($sender, $e) if ($treeView.SelectedNode -ne $null -and $treeView.SelectedNode.Tag -is [string] -and $treeView.SelectedNode.Tag -like '*,DC=*') { $loadUsersButton.Enabled = $true $statusLabel.Text = "OU '$($treeView.SelectedNode.Text)' selected. Click 'Load Users'." } else { $loadUsersButton.Enabled = $false if($global:CurrentTargetDomainFQDN) { $statusLabel.Text = "Select a valid OU in '$($global:CurrentTargetDomainFQDN)'." } else { $statusLabel.Text = "Connect to a domain first." } } if ($dataGridView.Rows.Count -gt 0) { $dataGridView.DataSource = $null $dataGridView.Rows.Clear() $dataGridView.Columns.Clear() $global:OriginalUsers.Clear() $saveButton.Enabled = $false } }) # --- Function to Load Users into DataGridView --- function Load-UsersToGrid { param( [Parameter(Mandatory=$true)] [string]$SelectedOU, [Parameter(Mandatory=$true)] [string]$TargetDC ) $selectedNodeText = $treeView.SelectedNode.Text $statusLabel.Text = "Loading users from '$selectedNodeText' in '$($global:CurrentTargetDomainFQDN)'..." $form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor $saveButton.Enabled = $false $loadUsersButton.Enabled = $false $connectDomainButton.Enabled = $false $treeView.Enabled = $false $toggleTreeViewButton.Enabled = $false # Top Panel toggle remains enabled $dataGridView.DataSource = $null $dataGridView.Rows.Clear() $dataGridView.Columns.Clear() $global:OriginalUsers.Clear() # Define Columns $dataGridView.Columns.Add("SamAccountName", "Account Name") | Out-Null; $dataGridView.Columns["SamAccountName"].MinimumWidth = 120; $dataGridView.Columns["SamAccountName"].ReadOnly = $true; $dataGridView.Columns["SamAccountName"].DefaultCellStyle.BackColor = [System.Drawing.Color]::LightGray $dataGridView.Columns.Add("DisplayName", "Display Name") | Out-Null; $dataGridView.Columns["DisplayName"].MinimumWidth = 150 $dataGridView.Columns.Add("Description", "Description") | Out-Null; $dataGridView.Columns["Description"].MinimumWidth = 200 $dataGridView.Columns.Add("Office", "Office") | Out-Null; $dataGridView.Columns["Office"].MinimumWidth = 80 $dataGridView.Columns.Add("Title", "Title") | Out-Null; $dataGridView.Columns["Title"].MinimumWidth = 150 $dataGridView.Columns.Add("Department", "Department") | Out-Null; $dataGridView.Columns["Department"].MinimumWidth = 120 $dataGridView.Columns.Add("Company", "Company") | Out-Null; $dataGridView.Columns["Company"].MinimumWidth = 120 $dataGridView.Columns.Add("EmailAddress", "Email Address") | Out-Null; $dataGridView.Columns["EmailAddress"].MinimumWidth = 180 $dataGridView.Columns.Add("StreetAddress", "Street Address") | Out-Null; $dataGridView.Columns["StreetAddress"].MinimumWidth = 200 $dataGridView.Columns.Add("City", "City") | Out-Null; $dataGridView.Columns["City"].MinimumWidth = 100 $dataGridView.Columns.Add("State", "State/Province") | Out-Null; $dataGridView.Columns["State"].MinimumWidth = 80 $dataGridView.Columns.Add("PostalCode", "Postal Code") | Out-Null; $dataGridView.Columns["PostalCode"].MinimumWidth = 80 $dataGridView.Columns.Add("Country", "Country/Region") | Out-Null; $dataGridView.Columns["Country"].MinimumWidth = 100 $adProperties = @("SamAccountName", "DisplayName", "Description", "Office", "Title", "Department", "Company", "EmailAddress", "Enabled", "UserPrincipalName", "StreetAddress", "l", "st", "postalCode", "co") $propertyMap = @{'l' = 'City'; 'st' = 'State'; 'postalCode' = 'PostalCode'; 'co' = 'Country'} Write-Host "Retrieving users from '$SelectedOU' using DC '$TargetDC'..." try { $Users = Get-ADUser -Filter * -SearchBase $SelectedOU -Properties $adProperties -Server $TargetDC -SearchScope Subtree -ErrorAction Stop | Where-Object { $_.Enabled -eq $true -and (-not [string]::IsNullOrWhiteSpace($_.UserPrincipalName)) } | Sort-Object Name Write-Host "Found $($Users.Count) enabled users with UPN." $statusLabel.Text = "Loading $($Users.Count) users..." $dataGridView.SuspendLayout() foreach ($User in $Users) { $rowData = @([string]$User.SamAccountName, [string]$User.DisplayName, [string]$User.Description, [string]$User.Office, [string]$User.Title, [string]$User.Department, [string]$User.Company, [string]$User.EmailAddress, [string]$User.StreetAddress, [string]$User.l, [string]$User.st, [string]$User.postalCode, [string]$User.co) $rowIndex = $dataGridView.Rows.Add($rowData) if (-not $global:OriginalUsers.ContainsKey($User.SamAccountName)) { $global:OriginalUsers.Add($User.SamAccountName, $User) } else { Write-Warning "Duplicate SamAccountName skipped: $($User.SamAccountName)" } } $dataGridView.ResumeLayout() $statusLabel.Text = "Loaded $($Users.Count) users from '$selectedNodeText'. Edit cells in non-gray columns." if ($Users.Count -gt 0) { $saveButton.Enabled = $true } else { $statusLabel.Text = "No enabled users with a UPN found in '$selectedNodeText' for '$($global:CurrentTargetDomainFQDN)'." } $dataGridView.AutoResizeColumns([System.Windows.Forms.DataGridViewAutoSizeColumnsMode]::AllCells) } catch { Write-Error "Failed to retrieve AD users from '$SelectedOU' on DC '$TargetDC'. Error: $($_.Exception.Message)" [System.Windows.Forms.MessageBox]::Show("Failed to retrieve AD users.`n$($_.Exception.Message)", "Load Error", [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error) $statusLabel.Text = "Error loading users from '$selectedNodeText'." } finally { $form.Cursor = [System.Windows.Forms.Cursors]::Default $connectDomainButton.Enabled = $true $treeView.Enabled = $true # Re-enable TreeView $toggleTreeViewButton.Enabled = $true # Re-enable toggle button $loadUsersButton.Enabled = ($treeView.SelectedNode -ne $null -and $treeView.SelectedNode.Tag -is [string] -and $treeView.SelectedNode.Tag -like '*,DC=*') } } # --- Load Users Button Action --- $loadUsersButton.Add_Click({ if ($treeView.SelectedNode -ne $null) { $selectedOUPath = $treeView.SelectedNode.Tag if (($selectedOUPath -is [string]) -and $selectedOUPath -like '*,DC=*') { if ([string]::IsNullOrEmpty($global:CurrentTargetDC)) { [System.Windows.Forms.MessageBox]::Show("Not connected to a domain DC. Please connect first.", "Connection Error", [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error); return } Load-UsersToGrid -SelectedOU $selectedOUPath -TargetDC $global:CurrentTargetDC } else { [System.Windows.Forms.MessageBox]::Show("The selected node does not appear to be a valid OU.", "Selection Error", [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning); $statusLabel.Text = "Select a valid OU." } } else { [System.Windows.Forms.MessageBox]::Show("Please select an OU from the tree first.", "Selection Required", [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information); $statusLabel.Text = "Select an OU." } }) # --- Save Changes Button Action --- Function AreEqual($val1, $val2) { return ([string]$val1 -eq [string]$val2) } $saveButton.Add_Click({ if ([string]::IsNullOrEmpty($global:CurrentTargetDC)) { [System.Windows.Forms.MessageBox]::Show("Not connected to a domain DC. Cannot save changes.", "Connection Error", [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error); return } $dataGridView.EndEdit(); if($dataGridView.CurrentCell -ne $null) { $dataGridView.CurrentCell = $null } $statusLabel.Text = "Saving changes to '$($global:CurrentTargetDomainFQDN)' via DC '$($global:CurrentTargetDC)'... Please wait." $form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor $saveButton.Enabled = $false $loadUsersButton.Enabled = $false $connectDomainButton.Enabled = $false $treeView.Enabled = $false $toggleTreeViewButton.Enabled = $false $toggleTopPanelButton.Enabled = $false # Disable top panel toggle during save $domainComboBox.Enabled = $false $changesMade = 0; $errorsOccurred = 0; $usersProcessed = 0 $errorDetails = [System.Collections.Generic.List[string]]::new() $dataGridView.SuspendLayout() $attributeMap = @{ "DisplayName" = "DisplayName"; "Description" = "Description"; "Office" = "Office"; "Title" = "Title"; "Department" = "Department"; "Company" = "Company"; "EmailAddress" = "EmailAddress"; "StreetAddress" = "StreetAddress"; "City" = "l"; "State" = "st"; "PostalCode" = "postalCode"; "Country" = "co" } $editableGridColumns = $attributeMap.Keys $adPropertiesForRefresh = @("SamAccountName", "DisplayName", "Description", "Office", "Title", "Department", "Company", "EmailAddress", "Enabled", "UserPrincipalName", "StreetAddress", "l", "st", "postalCode", "co") foreach ($row in $dataGridView.Rows) { if ($row.IsNewRow) { continue }; $usersProcessed++ # Reset color if ($row.DefaultCellStyle.BackColor -ne [System.Drawing.Color]::LightGreen -and $row.DefaultCellStyle.BackColor -ne [System.Drawing.Color]::LightCoral -and $row.DefaultCellStyle.BackColor -ne [System.Drawing.Color]::LightYellow) { if ($row.Index % 2 -eq 0) { $row.DefaultCellStyle.BackColor = $dataGridView.DefaultCellStyle.BackColor } else { $row.DefaultCellStyle.BackColor = $dataGridView.AlternatingRowsDefaultCellStyle.BackColor } $row.DefaultCellStyle.SelectionBackColor = $dataGridView.DefaultCellStyle.SelectionBackColor } $samAccountNameCell = $row.Cells["SamAccountName"] if($samAccountNameCell -eq $null -or $samAccountNameCell.Value -eq $null -or [string]::IsNullOrWhiteSpace($samAccountNameCell.Value)) { Write-Warning "Skipping row $usersProcessed - missing SamAccountName."; $errorDetails.Add("Row $($row.Index + 1): Skipped (Missing SamAccountName)"); $errorsOccurred++; $row.DefaultCellStyle.BackColor = [System.Drawing.Color]::LightYellow; continue } $samAccountName = $samAccountNameCell.Value if (-not $global:OriginalUsers.ContainsKey($samAccountName)) { Write-Warning "Skipping user '$samAccountName' - original data not found in cache."; $errorDetails.Add("User '$samAccountName': Skipped (Original data missing)"); $errorsOccurred++; $row.DefaultCellStyle.BackColor = [System.Drawing.Color]::LightYellow; continue } $originalUser = $global:OriginalUsers[$samAccountName] $setParams = @{ Identity = $samAccountName; Server = $global:CurrentTargetDC } $clearParams = @(); $replaceParams = @{}; $userHasChanges = $false foreach ($gridColName in $editableGridColumns) { $adAttributeName = $attributeMap[$gridColName]; $gridCell = $row.Cells[$gridColName] if ($gridCell -ne $null) { $gridValue = $gridCell.Value; $originalValue = $null if ($originalUser.PSObject.Properties[$adAttributeName] -ne $null) { $originalValue = $originalUser.$adAttributeName } if (-not (AreEqual $gridValue $originalValue)) { Write-Host "Change detected for '$samAccountName', Attribute '$adAttributeName' (Grid: '$gridColName'): '$originalValue' -> '$gridValue'" if ($gridValue -eq $null -or [string]::IsNullOrEmpty([string]$gridValue)) { if ($originalValue -ne $null -and (-not [string]::IsNullOrEmpty([string]$originalValue))) { $clearParams += $adAttributeName; $userHasChanges = $true; Write-Host " -> Queued for clearing" } } else { $replaceParams[$adAttributeName] = $gridValue; $userHasChanges = $true; Write-Host " -> Queued for replacement" } } } else { Write-Warning "Grid Column '$gridColName' not found in DataGridView for row $usersProcessed ($samAccountName)." } } # End foreach column if ($userHasChanges) { if ($clearParams.Count -gt 0) { $setParams.Add("Clear", $clearParams) } if ($replaceParams.Count -gt 0) { $setParams.Add("Replace", $replaceParams) } if ($setParams.Count -gt 2) { # Check if there's actually something to set (Identity+Server + Clear/Replace) Write-Host "Attempting to update user '$samAccountName' on DC '$($global:CurrentTargetDC)' with params:"; Write-Host ($setParams | Format-List | Out-String) try { Set-ADUser @setParams -ErrorAction Stop; $changesMade++ # Refresh the original user data after successful save $global:OriginalUsers[$samAccountName] = Get-ADUser -Identity $samAccountName -Properties $adPropertiesForRefresh -Server $global:CurrentTargetDC -ErrorAction Stop $row.DefaultCellStyle.BackColor = [System.Drawing.Color]::LightGreen } catch { $errMsg = "Failed to update user '$samAccountName' on DC '$($global:CurrentTargetDC)'. Error: $($_.Exception.Message -replace "`n"," ")" Write-Error $errMsg; $errorDetails.Add("User '$samAccountName': $errMsg"); $row.DefaultCellStyle.BackColor = [System.Drawing.Color]::LightCoral; $errorsOccurred++ } } else { Write-Host "Skipping Set-ADUser for '$samAccountName' as no effective changes were detected after comparing with original values." } } else { Write-Host "No changes detected for user '$samAccountName'." # Reset row color if it was previously yellow or red but no changes were made this time if ($row.DefaultCellStyle.BackColor -eq [System.Drawing.Color]::LightYellow -or $row.DefaultCellStyle.BackColor -eq [System.Drawing.Color]::LightCoral) { if ($row.Index % 2 -eq 0) { $row.DefaultCellStyle.BackColor = $dataGridView.DefaultCellStyle.BackColor } else { $row.DefaultCellStyle.BackColor = $dataGridView.AlternatingRowsDefaultCellStyle.BackColor } } } } # End foreach row $dataGridView.ResumeLayout(); $dataGridView.Refresh() $finalMsg = "Save complete for domain '$($global:CurrentTargetDomainFQDN)'.`nProcessed: $usersProcessed | Updated: $changesMade | Errors: $errorsOccurred" if ($errorsOccurred -gt 0) { $detailedErrors = $errorDetails -join "`n"; if ($detailedErrors.Length -gt 1000) { $detailedErrors = $detailedErrors.Substring(0, 1000) + "... (See console/log for full details)" } $finalMsg += "`n`nError Details:`n" + $detailedErrors [System.Windows.Forms.MessageBox]::Show($finalMsg, "Save Result (with Errors)", [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning) } else { [System.Windows.Forms.MessageBox]::Show($finalMsg, "Save Result", [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information) } $statusLabel.Text = "Save complete for '$($global:CurrentTargetDomainFQDN)'. Ready." # Re-enable controls after save $form.Cursor = [System.Windows.Forms.Cursors]::Default $connectDomainButton.Enabled = $true $domainComboBox.Enabled = $true $treeView.Enabled = $true $toggleTreeViewButton.Enabled = $true $toggleTopPanelButton.Enabled = $true # Re-enable top panel toggle $loadUsersButton.Enabled = ($treeView.SelectedNode -ne $null -and $treeView.SelectedNode.Tag -is [string] -and $treeView.SelectedNode.Tag -like '*,DC=*') $saveButton.Enabled = ($dataGridView.Rows.Count -gt 0) # Re-enable save only if there are still rows }) # End Save Button Click # --- Form Closing Event --- $form.Add_FormClosing({ param($sender, $e) $unsavedChangesExist = $false if ($saveButton.Enabled -and $dataGridView.Rows.Count -gt 0 -and $global:OriginalUsers.Count -gt 0) { Write-Host "Checking for unsaved changes before closing..." $attributeMapCheck = @{ "DisplayName" = "DisplayName"; "Description" = "Description"; "Office" = "Office"; "Title" = "Title"; "Department" = "Department"; "Company" = "Company"; "EmailAddress" = "EmailAddress"; "StreetAddress" = "StreetAddress"; "City" = "l"; "State" = "st"; "PostalCode" = "postalCode"; "Country" = "co" } $editableGridColumnsCheck = $attributeMapCheck.Keys foreach ($row in $dataGridView.Rows) { if ($row.IsNewRow) { continue } $samAccountNameCellCheck = $row.Cells["SamAccountName"]; if($samAccountNameCellCheck -eq $null -or $samAccountNameCellCheck.Value -eq $null) { continue } $samAccountNameCheck = $samAccountNameCellCheck.Value if ($global:OriginalUsers.ContainsKey($samAccountNameCheck)) { $originalUserCheck = $global:OriginalUsers[$samAccountNameCheck] foreach ($gridColNameCheck in $editableGridColumnsCheck) { $adAttributeNameCheck = $attributeMapCheck[$gridColNameCheck]; $gridCellCheck = $row.Cells[$gridColNameCheck] if ($gridCellCheck -ne $null) { $gridValueCheck = $gridCellCheck.Value; $originalValueCheck = $null # Need to handle potential null property on original object if ($originalUserCheck.PSObject.Properties[$adAttributeNameCheck] -ne $null) { $originalValueCheck = $originalUserCheck.$adAttributeNameCheck } if (-not (AreEqual $gridValueCheck $originalValueCheck)) { $unsavedChangesExist = $true; Write-Host "Unsaved change detected for $samAccountNameCheck, Attribute $adAttributeNameCheck."; break } } } } if ($unsavedChangesExist) { break } } Write-Host "Finished checking for unsaved changes. Found: $unsavedChangesExist" } if ($unsavedChangesExist) { $result = [System.Windows.Forms.MessageBox]::Show("You have unsaved changes. Are you sure you want to exit without saving?", "Confirm Exit", [System.Windows.Forms.MessageBoxButtons]::YesNo, [System.Windows.Forms.MessageBoxIcon]::Warning, [System.Windows.Forms.MessageBoxDefaultButton]::Button2) if ($result -eq [System.Windows.Forms.DialogResult]::No) { $e.Cancel = $true } } }) # --- Form Shown Event to Correct SplitterDistance --- $form.Add_Shown({ param($sender, $e) # Optional parameters, but good practice Write-Host "DEBUG: Form_Shown event executing." try { # --- SET THE DESIRED SPLITTER DISTANCE HERE --- # Change this value to your preferred initial width for the left panel $desiredSplitterDistance = 350 # Default: 350. Try 400 or 450 for wider. # --- # Only set it if it's not already correct (optional, but prevents redundant sets) if ($splitContainer.SplitterDistance -ne $desiredSplitterDistance) { # Basic validation: ensure distance isn't too large or small if ($desiredSplitterDistance -gt 50 -and $desiredSplitterDistance -lt ($form.Width - 100)) { Write-Host "DEBUG: Current SplitterDistance is $($splitContainer.SplitterDistance). Setting to $desiredSplitterDistance." $splitContainer.SplitterDistance = $desiredSplitterDistance Write-Host "DEBUG: SplitterDistance hopefully set. Current value now: $($splitContainer.SplitterDistance)" } else { Write-Warning "DEBUG: Desired SplitterDistance ($desiredSplitterDistance) is outside reasonable bounds for form width ($($form.Width)). Using default behavior." } } else { Write-Host "DEBUG: SplitterDistance ($($splitContainer.SplitterDistance)) already matches desired value ($desiredSplitterDistance)." } # Ensure the form is active (often needed after manipulating controls in Shown event) $sender.Activate() # $sender refers to the $form in this context } catch { Write-Warning "Error setting SplitterDistance in Form_Shown: $($_.Exception.Message)" } }) # --- Show Form --- Write-Host "Showing AD User Editor form... (Context: Bexley, NSW - $(Get-Date -Format 'yyyy-MM-dd HH:mm K'))" [System.Windows.Forms.Application]::EnableVisualStyles() $form.ShowDialog() | Out-Null # --- Cleanup --- Write-Host "Closing form and cleaning up resources..." try { $form.Dispose() } catch { Write-Warning "Error disposing form: $($_.Exception.Message)"} Write-Host "Script finished."