Public/Start-NSPNpsManager.ps1

function Start-NSPNpsManager {
    <#
    .SYNOPSIS
        The NPS Manager dashboard: install/authorize NPS, the NPS Extension, importing a client's
        RADIUS definitions from the Orchestrator, RADIUS clients and templates, network and connection
        request policies, troubleshooting, and the hand-back to the Orchestrator.
 
    .DESCRIPTION
        Moved from the zip-era NPS-Manager.ps1; the menus are unchanged. Relaunches elevated if
        needed. Answers live in the NPS work folder (Get-NSPToolWorkPath -Tool NPS): a launcher's
        -SeedAnswersJson is merged there, and the AD server, AD username (never a password) and
        "always ask for AD credentials" flag that the zip-era tool wrote into its shim are saved
        there instead. The first start on a server offers to move the zip-era NPS-Manager's files
        into the work folder.
 
    .PARAMETER SeedAnswersJson
        Answers to merge in (plain JSON - see ConvertTo-NSPNpsAnswers - or an NPS Answers hand-off).
 
    .PARAMETER SeedOnly
        Merge -SeedAnswersJson and return without opening the dashboard.
 
    .PARAMETER IASConfigPath
        The live NPS configuration file.
 
    .PARAMETER ADServer
        A specific DC, for sites where AD auto-discovery is unreliable (defaults to the saved one).
 
    .PARAMETER ADUsername
        Pre-fills AD credential prompts (defaults to the saved one).
 
    .PARAMETER RequiresExplicitADCredentials
        Ask for AD credentials at startup (defaults to the saved setting).
 
    .PARAMETER NoElevate
        Do not relaunch elevated.
 
    .EXAMPLE
        Start-NSPNpsManager
 
    .EXAMPLE
        Start-NSPNpsManager -ADServer dc01.example.com
    #>

    [CmdletBinding()]
    param(
        [string]$SeedAnswersJson,
        [switch]$SeedOnly,
        [string]$IASConfigPath = "C:\Windows\System32\ias\ias.xml",
        [string]$ADServer,
        [string]$ADUsername,
        [switch]$RequiresExplicitADCredentials,
        [switch]$NoElevate
    )

    Use-NSPNpsDependency

    if ($SeedOnly) {
        $null = Import-NSPToolSeedAnswers -Tool NPS -SeedAnswersJson $SeedAnswersJson
        return
    }

    if (-not $NoElevate) {
        $manifest = (Join-Path $script:ModuleRoot 'NSP.NPS.psd1').Replace("'", "''")
        $relaunch = "Import-Module '$manifest'; Start-NSPNpsManager"
        if ($PSBoundParameters.ContainsKey('IASConfigPath')) { $relaunch += " -IASConfigPath '$($IASConfigPath.Replace("'", "''"))'" }
        if ($PSBoundParameters.ContainsKey('ADServer')) { $relaunch += " -ADServer '$($ADServer.Replace("'", "''"))'" }
        if ($PSBoundParameters.ContainsKey('ADUsername')) { $relaunch += " -ADUsername '$($ADUsername.Replace("'", "''"))'" }
        if ($RequiresExplicitADCredentials) { $relaunch += ' -RequiresExplicitADCredentials' }
        if (Invoke-NSPElevated -Command $relaunch -NoExit) { return }
    }

    $saved = Import-NSPToolSeedAnswers -Tool NPS -SeedAnswersJson $SeedAnswersJson

    # First start on this server: offer to bring over what the zip-era NPS-Manager left behind.
    $marker = Join-Path (Get-NSPToolWorkPath -Tool NPS -Create) '.legacy-checked'
    if (-not (Test-Path -LiteralPath $marker)) {
        try {
            $summary = Move-NSPToolLegacyData -Tool NPS
            if ($summary.Found) { $saved = Get-NSPToolAnswers -Tool NPS; Read-Host "`nPress Enter to continue" | Out-Null }
        } catch { Write-Warning "Old NPS-Manager files could not be checked: $($_.Exception.Message)" }
        Set-Content -LiteralPath $marker -Value (Get-Date -Format 's')
    }

    # The zip-era script kept these as script-level variables that its functions read and update
    # ($script:ADServer = ...). Same here, in module scope: the parameters (or the saved answers) are
    # copied into $script:, then the locals are removed so every later $ADServer / $IASConfigPath /
    # ... below resolves to the module-scope value the functions update, exactly as before.
    $savedValue = { param($Name) if ($saved -and $saved.PSObject.Properties[$Name]) { $saved.$Name } else { $null } }
    if (-not $PSBoundParameters.ContainsKey('ADServer') -and (& $savedValue 'NPSADServer')) { $ADServer = [string](& $savedValue 'NPSADServer') }
    if (-not $PSBoundParameters.ContainsKey('ADUsername') -and (& $savedValue 'NPSADUsername')) { $ADUsername = [string](& $savedValue 'NPSADUsername') }
    if (-not $RequiresExplicitADCredentials -and (& $savedValue 'NPSRequiresExplicitADCredentials')) { $RequiresExplicitADCredentials = [switch]$true }

    # Answers.json stands in for the zip-era shim path: Set-NPSShim* write their fields into it.
    $answersDir = Get-NSPToolWorkPath -Tool NPS -Kind Answers -Create
    $answersFile = Join-Path $answersDir 'Answers.json'
    if (-not (Test-Path -LiteralPath $answersFile)) { [IO.File]::WriteAllText($answersFile, '{}') }

    $script:IASConfigPath = $IASConfigPath
    $script:ADServer = $ADServer
    $script:ShimPath = $answersFile
    $script:BaseDir = $answersDir
    $script:NPSManagerVersion = Get-NSPNpsModuleVersion
    $script:NPSManagerVersionLabel = (Get-NSPToolVersionStatus -ToolKey 'NSP.NPS' -Version $script:NPSManagerVersion).Label
    $script:ADUsername = $ADUsername
    $script:RequiresExplicitADCredentials = [bool]$RequiresExplicitADCredentials
    Remove-Variable -Name IASConfigPath, ADServer, ADUsername, RequiresExplicitADCredentials -Scope Local
    Set-ConsoleFullScreen

    # ----- zip-era NPS-Manager.ps1 startup block (verbatim) -----
    # Set once a tech successfully authenticates to a specific DC via Invoke-NPSADFallbackPrompt (Option
    # 1, or Troubleshooting's "Test AD connectivity") - confirmed live (the maintainer) that the SAME credential
    # is needed for every AD-touching operation this session, not just the one check that first surfaced
    # the problem (the rule builder's wildcard group search hits the identical auth failure). Deliberately
    # NOT a script parameter (no plaintext-password-on-the-command-line) and NEVER written to disk -
    # session-only, same as $ADServer is persisted (into the shim) while a credential never is.
    $script:ADCredential = $null

    # Mirrors the -RequiresExplicitADCredentials switch into a script-scoped variable so the
    # Troubleshooting menu's toggle (Set-NPSShimRequiresExplicitADCredentials) can flip it mid-session and
    # have the menu's own "currently: Yes/No" display and this session's proactive-prompt behavior both
    # see the change immediately, not just future launches.
    $script:RequiresExplicitADCredentials = [bool]$RequiresExplicitADCredentials

    # Last-known-good username for this site (see $NPSADUsername / Set-NPSShimADUsername) - NEVER the
    # password. Only ever used to pre-fill Get-Credential prompts; updated below whenever a fallback/
    # required-credentials prompt actually captures a (possibly different) username, same "session
    # variable, threaded explicitly through every function that needs it" pattern as $script:ADServer.
    $script:ADUsername = $ADUsername

    # Proactive AD credential prompt for a client flagged as always needing it (see
    # $RequiresExplicitADCredentials above / Invoke-NPSRequiredCredentialsPrompt) - runs BEFORE the main
    # menu loop's first Get-NPSStatus call, so that very first status screen already reflects reality
    # instead of showing "Unknown" until the tech happens to visit Option 1 or Troubleshooting Tools.
    # Credentials are never saved to disk, so this still runs every launch even though $ADServer itself
    # (once learned) is not re-prompted for - see the function's own header for why.
    if ($script:RequiresExplicitADCredentials) {
        $requiredCredsResult = Invoke-NPSRequiredCredentialsPrompt -ADServer $ADServer -ShimPath $ShimPath -ADUsername $script:ADUsername
        if ($requiredCredsResult) {
            $script:ADServer = $requiredCredsResult.DCServer
            $script:ADCredential = $requiredCredsResult.Credential
            if ($requiredCredsResult.Username) { $script:ADUsername = $requiredCredsResult.Username }
        }
    }

    # ----- zip-era NPS-Manager.ps1 main menu loop (verbatim; 'exit 0' -> 'return') -----
    :MainMenu while ($true) {
        Write-NPSHeader "NPS Manager Dashboard $script:NPSManagerVersionLabel"

        $status = Get-NPSStatus -IASConfigPath $IASConfigPath -ADServer $ADServer -ADCredential $ADCredential
        Show-NPSStatus -Status $status

        if ($status.IASConfigExists) {
            # Compact form only - see Show-NPSConfigSummary's -Compact notes for why the full per-rule
            # detail stays reserved for Troubleshooting -> 9 instead of every single redraw here.
            Show-NPSConfigSummary -IASConfigPath $IASConfigPath -Compact
            Write-Host ""
        }

        Write-Host " 1." -NoNewline -ForegroundColor Yellow
        Write-Host " Install / authorize the NPS Server"
        Write-Host " 2." -NoNewline -ForegroundColor Yellow
        Write-Host " Manage NPS Extension (install/update, uninstall, number matching override)"
        Write-Host " 3." -NoNewline -ForegroundColor Yellow
        Write-Host " Import Kickstart Definitions from Master Orchestrator"
        Write-Host " 4." -NoNewline -ForegroundColor Yellow
        Write-Host " Manage NPS Clients & Templates"
        Write-Host " 5." -NoNewline -ForegroundColor Yellow
        Write-Host " Manage NPS Rules"
        Write-Host " 6." -NoNewline -ForegroundColor Yellow
        Write-Host " Troubleshooting Tools"
        Show-TroubleshootingCategoryLinks
        Write-Host " 7." -NoNewline -ForegroundColor Yellow
        Write-Host " Hand back to the Orchestrator (NPS facts + ias.xml, secrets removed)"
        Write-Host " Q." -NoNewline -ForegroundColor Yellow
        Write-Host " Quit"
        Write-Host ""
        $choice = Read-Host "Select an option"

        switch -Regex ($choice) {
            '^1$'    { Invoke-InstallAuthorizeNPS -ADServer $ADServer -ShimPath $ShimPath -ADCredential $ADCredential -ADUsername $ADUsername }
            '^2$'    { Invoke-ManageNPSExtension -IASConfigPath $IASConfigPath }
            '^3$'    {
                if (-not (Test-Path $IASConfigPath)) {
                    Write-Host "ias.xml not found at $IASConfigPath - install/authorize NPS first (option 1)." -ForegroundColor Red
                    Read-Host "Press Enter to return to the menu"
                } else {
                    # Every error path Invoke-ImportKickstartDefinitions already anticipates pauses on
                    # its own (see OrchestratorImport.ps1) - this try/catch is the defense-in-depth net
                    # for anything that ISN'T anticipated: an uncaught exception used to print, then
                    # vanish instantly under this loop's own next Write-NPSHeader (Clear-Host) before a
                    # tech had any chance to read it. Confirmed live (the maintainer, 2026-08-18): "errors out,
                    # but it flashes so quick I can't see the error."
                    try {
                        Invoke-ImportKickstartDefinitions -IASConfigPath $IASConfigPath -ADServer $ADServer -ADCredential $ADCredential -ScriptRoot $script:BaseDir -ShimPath $ShimPath
                    } catch {
                        Write-Host "`nUNEXPECTED ERROR: $($_.Exception.Message)" -ForegroundColor Red
                        Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray
                        Read-Host "Press Enter to return to the menu"
                    }
                }
            }
            '^4$'    {
                if (-not (Test-Path $IASConfigPath)) {
                    Write-Host "ias.xml not found at $IASConfigPath - nothing to manage yet." -ForegroundColor Red
                    Read-Host "Press Enter to return to the menu"
                } else {
                    Invoke-ManageNPSClientsMenu -IASConfigPath $IASConfigPath
                }
            }
            '^5$'    {
                if (-not (Test-Path $IASConfigPath)) {
                    Write-Host "ias.xml not found at $IASConfigPath - nothing to manage yet." -ForegroundColor Red
                    Read-Host "Press Enter to return to the menu"
                } else {
                    Invoke-ManageNPSRulesMenu -IASConfigPath $IASConfigPath -ADServer $ADServer -ADCredential $ADCredential -ShimPath $ShimPath
                }
            }
            '^6$'    { Invoke-TroubleshootingMenu -IASConfigPath $IASConfigPath -ADServer $ADServer -ShimPath $ShimPath -ADCredential $ADCredential -ADUsername $ADUsername }
            '^6[A-Za-z]$' {
                # "6a"-"6e" etc, straight from Show-TroubleshootingCategoryLinks' own dashboard row - same
                # first-seen category order/letter derivation as that function uses, so the two can never
                # silently drift apart.
                $letterIdx = [int][char]($choice.Substring(1, 1).ToLower()) - 97
                $troubleshootCategories = @(Get-NPSTroubleshootingActions | Select-Object -ExpandProperty Category -Unique)
                if ($letterIdx -ge 0 -and $letterIdx -lt $troubleshootCategories.Count) {
                    Invoke-TroubleshootingCategoryMenu -Category $troubleshootCategories[$letterIdx] -IASConfigPath $IASConfigPath -ADServer $ADServer -ShimPath $ShimPath -ADCredential $ADCredential -ADUsername $ADUsername
                } else {
                    Write-Host "Invalid selection." -ForegroundColor Yellow
                }
            }
            '^7$'    { Invoke-NPSMenuHandoff -IASConfigPath $IASConfigPath -ScriptRoot $script:BaseDir -Status $status }
            '^[Qq]$' { Write-Host "Goodbye." -ForegroundColor Gray; return }
            default  { Write-Host "Invalid selection." -ForegroundColor Yellow }
        }
    }
}