internal/Gui/Show-AddCredentialDialog.ps1

# Guerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/Guerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution

# GUI-driven credential entry (the "Add credential" modal on the Safehouse page).
# Builds the list of vault entries from collected field values, and a WPF dialog
# to collect them, styled on the same design tokens as the main window.
# New-AddCredentialEntries is kept pure so it can be unit-tested without a UI;
# Show-AddCredentialDialog drives the actual window.

function New-AddCredentialEntries {
    <#
    .SYNOPSIS
        Pure builder: turns collected dialog field values into the vault entry list that
        Save-SafehouseCredentialSet stores. No UI, no side effects — unit-testable.
    .PARAMETER Environment
        'microsoftGraph' or 'googleWorkspace'.
    .PARAMETER Fields
        Hashtable of the raw field values from the dialog.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Environment,
        [Parameter(Mandatory)][hashtable]$Fields
    )

    $entries = [System.Collections.Generic.List[object]]::new()
    switch ($Environment) {
        'microsoftGraph' {
            $entries.Add(@{ VaultKey = 'GUERRILLA_GRAPH_TENANT'; Value = $Fields.TenantId; Type = 'tenantId'
                Environment = 'microsoftGraph'; Description = 'Entra ID Tenant ID'; Identity = $Fields.TenantId })
            $entries.Add(@{ VaultKey = 'GUERRILLA_GRAPH_CLIENTID'; Value = $Fields.ClientId; Type = 'clientId'
                Environment = 'microsoftGraph'; Description = 'App Registration Client ID'; Identity = $Fields.ClientId })
            $secret = @{ VaultKey = 'GUERRILLA_GRAPH_SECRET'; Value = $Fields.ClientSecret; Type = 'clientSecret'
                Environment = 'microsoftGraph'; Description = 'Microsoft Graph Client Secret' }
            if ($Fields.Expiration) { $secret.ExpirationDate = $Fields.Expiration }
            $entries.Add($secret)
        }
        'googleWorkspace' {
            $entries.Add(@{ VaultKey = 'GUERRILLA_GWS_SA'; Value = $Fields.ServiceAccountJson; Type = 'serviceAccount'
                Environment = 'googleWorkspace'; Description = 'Google Workspace service account'; Identity = $Fields.SaClientEmail })
            $entries.Add(@{ VaultKey = 'GUERRILLA_GWS_SA_ADMIN_EMAIL'; Value = $Fields.AdminEmail; Type = 'adminEmail'
                Environment = 'googleWorkspace'; Description = 'Google Workspace delegated-admin email'; Identity = $Fields.AdminEmail })
        }
    }
    return $entries
}

function Test-AddCredentialFields {
    <#
    .SYNOPSIS
        Validates collected dialog values; returns an array of human-readable error
        strings (empty array = valid). Pure — unit-testable.
    .PARAMETER Strings
        Localized string table (dot-key -> text) from Get-GuerrillaGuiStringTable.
        Defaults to English so callers without a GUI session still get messages.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Environment,
        [Parameter(Mandatory)][hashtable]$Fields,
        [hashtable]$Strings
    )

    $errs = [System.Collections.Generic.List[string]]::new()
    $guid = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$'
    $L = if ($Strings) { $Strings } else { Get-GuerrillaGuiStringTable }
    switch ($Environment) {
        'microsoftGraph' {
            if ("$($Fields.TenantId)" -notmatch $guid) { $errs.Add($L['credential.errTenantGuid']) }
            if ("$($Fields.ClientId)" -notmatch $guid) { $errs.Add($L['credential.errClientGuid']) }
            if (-not "$($Fields.ClientSecret)") { $errs.Add($L['credential.errSecretRequired']) }
            if ($Fields.Expiration -and "$($Fields.Expiration)" -notmatch '^\d{4}-\d{2}-\d{2}$') {
                $errs.Add($L['credential.errExpiryFormat'])
            }
        }
        'googleWorkspace' {
            if (-not "$($Fields.ServiceAccountJson)") { $errs.Add($L['credential.errSaJsonRequired']) }
            elseif (-not $Fields.SaClientEmail)       { $errs.Add($L['credential.errSaJsonInvalid']) }
            if ("$($Fields.AdminEmail)" -notmatch '^[^@\s]+@[^@\s]+\.[^@\s]+$') { $errs.Add($L['credential.errAdminEmail']) }
        }
        default { $errs.Add(($L['credential.errUnknownEnvironment'] -f $Environment)) }
    }
    return @($errs)
}

