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.
    #>

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

    $errs = [System.Collections.Generic.List[string]]::new()
    $guid = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$'
    switch ($Environment) {
        'microsoftGraph' {
            if ("$($Fields.TenantId)" -notmatch $guid) { $errs.Add('Tenant ID must be a GUID.') }
            if ("$($Fields.ClientId)" -notmatch $guid) { $errs.Add('Client ID must be a GUID.') }
            if (-not "$($Fields.ClientSecret)") { $errs.Add('Client Secret is required.') }
            if ($Fields.Expiration -and "$($Fields.Expiration)" -notmatch '^\d{4}-\d{2}-\d{2}$') {
                $errs.Add('Secret expiry must be YYYY-MM-DD (or left blank).')
            }
        }
        'googleWorkspace' {
            if (-not "$($Fields.ServiceAccountJson)") { $errs.Add('Service account JSON is required.') }
            elseif (-not $Fields.SaClientEmail)       { $errs.Add('Service account JSON is not valid (no client_email).') }
            if ("$($Fields.AdminEmail)" -notmatch '^[^@\s]+@[^@\s]+\.[^@\s]+$') { $errs.Add('A valid admin email is required.') }
        }
        default { $errs.Add("Unknown environment '$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.
    #>

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

    $xaml = @'
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Add credential" 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="Add a credential to the Safehouse" 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="Microsoft Entra / Graph" GroupName="Env" IsChecked="True"
                     Style="{StaticResource SegPill}"/>
        <RadioButton x:Name="rb_Gws" Content="Google Workspace" GroupName="Env"
                     Style="{StaticResource SegPill}"/>
      </StackPanel>

      <Grid Grid.Row="3" VerticalAlignment="Top">
        <!-- Entra panel -->
        <StackPanel x:Name="panel_Entra" Visibility="Visible">
          <TextBlock Text="Tenant ID (GUID)"/>
          <TextBox x:Name="tb_Tenant"/>
          <TextBlock Text="Application (Client) ID (GUID)"/>
          <TextBox x:Name="tb_ClientId"/>
          <TextBlock Text="Client Secret"/>
          <PasswordBox x:Name="pb_Secret"/>
          <TextBlock Text="Secret expiry (YYYY-MM-DD, optional)"/>
          <TextBox x:Name="tb_Expiry"/>
        </StackPanel>
        <!-- Google Workspace panel -->
        <StackPanel x:Name="panel_Gws" Visibility="Collapsed">
          <TextBlock Text="Service account JSON key file"/>
          <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="Browse" Style="{StaticResource PillGhost}"
                    Margin="8,0,0,0"/>
          </Grid>
          <TextBlock Text="Delegated-admin email (a Super Admin)"/>
          <TextBox x:Name="tb_AdminEmail"/>
          <TextBlock Text="The service account needs domain-wide delegation configured in the Google Admin Console."
                     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="Cancel" Style="{StaticResource PillGhost}" Margin="0,0,8,0"/>
        <Button x:Name="btn_Save" Content="Save credential" 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 }

    # 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 = 'Service account JSON (*.json)|*.json|All files (*.*)|*.*'
        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
        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
}