Public/Core/Reset-IDBridgeADPassword.ps1

<#
.SYNOPSIS
GUI tool: multi-select AD org units and reset every account's password to its Keysmith passphrase.

.DESCRIPTION
Operations tool, run by hand — never called by the pipeline. Opens a Windows Forms window with
the domain's OU tree (checkboxes; checking an OU checks everything under it), the Keysmith
settings (nonce and API token — picked from the vault by secret name, with masked manual entry
as a fallback — plus mode, word count, and an optional word-list rev to regenerate an earlier
era's passphrases; leave rev blank for the server's latest), and the run options. "Load Users"
lists every matching account in the checked OUs for review; "Reset Passwords" asks for explicit
confirmation with the exact account count, then requests the deterministic passphrases in bulk
(Get-ADUsersToResetPassword -> New-Passphrase) and applies Set-ADAccountPassword -Reset per
account, optionally also setting ChangePasswordAtLogon.

Because Keysmith passphrases are deterministic, the phrases never need to be stored — anyone
with the nonce can regenerate them at keysmith.scnlabs.net. Optionally the run can export a
name/username/passphrase/OU CSV to the Exports folder for handout (off by default). "Export
Passphrases" instead writes that same CSV for the loaded users WITHOUT touching AD —
regenerating what the current passwords already are, e.g. for login slips. The exported
phrases match the accounts' current passwords only when the nonce, mode, word count, and
word-list rev match what the passwords were last set with — pin the rev for accounts from an
earlier era. Passphrases are never written to the log.

This tool writes to AD when confirmed regardless of Debug.readOnly — it is interactive and gated
by its own confirmation dialog instead (the pipeline's ReadOnly/ChangeThreshold gates protect
unattended runs, which this is not). Requires Windows (WinForms), an STA session (the pwsh
default), and the ActiveDirectory module (AD.enabled).

.PARAMETER RootPath
Base dir for Config/Logs/Exports/Plugins/Data/Vault. Defaults to C:\IDBridge.

.OUTPUTS
[pscustomobject] @{ Total; Succeeded; Failed; ExportPath } for the last reset run (zeroes when
the window is closed without running one; an export-only run fills ExportPath only).

.EXAMPLE
Reset-IDBridgeADPassword

.EXAMPLE
Reset-IDBridgeADPassword -RootPath 'C:\IDBridge'

.NOTES
   Created by: Sam Cattanach
   Modified: 2026-08-31
#>