function Show-AddCredentialDialog {
    <#
    .SYNOPSIS
        Modal WPF dialog to add Entra (Graph) or Google Workspace credentials to the vault.
        Returns the entry list to store (for Save-SafehouseCredentialSet), or $null if the
        user cancelled.
    .PARAMETER Owner
        Owning window (centers the dialog over it).
    .PARAMETER Theme
        'Light' or 'Dark'. Applies the matching Get-GuerrillaGuiTheme palette so the
        modal matches whatever theme the main window is currently showing.
    .PARAMETER Language
        Catalog code (e.g. 'en', 'es'). Defaults to the language the main window
        last applied, else the resolved startup language.
    #>

    [CmdletBinding()]
    param(
        $Owner,
        [ValidateSet('Light', 'Dark')]
        [string]$Theme = 'Light',
        [string]$Language
    )

    $xaml = @'
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="{DynamicResource L_credential_windowTitle}" Width="560" Height="560"
        WindowStyle="None" ResizeMode="NoResize" AllowsTransparency="False"
        WindowStartupLocation="CenterOwner"
        Background="{DynamicResource BgBrush}"
        UseLayoutRounding="True" SnapsToDevicePixels="True"
        TextOptions.TextFormattingMode="Display"
        FontFamily="Segoe UI Variable Text, Segoe UI" FontSize="13"
        Foreground="{DynamicResource TextBrush}">
  <WindowChrome.WindowChrome>
    <WindowChrome CaptionHeight="48" ResizeBorderThickness="0"
                  GlassFrameThickness="0,0,0,1" CornerRadius="0" UseAeroCaptionButtons="False"/>
  </WindowChrome.WindowChrome>
  <Window.Resources>
    <!-- Light defaults; overwritten from Get-GuerrillaGuiTheme after load. -->
    <SolidColorBrush x:Key="BgBrush" Color="#FFFFFF"/>
    <SolidColorBrush x:Key="SurfaceBrush" Color="#F5F5F7"/>
    <SolidColorBrush x:Key="SurfaceAltBrush" Color="#E8E8ED"/>
    <SolidColorBrush x:Key="TextBrush" Color="#1D1D1F"/>
    <SolidColorBrush x:Key="HeadingBrush" Color="#1D1D1F"/>
    <SolidColorBrush x:Key="MutedBrush" Color="#515154"/>
    <SolidColorBrush x:Key="AccentBrush" Color="#0066CC"/>
    <SolidColorBrush x:Key="AccentHoverBrush" Color="#1274DB"/>
    <SolidColorBrush x:Key="OnAccentBrush" Color="#FFFFFF"/>
    <SolidColorBrush x:Key="LineBrush" Color="#D2D2D7"/>
    <SolidColorBrush x:Key="LineStrongBrush" Color="#76767C"/>
    <SolidColorBrush x:Key="FocusBrush" Color="#0066CC"/>
    <SolidColorBrush x:Key="BadBrush" Color="#B32424"/>

    <Style x:Key="Pill" TargetType="Button">
      <Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
      <Setter Property="Foreground" Value="{DynamicResource OnAccentBrush}"/>
      <Setter Property="BorderThickness" Value="0"/>
      <Setter Property="Padding" Value="18,8"/>
      <Setter Property="FontWeight" Value="Medium"/>
      <Setter Property="Cursor" Value="Hand"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="Button">
            <Border Background="{TemplateBinding Background}" CornerRadius="980"
                    Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
              <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
            </Border>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
      <Style.Triggers>
        <Trigger Property="IsMouseOver" Value="True">
          <Setter Property="Background" Value="{DynamicResource AccentHoverBrush}"/>
        </Trigger>
      </Style.Triggers>
    </Style>
    <Style x:Key="PillGhost" TargetType="Button">
      <Setter Property="Background" Value="Transparent"/>
      <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
      <Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
      <Setter Property="BorderThickness" Value="1"/>
      <Setter Property="Padding" Value="14,7"/>
      <Setter Property="Cursor" Value="Hand"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="Button">
            <Border Background="{TemplateBinding Background}" CornerRadius="980"
                    BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}"
                    Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
              <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
            </Border>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
      <Style.Triggers>
        <Trigger Property="IsMouseOver" Value="True">
          <Setter Property="Background" Value="{DynamicResource SurfaceBrush}"/>
        </Trigger>
      </Style.Triggers>
    </Style>
    <Style x:Key="SegPill" TargetType="RadioButton">
      <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
      <Setter Property="Cursor" Value="Hand"/>
      <Setter Property="Margin" Value="0,0,8,0"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="RadioButton">
            <Border x:Name="bd" CornerRadius="980" Background="Transparent"
                    BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
                    Padding="14,6" SnapsToDevicePixels="True">
              <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
            </Border>
            <ControlTemplate.Triggers>
              <Trigger Property="IsMouseOver" Value="True">
                <Setter TargetName="bd" Property="Background" Value="{DynamicResource SurfaceBrush}"/>
              </Trigger>
              <Trigger Property="IsChecked" Value="True">
                <Setter TargetName="bd" Property="Background" Value="{DynamicResource AccentBrush}"/>
                <Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
                <Setter Property="Foreground" Value="{DynamicResource OnAccentBrush}"/>
              </Trigger>
            </ControlTemplate.Triggers>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>
    <Style TargetType="TextBlock">
      <Setter Property="Foreground" Value="{DynamicResource MutedBrush}"/>
      <Setter Property="Margin" Value="0,10,0,4"/>
      <Setter Property="FontSize" Value="12"/>
    </Style>
    <Style TargetType="TextBox">
      <Setter Property="Background" Value="{DynamicResource BgBrush}"/>
      <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
      <Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
      <Setter Property="BorderThickness" Value="1"/>
      <Setter Property="Padding" Value="10,7"/>
      <Setter Property="CaretBrush" Value="{DynamicResource TextBrush}"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="TextBox">
            <Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="8" SnapsToDevicePixels="True">
              <ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" VerticalAlignment="Center"/>
            </Border>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
      <Style.Triggers>
        <Trigger Property="IsKeyboardFocusWithin" Value="True">
          <Setter Property="BorderBrush" Value="{DynamicResource FocusBrush}"/>
        </Trigger>
      </Style.Triggers>
    </Style>
    <Style TargetType="PasswordBox">
      <Setter Property="Background" Value="{DynamicResource BgBrush}"/>
      <Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
      <Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
      <Setter Property="BorderThickness" Value="1"/>
      <Setter Property="Padding" Value="10,7"/>
      <Setter Property="CaretBrush" Value="{DynamicResource TextBrush}"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="PasswordBox">
            <Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="8" SnapsToDevicePixels="True">
              <ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" VerticalAlignment="Center"/>
            </Border>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
      <Style.Triggers>
        <Trigger Property="IsKeyboardFocusWithin" Value="True">
          <Setter Property="BorderBrush" Value="{DynamicResource FocusBrush}"/>
        </Trigger>
      </Style.Triggers>
    </Style>
  </Window.Resources>

  <Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" Background="{DynamicResource BgBrush}">
    <Grid Margin="24,0,24,20">
      <Grid.RowDefinitions>
        <RowDefinition Height="48"/>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="*"/>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="Auto"/>
      </Grid.RowDefinitions>

      <Grid Grid.Row="0">
        <TextBlock Text="{DynamicResource L_credential_heading}" FontSize="15" FontWeight="SemiBold"
                   Foreground="{DynamicResource HeadingBrush}" VerticalAlignment="Center" Margin="0"/>
      </Grid>

      <StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,8,0,4">
        <RadioButton x:Name="rb_Entra" Content="{DynamicResource L_credential_entra}" GroupName="Env" IsChecked="True"
                     Style="{StaticResource SegPill}"/>
        <RadioButton x:Name="rb_Gws" Content="{DynamicResource L_platform_gws}" GroupName="Env"
                     Style="{StaticResource SegPill}"/>
      </StackPanel>

      <Grid Grid.Row="3" VerticalAlignment="Top">
        <!-- Entra panel -->
        <StackPanel x:Name="panel_Entra" Visibility="Visible">
          <TextBlock Text="{DynamicResource L_credential_tenantId}"/>
          <TextBox x:Name="tb_Tenant"/>
          <TextBlock Text="{DynamicResource L_credential_clientId}"/>
          <TextBox x:Name="tb_ClientId"/>
          <TextBlock Text="{DynamicResource L_credential_clientSecret}"/>
          <PasswordBox x:Name="pb_Secret"/>
          <TextBlock Text="{DynamicResource L_credential_secretExpiry}"/>
          <TextBox x:Name="tb_Expiry"/>
        </StackPanel>
        <!-- Google Workspace panel -->
        <StackPanel x:Name="panel_Gws" Visibility="Collapsed">
          <TextBlock Text="{DynamicResource L_credential_saKeyFile}"/>
          <Grid>
            <Grid.ColumnDefinitions>
              <ColumnDefinition Width="*"/>
              <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>
            <TextBox x:Name="tb_SaPath" Grid.Column="0"/>
            <Button x:Name="btn_Browse" Grid.Column="1" Content="{DynamicResource L_common_browse}" Style="{StaticResource PillGhost}"
                    Margin="8,0,0,0"/>
          </Grid>
          <TextBlock Text="{DynamicResource L_credential_adminEmail}"/>
          <TextBox x:Name="tb_AdminEmail"/>
          <TextBlock Text="{DynamicResource L_credential_delegationNote}"
                     TextWrapping="Wrap" Margin="0,12,0,0"/>
        </StackPanel>
      </Grid>

      <TextBlock x:Name="tb_Error" Grid.Row="4" Foreground="{DynamicResource BadBrush}" TextWrapping="Wrap"
                 Margin="0,10,0,0" FontSize="12"/>

      <StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
        <Button x:Name="btn_Cancel" Content="{DynamicResource L_common_cancel}" Style="{StaticResource PillGhost}" Margin="0,0,8,0"/>
        <Button x:Name="btn_Save" Content="{DynamicResource L_credential_saveCredential}" Style="{StaticResource Pill}"/>
      </StackPanel>
    </Grid>
  </Border>
</Window>
'@


    $reader = New-Object System.Xml.XmlNodeReader ([xml]$xaml)
    $win = [System.Windows.Markup.XamlReader]::Load($reader)
    if ($Owner) { $win.Owner = $Owner }

    # Fill this window's L_* string resources so every {DynamicResource L_*}
    # resolves in the language the main window is showing.
    if (-not $Language) {
        $Language = if ($script:GuerrillaGuiLanguage) { [string]$script:GuerrillaGuiLanguage }
                    else { Resolve-GuerrillaGuiLanguage }
    }
    $L = Get-GuerrillaGuiStringTable -Language $Language
    foreach ($k in $L.Keys) {
        $win.Resources[('L_' + ($k -replace '\.', '_'))] = [string]$L[$k]
    }

    # Match the main window's current theme by overwriting the brush resources.
    try {
        $palettes = Get-GuerrillaGuiTheme
        $pal = if ($Theme -eq 'Dark') { $palettes.Dark } else { $palettes.Light }
        foreach ($key in $pal.Keys) {
            if ($win.Resources.Contains("${key}Brush")) {
                $color = [System.Windows.Media.ColorConverter]::ConvertFromString($pal[$key])
                $brush = [System.Windows.Media.SolidColorBrush]::new($color)
                $brush.Freeze()
                $win.Resources["${key}Brush"] = $brush
            }
        }
    } catch { }

    $ctl = @{}
    foreach ($n in 'rb_Entra', 'rb_Gws', 'panel_Entra', 'panel_Gws', 'tb_Tenant', 'tb_ClientId',
        'pb_Secret', 'tb_Expiry', 'tb_SaPath', 'btn_Browse', 'tb_AdminEmail', 'tb_Error', 'btn_Cancel', 'btn_Save') {
        $ctl[$n] = $win.FindName($n)
    }

    $ctl.rb_Entra.Add_Checked({ $ctl.panel_Entra.Visibility = 'Visible'; $ctl.panel_Gws.Visibility = 'Collapsed' }.GetNewClosure())
    $ctl.rb_Gws.Add_Checked({ $ctl.panel_Entra.Visibility = 'Collapsed'; $ctl.panel_Gws.Visibility = 'Visible' }.GetNewClosure())

    $ctl.btn_Browse.Add_Click({
        $dlg = New-Object Microsoft.Win32.OpenFileDialog
        $dlg.Filter = $L['credential.filterSaJson']
        if ($dlg.ShowDialog()) { $ctl.tb_SaPath.Text = $dlg.FileName }
    }.GetNewClosure())

    $result = @{ Entries = $null }

    # Borderless window: no system close button, so wire the keyboard equivalents.
    $ctl.btn_Cancel.IsCancel = $true    # Esc closes
    $ctl.btn_Save.IsDefault  = $true    # Enter saves

    $ctl.btn_Cancel.Add_Click({ $win.Close() }.GetNewClosure())

    $ctl.btn_Save.Add_Click({
        $env = if ($ctl.rb_Gws.IsChecked) { 'googleWorkspace' } else { 'microsoftGraph' }
        $fields = @{}
        if ($env -eq 'microsoftGraph') {
            $fields.TenantId     = $ctl.tb_Tenant.Text.Trim()
            $fields.ClientId     = $ctl.tb_ClientId.Text.Trim()
            $fields.ClientSecret = $ctl.pb_Secret.Password
            $fields.Expiration   = $ctl.tb_Expiry.Text.Trim()
        } else {
            $saPath = $ctl.tb_SaPath.Text.Trim()
            $fields.ServiceAccountJson = $null; $fields.SaClientEmail = $null
            if ($saPath -and (Test-Path $saPath)) {
                try {
                    $raw = Get-Content -Path $saPath -Raw
                    $sa = $raw | ConvertFrom-Json
                    $fields.ServiceAccountJson = $raw
                    $fields.SaClientEmail = $sa.client_email
                } catch { }
            }
            $fields.AdminEmail = $ctl.tb_AdminEmail.Text.Trim()
        }

        $errs = Test-AddCredentialFields -Environment $env -Fields $fields -Strings $L
        if ($errs.Count -gt 0) {
            $ctl.tb_Error.Text = ($errs -join ' ')
            return
        }
        $result.Entries = New-AddCredentialEntries -Environment $env -Fields $fields
        $win.Close()
    }.GetNewClosure())

    [void]$win.ShowDialog()
    return $result.Entries
}