function Reset-IDBridgeADPassword {
    [CmdletBinding()]
    param (
        [string]$RootPath = "C:\IDBridge"
    )

    #region Environment Guards
    if (-not $IsWindows) {
        Throw "Reset-IDBridgeADPassword requires Windows - the GUI uses Windows Forms."
    }

    if ([System.Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') {
        Throw "Reset-IDBridgeADPassword requires an STA session (the pwsh default). Start pwsh without -MTA."
    }

    try {
        Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
        Add-Type -AssemblyName System.Drawing -ErrorAction Stop
    }
    catch {
        Throw "Reset-IDBridgeADPassword could not load Windows Forms: $_"
    }
    #endregion Environment Guards



    #region Import Configuration
    try { Initialize-IDBridge -RootPath $RootPath } catch { Throw }

    try { $IDConfig = Get-IDBridgeConfig } catch { Throw }

    if ($IDConfig.AD.enabled -ne $true -or -not (Get-Command Get-ADUser -ErrorAction SilentlyContinue)) {
        Write-Log -Message "Reset: AD is disabled or the ActiveDirectory module is unavailable - nothing to reset." -Level Error
        Throw "Reset: AD is disabled or the ActiveDirectory module is unavailable - nothing to reset."
    }
    #endregion Import Configuration



    #region Fetch Org Units
    try {
        Write-Log -Message "Reset: Fetching Org Units" -Level Trace
        $orgUnits = @(Get-ADOrganizationalUnit -LDAPFilter '(name=*)' | Select-Object -ExpandProperty DistinguishedName)

        if ($orgUnits.Count -eq 0) {
            Throw "Reset: Connected to AD but no org units fetched"
        }
    }
    catch {
        Write-Log -Message "Reset: No org units fetched" -Level Error
        Throw $_
    }
    #endregion Fetch Org Units



    #region Build Form
    $ManualEntry = '(enter manually)'

    #Shared mutable state for the event handlers (a hashtable so handlers can write to it -
    #plain variable assignment inside a handler would only create a handler-local copy)
    $state = @{
        Users     = @()
        ItemByDN  = @{}
        Total     = 0
        Succeeded = 0
        Failed    = 0
        ExportPath = $null
    }

    [System.Windows.Forms.Application]::EnableVisualStyles()

    $form = [System.Windows.Forms.Form]::new()
    $form.Text = "IDBridge - AD Password Reset"
    $form.Size = [System.Drawing.Size]::new(1010, 680)
    $form.StartPosition = 'CenterScreen'
    $form.FormBorderStyle = 'FixedDialog'
    $form.MaximizeBox = $false

    $lblTree = [System.Windows.Forms.Label]::new()
    $lblTree.Text = "Check the org units to reset:"
    $lblTree.Location = [System.Drawing.Point]::new(10, 10)
    $lblTree.AutoSize = $true
    $form.Controls.Add($lblTree)

    $treeView = [System.Windows.Forms.TreeView]::new()
    $treeView.Location = [System.Drawing.Point]::new(10, 32)
    $treeView.Size = [System.Drawing.Size]::new(375, 600)
    $treeView.CheckBoxes = $true
    $form.Controls.Add($treeView)

    #Checking an OU checks everything under it. Programmatic sets re-fire AfterCheck with
    #Action Unknown, which the guard skips - only the user's own click propagates.
    $treeView.add_AfterCheck({
        param($senderObj, $e)
        if ($e.Action -ne [System.Windows.Forms.TreeViewAction]::Unknown) {
            $stack = [System.Collections.Stack]::new()
            $stack.Push($e.Node)
            while ($stack.Count -gt 0) {
                foreach ($child in $stack.Pop().Nodes) {
                    $child.Checked = $e.Node.Checked
                    $stack.Push($child)
                }
            }
        }
    })

    #Build the OU tree from the DN list - parents first (sorted by DN depth), each node keyed
    #by DN so children find their parent; an OU whose parent is not an OU (domain root,
    #containers) becomes a top-level node. The first-RDN regex honors escaped commas.
    $nodeByDN = @{}
    foreach ($dn in ($orgUnits | Sort-Object { ($_ -split '(?<!\\),').Count }, { $_ })) {
        $node = [System.Windows.Forms.TreeNode]::new()
        $node.Text = ([regex]::Match($dn, '^OU=((?:\\.|[^,\\])*)').Groups[1].Value -replace '\\(.)', '$1')
        $node.Tag = $dn

        $parentDN = $dn -replace '^OU=(?:\\.|[^,\\])*,', ''
        if ($nodeByDN.ContainsKey($parentDN)) {
            $nodeByDN[$parentDN].Nodes.Add($node) | Out-Null
        } else {
            $treeView.Nodes.Add($node) | Out-Null
        }
        $nodeByDN[$dn] = $node
    }

    #Keysmith settings
    $grpKeysmith = [System.Windows.Forms.GroupBox]::new()
    $grpKeysmith.Text = "Keysmith"
    $grpKeysmith.Location = [System.Drawing.Point]::new(400, 10)
    $grpKeysmith.Size = [System.Drawing.Size]::new(590, 165)
    $form.Controls.Add($grpKeysmith)

    $lblNonce = [System.Windows.Forms.Label]::new()
    $lblNonce.Text = "Nonce secret:"
    $lblNonce.Location = [System.Drawing.Point]::new(10, 28)
    $lblNonce.AutoSize = $true
    $grpKeysmith.Controls.Add($lblNonce)

    $cboNonce = [System.Windows.Forms.ComboBox]::new()
    $cboNonce.Location = [System.Drawing.Point]::new(120, 24)
    $cboNonce.Size = [System.Drawing.Size]::new(255, 23)
    $cboNonce.Items.AddRange(@('ApiKey-PassphraseNonceStudent', 'ApiKey-PassphraseNonceStaff', $ManualEntry))
    $cboNonce.SelectedIndex = 0
    $grpKeysmith.Controls.Add($cboNonce)

    $txtNonce = [System.Windows.Forms.TextBox]::new()
    $txtNonce.Location = [System.Drawing.Point]::new(120, 54)
    $txtNonce.Size = [System.Drawing.Size]::new(255, 23)
    $txtNonce.UseSystemPasswordChar = $true
    $txtNonce.Enabled = $false
    $grpKeysmith.Controls.Add($txtNonce)

    $lblToken = [System.Windows.Forms.Label]::new()
    $lblToken.Text = "Token secret:"
    $lblToken.Location = [System.Drawing.Point]::new(10, 92)
    $lblToken.AutoSize = $true
    $grpKeysmith.Controls.Add($lblToken)

    $cboToken = [System.Windows.Forms.ComboBox]::new()
    $cboToken.Location = [System.Drawing.Point]::new(120, 88)
    $cboToken.Size = [System.Drawing.Size]::new(255, 23)
    $cboToken.Items.AddRange(@('ApiKey-Passphrase', $ManualEntry))
    $cboToken.SelectedIndex = 0
    $grpKeysmith.Controls.Add($cboToken)

    $txtToken = [System.Windows.Forms.TextBox]::new()
    $txtToken.Location = [System.Drawing.Point]::new(120, 118)
    $txtToken.Size = [System.Drawing.Size]::new(255, 23)
    $txtToken.UseSystemPasswordChar = $true
    $txtToken.Enabled = $false
    $grpKeysmith.Controls.Add($txtToken)

    #The manual textboxes only unlock when '(enter manually)' is picked; any other text is
    #treated as a vault secret name (the defaults are what the shipped plugins use)
    $cboNonce.add_TextChanged({ $txtNonce.Enabled = ($cboNonce.Text -eq $ManualEntry) })
    $cboToken.add_TextChanged({ $txtToken.Enabled = ($cboToken.Text -eq $ManualEntry) })

    $lblMode = [System.Windows.Forms.Label]::new()
    $lblMode.Text = "Mode:"
    $lblMode.Location = [System.Drawing.Point]::new(400, 28)
    $lblMode.AutoSize = $true
    $grpKeysmith.Controls.Add($lblMode)

    $cboMode = [System.Windows.Forms.ComboBox]::new()
    $cboMode.Location = [System.Drawing.Point]::new(480, 24)
    $cboMode.Size = [System.Drawing.Size]::new(100, 23)
    $cboMode.DropDownStyle = 'DropDownList'
    $cboMode.Items.AddRange(@('words', 'verbnoun'))
    $cboMode.SelectedIndex = 0
    $grpKeysmith.Controls.Add($cboMode)

    $lblWordCount = [System.Windows.Forms.Label]::new()
    $lblWordCount.Text = "Words:"
    $lblWordCount.Location = [System.Drawing.Point]::new(400, 60)
    $lblWordCount.AutoSize = $true
    $grpKeysmith.Controls.Add($lblWordCount)

    $numWordCount = [System.Windows.Forms.NumericUpDown]::new()
    $numWordCount.Location = [System.Drawing.Point]::new(480, 56)
    $numWordCount.Size = [System.Drawing.Size]::new(100, 23)
    $numWordCount.Minimum = 2
    $numWordCount.Maximum = 6
    $numWordCount.Value = 3
    $grpKeysmith.Controls.Add($numWordCount)

    $cboMode.add_SelectedIndexChanged({ $numWordCount.Enabled = ($cboMode.Text -eq 'words') })

    $lblRev = [System.Windows.Forms.Label]::new()
    $lblRev.Text = "Rev (blank = latest):"
    $lblRev.Location = [System.Drawing.Point]::new(400, 92)
    $lblRev.AutoSize = $true
    $grpKeysmith.Controls.Add($lblRev)

    $txtRev = [System.Windows.Forms.TextBox]::new()
    $txtRev.Location = [System.Drawing.Point]::new(520, 88)
    $txtRev.Size = [System.Drawing.Size]::new(60, 23)
    $grpKeysmith.Controls.Add($txtRev)

    #Run options
    $grpOptions = [System.Windows.Forms.GroupBox]::new()
    $grpOptions.Text = "Options"
    $grpOptions.Location = [System.Drawing.Point]::new(400, 185)
    $grpOptions.Size = [System.Drawing.Size]::new(590, 105)
    $form.Controls.Add($grpOptions)

    $chkSubOUs = [System.Windows.Forms.CheckBox]::new()
    $chkSubOUs.Text = "Include sub-OUs"
    $chkSubOUs.Location = [System.Drawing.Point]::new(10, 25)
    $chkSubOUs.AutoSize = $true
    $chkSubOUs.Checked = $true
    $grpOptions.Controls.Add($chkSubOUs)

    $chkEnabledOnly = [System.Windows.Forms.CheckBox]::new()
    $chkEnabledOnly.Text = "Enabled accounts only"
    $chkEnabledOnly.Location = [System.Drawing.Point]::new(10, 50)
    $chkEnabledOnly.AutoSize = $true
    $chkEnabledOnly.Checked = $true
    $grpOptions.Controls.Add($chkEnabledOnly)

    $chkChangeAtLogon = [System.Windows.Forms.CheckBox]::new()
    $chkChangeAtLogon.Text = "Require password change at next logon"
    $chkChangeAtLogon.Location = [System.Drawing.Point]::new(300, 25)
    $chkChangeAtLogon.AutoSize = $true
    $grpOptions.Controls.Add($chkChangeAtLogon)

    $chkExport = [System.Windows.Forms.CheckBox]::new()
    $chkExport.Text = "Export username/passphrase CSV to Exports"
    $chkExport.Location = [System.Drawing.Point]::new(300, 50)
    $chkExport.AutoSize = $true
    $grpOptions.Controls.Add($chkExport)

    #Load + result list
    $btnLoad = [System.Windows.Forms.Button]::new()
    $btnLoad.Text = "Load Users"
    $btnLoad.Location = [System.Drawing.Point]::new(400, 300)
    $btnLoad.Size = [System.Drawing.Size]::new(120, 30)
    $form.Controls.Add($btnLoad)

    $lblCount = [System.Windows.Forms.Label]::new()
    $lblCount.Text = "No users loaded."
    $lblCount.Location = [System.Drawing.Point]::new(535, 308)
    $lblCount.AutoSize = $true
    $form.Controls.Add($lblCount)

    $listView = [System.Windows.Forms.ListView]::new()
    $listView.Location = [System.Drawing.Point]::new(400, 340)
    $listView.Size = [System.Drawing.Size]::new(590, 250)
    $listView.View = 'Details'
    $listView.FullRowSelect = $true
    $listView.Columns.Add("User", 150) | Out-Null
    $listView.Columns.Add("Username", 110) | Out-Null
    $listView.Columns.Add("Org Unit", 180) | Out-Null
    $listView.Columns.Add("Status", 130) | Out-Null
    $form.Controls.Add($listView)

    $btnReset = [System.Windows.Forms.Button]::new()
    $btnReset.Text = "Reset Passwords"
    $btnReset.Location = [System.Drawing.Point]::new(400, 600)
    $btnReset.Size = [System.Drawing.Size]::new(150, 32)
    $btnReset.Enabled = $false
    $form.Controls.Add($btnReset)

    $btnExport = [System.Windows.Forms.Button]::new()
    $btnExport.Text = "Export Passphrases"
    $btnExport.Location = [System.Drawing.Point]::new(560, 600)
    $btnExport.Size = [System.Drawing.Size]::new(160, 32)
    $btnExport.Enabled = $false
    $form.Controls.Add($btnExport)

    $btnClose = [System.Windows.Forms.Button]::new()
    $btnClose.Text = "Close"
    $btnClose.Location = [System.Drawing.Point]::new(870, 600)
    $btnClose.Size = [System.Drawing.Size]::new(120, 32)
    $btnClose.add_Click({ $form.Close() })
    $form.Controls.Add($btnClose)
    #endregion Build Form



    #region Load Users Handler
    $btnLoad.add_Click({
        $checkedDNs = @()
        $stack = [System.Collections.Stack]::new()
        foreach ($node in $treeView.Nodes) { $stack.Push($node) }
        while ($stack.Count -gt 0) {
            $node = $stack.Pop()
            if ($node.Checked) { $checkedDNs += $node.Tag }
            foreach ($child in $node.Nodes) { $stack.Push($child) }
        }

        if ($checkedDNs.Count -eq 0) {
            [System.Windows.Forms.MessageBox]::Show($form, "Check at least one org unit first.", "IDBridge", 'OK', 'Information') | Out-Null
            return
        }

        $scope = 'OneLevel'
        if ($chkSubOUs.Checked) {
            $scope = 'Subtree'
            #Checking a parent auto-checks its children - drop DNs already covered by a
            #checked ancestor so each subtree is only searched once
            $checkedDNs = @($checkedDNs | Where-Object {
                $dn = $_
                -not ($checkedDNs | Where-Object { $dn -ne $_ -and $dn.EndsWith(",$_") })
            })
        }

        $filter = '*'
        if ($chkEnabledOnly.Checked) { $filter = "Enabled -eq 'True'" }

        try {
            $found = @{}
            foreach ($dn in $checkedDNs) {
                foreach ($adUser in @(Get-ADUser -SearchBase $dn -SearchScope $scope -Filter $filter -Properties DisplayName)) {
                    $found[$adUser.DistinguishedName] = $adUser
                }
            }
        }
        catch {
            Write-Log -Message ("Reset: Failed to load users: $($_.Exception.Message)") -Level Error
            [System.Windows.Forms.MessageBox]::Show($form, "Failed to load users: $($_.Exception.Message)", "IDBridge", 'OK', 'Error') | Out-Null
            return
        }

        $state.Users = @($found.Values | Sort-Object SamAccountName)
        $state.ItemByDN = @{}

        $listView.BeginUpdate()
        $listView.Items.Clear()
        foreach ($adUser in $state.Users) {
            $item = [System.Windows.Forms.ListViewItem]::new($adUser.DisplayName)
            $item.SubItems.Add($adUser.SamAccountName) | Out-Null
            $item.SubItems.Add(($adUser.DistinguishedName -replace '^CN=(?:\\.|[^,\\])*,', '')) | Out-Null
            $item.SubItems.Add("") | Out-Null
            $listView.Items.Add($item) | Out-Null
            $state.ItemByDN[$adUser.DistinguishedName] = $item
        }
        $listView.EndUpdate()

        Write-Log -Message "Reset: Loaded $($state.Users.Count) user(s) from $($checkedDNs.Count) org unit(s) for review."
        $lblCount.Text = "$($state.Users.Count) user(s) loaded from $($checkedDNs.Count) org unit(s)."
        $btnReset.Enabled = ($state.Users.Count -gt 0)
        $btnExport.Enabled = ($state.Users.Count -gt 0)
    })
    #endregion Load Users Handler



    #region Resolve Keysmith Settings
    #Shared by the reset and export handlers: nonce/token from the vault by secret name (or
    #the masked manual entry) plus the optional rev. Returns the PassphraseAPI hashtable, or
    #$null after showing the error - the caller just returns.
    $getKeysmithApi = {
        try {
            if ($cboNonce.Text -eq $ManualEntry) {
                if ([string]::IsNullOrWhiteSpace($txtNonce.Text)) { Throw "Enter the nonce, or pick a vault secret name." }
                $nonce = ConvertTo-SecureString $txtNonce.Text -AsPlainText -Force
            } else {
                $nonce = Get-IDBridgeSecret -Name $cboNonce.Text
            }

            if ($cboToken.Text -eq $ManualEntry) {
                if ([string]::IsNullOrWhiteSpace($txtToken.Text)) { Throw "Enter the API token, or pick a vault secret name." }
                $token = ConvertTo-SecureString $txtToken.Text -AsPlainText -Force
            } else {
                $token = Get-IDBridgeSecret -Name $cboToken.Text
            }
        }
        catch {
            [System.Windows.Forms.MessageBox]::Show($form, "Keysmith settings error: $($_.Exception.Message)", "IDBridge", 'OK', 'Error') | Out-Null
            return $null
        }

        $rev = 0
        if (-not [string]::IsNullOrWhiteSpace($txtRev.Text)) {
            if (-not [int]::TryParse($txtRev.Text, [ref]$rev) -or $rev -lt 1 -or $rev -gt 99) {
                [System.Windows.Forms.MessageBox]::Show($form, "Rev must be a number from 1 to 99, or blank for the server's latest.", "IDBridge", 'OK', 'Error') | Out-Null
                return $null
            }
        }

        $passphraseAPI = @{
            Nonce = $nonce
            AuthToken = $token
            Mode = $cboMode.Text
            WordCount = [int]$numWordCount.Value
        }
        if ($rev -gt 0) { $passphraseAPI.Rev = $rev }

        return $passphraseAPI
    }
    #endregion Resolve Keysmith Settings



    #region Reset Passwords Handler
    $btnReset.add_Click({
        $passphraseAPI = & $getKeysmithApi
        if (-not $passphraseAPI) { return }

        $confirmText = "$($state.Users.Count) AD account password(s) will be RESET to their Keysmith passphrase.`n`nThis cannot be undone. Continue?"
        $answer = [System.Windows.Forms.MessageBox]::Show($form, $confirmText, "IDBridge - Confirm Password Reset", 'YesNo', 'Warning', 'Button2')
        if ($answer -ne [System.Windows.Forms.DialogResult]::Yes) { return }

        #Decide: fetch every passphrase up front - any Keysmith failure aborts before a single write
        try {
            $resets = @(Get-ADUsersToResetPassword -UserList $state.Users -PassphraseAPI $passphraseAPI)
        }
        catch {
            [System.Windows.Forms.MessageBox]::Show($form, "Keysmith request failed - no passwords were changed.`n`n$($_.Exception.Message)", "IDBridge", 'OK', 'Error') | Out-Null
            return
        }

        #Act: apply the resets
        Write-Log -Message "Reset: Applying password reset for $($resets.Count) user(s)."
        $state.Total = $resets.Count
        $state.Succeeded = 0
        $state.Failed = 0
        $state.ExportPath = $null
        $succeededItems = @()

        foreach ($resetItem in $resets) {
            $item = $state.ItemByDN[$resetItem.DistinguishedName]

            try {
                $itemSplat = $resetItem.Splat
                Set-ADAccountPassword @itemSplat
                Write-Log -Message "Reset: Applying: Reset password for $($resetItem.SamAccountName) ($($resetItem.DistinguishedName))."
            }
            catch {
                Write-Log -Message ($_.Exception.Message) -Level Error
                $item.SubItems[3].Text = "Failed: $($_.Exception.Message)"
                $state.Failed++
                continue
            }

            $item.SubItems[3].Text = "Reset"

            #The password is already reset - a failed flag write is a warning, not a failure
            if ($chkChangeAtLogon.Checked) {
                try {
                    Set-ADUser -Identity $resetItem.DistinguishedName -ChangePasswordAtLogon $true -ErrorAction Stop
                }
                catch {
                    Write-Log -Message ("Reset: Password reset for $($resetItem.SamAccountName) but ChangePasswordAtLogon could not be set: $($_.Exception.Message)") -Level Warn
                    $item.SubItems[3].Text = "Reset (logon flag failed)"
                }
            }

            $state.Succeeded++
            $succeededItems += $resetItem
        }

        #Optional handout CSV - the only place a passphrase leaves this run
        if ($chkExport.Checked -and $succeededItems.Count -gt 0) {
            try {
                $state.ExportPath = Join-Path $IDConfig.Paths.ExportsRoot ("ADPasswordReset_" + (Get-Date -Format "yyyy-MM-dd-HH.mm.ss") + ".csv")
                $succeededItems | Select-Object @{n = 'FirstName'; e = { $_.GivenName }}, @{n = 'LastName'; e = { $_.Surname }}, SamAccountName, Passphrase, @{n = 'OrgUnit'; e = { [regex]::Match(($_.DistinguishedName -replace '^CN=(?:\\.|[^,\\])*,', ''), '^OU=((?:\\.|[^,\\])*)').Groups[1].Value -replace '\\(.)', '$1' }} | Export-Csv -Path $state.ExportPath -NoTypeInformation
                Write-Log -Message "Reset: Exported $($succeededItems.Count) passphrase(s) to $($state.ExportPath). Delete the file after handout."
            }
            catch {
                Write-Log -Message ("Reset: Passphrase export failed: $($_.Exception.Message)") -Level Error
                [System.Windows.Forms.MessageBox]::Show($form, "Passphrase export failed: $($_.Exception.Message)", "IDBridge", 'OK', 'Error') | Out-Null
            }
        }

        Write-Log -Message "Reset: Password reset finished - $($state.Succeeded) succeeded, $($state.Failed) failed."

        $summary = "Password reset finished.`n`nSucceeded: $($state.Succeeded)`nFailed: $($state.Failed)"
        if ($state.ExportPath) { $summary += "`n`nPassphrases exported to:`n$($state.ExportPath)" }
        [System.Windows.Forms.MessageBox]::Show($form, $summary, "IDBridge", 'OK', 'Information') | Out-Null

        #Force a fresh Load Users before another run - the list now shows results, not a plan
        $btnReset.Enabled = $false
    })
    #endregion Reset Passwords Handler



    #region Export Passphrases Handler
    #Export-only: regenerate the loaded users' deterministic passphrases and write them to a
    #CSV with each user's OU - NOTHING is written to AD. Matches the accounts' current
    #passwords only when nonce/mode/word count/rev match what they were last set with.
    $btnExport.add_Click({
        $passphraseAPI = & $getKeysmithApi
        if (-not $passphraseAPI) { return }

        $confirmText = "$($state.Users.Count) passphrase(s) will be generated and written in PLAIN TEXT to the Exports folder.`n`nNo passwords are changed. Continue?"
        $answer = [System.Windows.Forms.MessageBox]::Show($form, $confirmText, "IDBridge - Confirm Passphrase Export", 'YesNo', 'Warning', 'Button2')
        if ($answer -ne [System.Windows.Forms.DialogResult]::Yes) { return }

        try {
            $resets = @(Get-ADUsersToResetPassword -UserList $state.Users -PassphraseAPI $passphraseAPI -ExportOnly)
        }
        catch {
            [System.Windows.Forms.MessageBox]::Show($form, "Keysmith request failed - nothing was exported.`n`n$($_.Exception.Message)", "IDBridge", 'OK', 'Error') | Out-Null
            return
        }

        try {
            $state.ExportPath = Join-Path $IDConfig.Paths.ExportsRoot ("ADPasswordExport_" + (Get-Date -Format "yyyy-MM-dd-HH.mm.ss") + ".csv")
            $resets | Select-Object @{n = 'FirstName'; e = { $_.GivenName }}, @{n = 'LastName'; e = { $_.Surname }}, SamAccountName, Passphrase, @{n = 'OrgUnit'; e = { [regex]::Match(($_.DistinguishedName -replace '^CN=(?:\\.|[^,\\])*,', ''), '^OU=((?:\\.|[^,\\])*)').Groups[1].Value -replace '\\(.)', '$1' }} | Export-Csv -Path $state.ExportPath -NoTypeInformation
        }
        catch {
            Write-Log -Message ("Reset: Passphrase export failed: $($_.Exception.Message)") -Level Error
            [System.Windows.Forms.MessageBox]::Show($form, "Passphrase export failed: $($_.Exception.Message)", "IDBridge", 'OK', 'Error') | Out-Null
            return
        }

        foreach ($resetItem in $resets) {
            $state.ItemByDN[$resetItem.DistinguishedName].SubItems[3].Text = "Exported"
        }

        Write-Log -Message "Reset: Exported $($resets.Count) passphrase(s) to $($state.ExportPath) without resetting. Delete the file after handout."

        [System.Windows.Forms.MessageBox]::Show($form, "Exported $($resets.Count) passphrase(s) - no passwords were changed.`n`n$($state.ExportPath)", "IDBridge", 'OK', 'Information') | Out-Null
    })
    #endregion Export Passphrases Handler



    #region Show
    Write-Log -Message "Reset: Opening the AD password reset window."
    $form.ShowDialog() | Out-Null
    $form.Dispose()
    #endregion Show

    return [PSCustomObject]@{
        Total     = $state.Total
        Succeeded = $state.Succeeded
        Failed    = $state.Failed
        ExportPath = $state.ExportPath
    }
}