Private/Gui/Show-AddSignalDialog.ps1

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

# GUI-driven alert-provider entry (the "Add Provider" modal on the Signals tab).
# Mirrors Show-AddCredentialDialog: a pure builder (New-SignalProviderEntry) that turns
# collected field values into the exact { Type, VaultKey, Secret, SeverityThreshold,
# ProviderConfig } shape the Signals tab stores, plus a modal WPF dialog to collect them.
#
# Storage convention is identical to Set-Safehouse / Invoke-CredentialMigration and is
# read back by Send-Signal's channel resolver:
# teams/slack/webhook/pagerduty -> vault secret is a single string
# pushover/sendgrid/mailgun/twilio -> vault secret is a compact JSON blob
# syslog/eventlog -> no secret (config-only)
# Canonical vault keys match Invoke-CredentialMigration:
# GUERRILLA_TEAMS_WEBHOOK, GUERRILLA_SLACK_WEBHOOK, GUERRILLA_WEBHOOK_URL,
# GUERRILLA_PAGERDUTY_KEY, GUERRILLA_PUSHOVER_KEY, GUERRILLA_SENDGRID_KEY,
# GUERRILLA_MAILGUN_KEY, GUERRILLA_TWILIO_KEY.

function Get-SignalProviderCatalog {
    <#
    .SYNOPSIS
        Returns the static catalog of alert-provider types: canonical vault key, secret
        format (None/String/Json), and the dialog field set. Single source of truth shared
        by the dialog and the Signals tab handlers. Pure — unit-testable.
    #>

    [CmdletBinding()]
    param()

    return @(
        [PSCustomObject]@{ Type = 'teams';     Display = 'Microsoft Teams (webhook)'; VaultKey = 'GUERRILLA_TEAMS_WEBHOOK'; SecretFormat = 'String'; Fields = @('Url') }
        [PSCustomObject]@{ Type = 'slack';     Display = 'Slack (webhook)';           VaultKey = 'GUERRILLA_SLACK_WEBHOOK'; SecretFormat = 'String'; Fields = @('Url') }
        [PSCustomObject]@{ Type = 'webhook';   Display = 'Generic webhook / SIEM';     VaultKey = 'GUERRILLA_WEBHOOK_URL';   SecretFormat = 'String'; Fields = @('Url') }
        [PSCustomObject]@{ Type = 'pagerduty'; Display = 'PagerDuty (Events v2)';      VaultKey = 'GUERRILLA_PAGERDUTY_KEY'; SecretFormat = 'String'; Fields = @('RoutingKey') }
        [PSCustomObject]@{ Type = 'pushover';  Display = 'Pushover';                   VaultKey = 'GUERRILLA_PUSHOVER_KEY';  SecretFormat = 'Json';   Fields = @('ApiToken', 'UserKey') }
        [PSCustomObject]@{ Type = 'sendgrid';  Display = 'Email — SendGrid';           VaultKey = 'GUERRILLA_SENDGRID_KEY';  SecretFormat = 'Json';   Fields = @('ApiKey', 'FromEmail', 'ToEmails') }
        [PSCustomObject]@{ Type = 'mailgun';   Display = 'Email — Mailgun';            VaultKey = 'GUERRILLA_MAILGUN_KEY';   SecretFormat = 'Json';   Fields = @('ApiKey', 'Domain', 'FromEmail', 'ToEmails') }
        [PSCustomObject]@{ Type = 'twilio';    Display = 'SMS — Twilio';               VaultKey = 'GUERRILLA_TWILIO_KEY';    SecretFormat = 'Json';   Fields = @('AccountSid', 'AuthToken', 'FromNumber', 'ToNumbers') }
        [PSCustomObject]@{ Type = 'syslog';    Display = 'Syslog (CEF/LEEF)';          VaultKey = $null;                     SecretFormat = 'None';   Fields = @('Server', 'Port') }
        [PSCustomObject]@{ Type = 'eventlog';  Display = 'Windows Event Log';          VaultKey = $null;                     SecretFormat = 'None';   Fields = @() }
    )
}

function New-SignalProviderEntry {
    <#
    .SYNOPSIS
        Pure builder: validates collected dialog values and returns the provider entry to
        store, or a list of error strings. No UI, no side effects — unit-testable.
    .DESCRIPTION
        On success returns a PSCustomObject:
          Type - provider type (teams/slack/.../eventlog)
          VaultKey - canonical vault key, or $null for config-only providers
          Secret - the exact value to write to the vault (string, or compact
                              JSON for pushover/sendgrid/mailgun/twilio), or $null
          SeverityThreshold - per-provider minimum threat level (or '' for inherit)
          ProviderConfig - the config.alerting.providers.<type> block to merge
                              (non-secret settings + vaultKey + enabled=true), matching
                              what Send-Signal reads back.
        On failure returns @{ Errors = @(...) }.
    .PARAMETER Type
        Provider type from Get-SignalProviderCatalog.
    .PARAMETER Fields
        Hashtable of the raw field values from the dialog.
    .PARAMETER SeverityThreshold
        ALL / LOW / MEDIUM / HIGH / CRITICAL. 'ALL' or '' means inherit the global level.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Type,
        [Parameter(Mandatory)][hashtable]$Fields,
        [string]$SeverityThreshold = 'ALL'
    )

    $catalog = Get-SignalProviderCatalog
    $spec = $catalog | Where-Object Type -eq $Type | Select-Object -First 1
    if (-not $spec) { return @{ Errors = @("Unknown provider type '$Type'.") } }

    $errs = [System.Collections.Generic.List[string]]::new()
    $url = '^https?://'
    $email = '^[^@\s]+@[^@\s]+\.[^@\s]+$'

    # Normalize the per-provider threshold: 'ALL'/'' inherits the global minimum.
    $threshold = if ($SeverityThreshold -and $SeverityThreshold -ne 'ALL') { $SeverityThreshold } else { '' }

    $secret = $null
    $provCfg = @{ enabled = $true }
    if ($spec.VaultKey) { $provCfg.vaultKey = $spec.VaultKey }
    if ($threshold)     { $provCfg.minimumThreatLevel = $threshold }

    switch ($Type) {
        { $_ -in @('teams', 'slack', 'webhook') } {
            $u = "$($Fields.Url)".Trim()
            if ($u -notmatch $url) { $errs.Add('Webhook URL must start with http:// or https://') }
            $secret = $u
            # config carries the same field Send-Signal injects from the vault
            if ($Type -eq 'webhook') { $provCfg.url = $u } else { $provCfg.webhookUrl = $u }
        }
        'pagerduty' {
            $k = "$($Fields.RoutingKey)".Trim()
            if (-not $k) { $errs.Add('PagerDuty routing key is required.') }
            $secret = $k
            $provCfg.routingKey = $k
        }
        'pushover' {
            $token = "$($Fields.ApiToken)".Trim()
            $user = "$($Fields.UserKey)".Trim()
            if (-not $token) { $errs.Add('Pushover application API token is required.') }
            if (-not $user)  { $errs.Add('Pushover user/group key is required.') }
            $secret = (@{ apiToken = $token; userKey = $user } | ConvertTo-Json -Compress)
            $provCfg.apiToken = $token
            $provCfg.userKey = $user
        }
        { $_ -in @('sendgrid', 'mailgun') } {
            $apiKey = "$($Fields.ApiKey)".Trim()
            $from = "$($Fields.FromEmail)".Trim()
            $toRaw = "$($Fields.ToEmails)".Trim()
            $to = @(($toRaw -split ',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })
            if (-not $apiKey) { $errs.Add('API key is required.') }
            if ($from -notmatch $email) { $errs.Add('From email must be a valid address.') }
            if ($to.Count -eq 0) { $errs.Add('At least one recipient email is required.') }
            elseif (@($to | Where-Object { $_ -notmatch $email }).Count -gt 0) { $errs.Add('One or more recipient emails are invalid.') }

            $blob = [ordered]@{ provider = $Type; apiKey = $apiKey; fromEmail = $from; toEmails = $to }
            $provCfg.apiKey = $apiKey
            $provCfg.fromEmail = $from
            $provCfg.toEmails = $to
            if ($Type -eq 'mailgun') {
                $domain = "$($Fields.Domain)".Trim()
                if (-not $domain -and $from -match '@(.+)$') { $domain = $Matches[1] }
                if (-not $domain) { $errs.Add('Mailgun sending domain is required.') }
                $blob.domain = $domain
                $provCfg.domain = $domain
            }
            $secret = ($blob | ConvertTo-Json -Compress)
        }
        'twilio' {
            $sid = "$($Fields.AccountSid)".Trim()
            $token = "$($Fields.AuthToken)".Trim()
            $fromNum = "$($Fields.FromNumber)".Trim()
            $toRaw = "$($Fields.ToNumbers)".Trim()
            $toNums = @(($toRaw -split ',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })
            if (-not $sid)     { $errs.Add('Twilio Account SID is required.') }
            if (-not $token)   { $errs.Add('Twilio auth token is required.') }
            if (-not $fromNum) { $errs.Add('Twilio from number is required.') }
            if ($toNums.Count -eq 0) { $errs.Add('At least one destination number is required.') }
            $secret = (@{ accountSid = $sid; authToken = $token; fromNumber = $fromNum; toNumbers = $toNums } | ConvertTo-Json -Compress)
            $provCfg.accountSid = $sid
            $provCfg.authToken = $token
            $provCfg.fromNumber = $fromNum
            $provCfg.toNumbers = $toNums
        }
        'syslog' {
            $server = "$($Fields.Server)".Trim()
            if (-not $server) { $errs.Add('Syslog server host is required.') }
            $port = 514
            if ("$($Fields.Port)".Trim()) {
                if (-not [int]::TryParse("$($Fields.Port)".Trim(), [ref]$port)) { $errs.Add('Syslog port must be a number.') }
            }
            $provCfg.server = $server
            $provCfg.port = $port
        }
        'eventlog' {
            # No fields/secret — config-only. Defaults applied by Send-SignalEventLog.
        }
        default { $errs.Add("Unknown provider type '$Type'.") }
    }

    if ($errs.Count -gt 0) { return @{ Errors = @($errs) } }

    return [PSCustomObject]@{
        Type              = $Type
        VaultKey          = $spec.VaultKey
        Secret            = $secret
        SeverityThreshold = $threshold
        ProviderConfig    = $provCfg
    }
}

function Show-AddSignalDialog {
    <#
    .SYNOPSIS
        Modal WPF dialog to add an alert/signal provider. Returns the provider entry from
        New-SignalProviderEntry (Type / VaultKey / Secret / SeverityThreshold /
        ProviderConfig), or $null if cancelled.
    #>

    [CmdletBinding()]
    param(
        $Owner
    )

    $xaml = @'
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Add Signal Provider" Width="540" Height="520" ResizeMode="NoResize"
        WindowStartupLocation="CenterOwner" Background="#F4F6F8" FontFamily="Segoe UI" Foreground="#1F2933">
  <Window.Resources>
    <Style TargetType="TextBlock"><Setter Property="Foreground" Value="#1F2933"/><Setter Property="Margin" Value="0,8,0,2"/></Style>
    <Style TargetType="TextBox">
      <Setter Property="Background" Value="#FFFFFF"/><Setter Property="Foreground" Value="#1F2933"/>
      <Setter Property="BorderBrush" Value="#E2E8F0"/><Setter Property="BorderThickness" Value="1"/>
      <Setter Property="Padding" Value="6,5"/><Setter Property="CaretBrush" Value="#1F2933"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="TextBox">
            <Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="4" SnapsToDevicePixels="True">
              <ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" VerticalAlignment="Center"/>
            </Border>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>
    <Style TargetType="PasswordBox">
      <Setter Property="Background" Value="#FFFFFF"/><Setter Property="Foreground" Value="#1F2933"/>
      <Setter Property="BorderBrush" Value="#E2E8F0"/><Setter Property="BorderThickness" Value="1"/>
      <Setter Property="Padding" Value="6,5"/><Setter Property="CaretBrush" Value="#1F2933"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="PasswordBox">
            <Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="4" SnapsToDevicePixels="True">
              <ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" VerticalAlignment="Center"/>
            </Border>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>
    <Style TargetType="ComboBox">
      <Setter Property="Background" Value="#FFFFFF"/><Setter Property="Foreground" Value="#1F2933"/>
      <Setter Property="BorderBrush" Value="#E2E8F0"/><Setter Property="Padding" Value="8,4"/><Setter Property="Height" Value="28"/>
    </Style>
    <Style TargetType="Button">
      <Setter Property="Background" Value="#2563EB"/><Setter Property="Foreground" Value="#FFFFFF"/>
      <Setter Property="BorderThickness" Value="0"/><Setter Property="Padding" Value="16,6"/><Setter Property="FontWeight" Value="Bold"/>
      <Setter Property="Cursor" Value="Hand"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="Button">
            <Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="6"
                    Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
              <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
            </Border>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>
  </Window.Resources>
  <Grid Margin="20">
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
      <RowDefinition Height="*"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>

    <TextBlock Grid.Row="0" Text="Add an alert/signal provider" FontSize="16" FontWeight="Bold"/>

    <Grid Grid.Row="1" Margin="0,12,0,0">
      <Grid.ColumnDefinitions><ColumnDefinition Width="170"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
      <TextBlock Grid.Column="0" Text="Provider type" VerticalAlignment="Center"/>
      <ComboBox Grid.Column="1" x:Name="cb_Type"/>
    </Grid>

    <Grid Grid.Row="2" Margin="0,8,0,0">
      <Grid.ColumnDefinitions><ColumnDefinition Width="170"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
      <TextBlock Grid.Column="0" Text="Severity threshold" VerticalAlignment="Center"/>
      <ComboBox Grid.Column="1" x:Name="cb_Severity">
        <ComboBoxItem Content="ALL" IsSelected="True"/>
        <ComboBoxItem Content="LOW"/>
        <ComboBoxItem Content="MEDIUM"/>
        <ComboBoxItem Content="HIGH"/>
        <ComboBoxItem Content="CRITICAL"/>
      </ComboBox>
    </Grid>

    <!-- Field stack — populated per selected type in code-behind -->
    <ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto" Margin="0,8,0,0">
      <StackPanel x:Name="sp_Fields"/>
    </ScrollViewer>

    <TextBlock x:Name="tb_Error" Grid.Row="4" Foreground="#DC2626" TextWrapping="Wrap" Margin="0,8,0,0"/>

    <StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
      <Button x:Name="btn_Cancel" Content="Cancel" Background="#FFFFFF" Foreground="#1F2933" BorderBrush="#E2E8F0" BorderThickness="1" Margin="0,0,8,0"/>
      <Button x:Name="btn_Save" Content="Add provider"/>
    </StackPanel>
  </Grid>
</Window>
'@


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

    $ctl = @{}
    foreach ($n in 'cb_Type', 'cb_Severity', 'sp_Fields', 'tb_Error', 'btn_Cancel', 'btn_Save') {
        $ctl[$n] = $win.FindName($n)
    }

    $catalog = Get-SignalProviderCatalog

    # Friendly labels + secret-masking hints for each dialog field.
    $fieldMeta = @{
        Url        = @{ Label = 'Webhook URL';                 Secret = $false }
        RoutingKey = @{ Label = 'Routing / integration key';   Secret = $true  }
        ApiToken   = @{ Label = 'Application API token';        Secret = $true  }
        UserKey    = @{ Label = 'User / group key';            Secret = $false }
        ApiKey     = @{ Label = 'API key';                     Secret = $true  }
        FromEmail  = @{ Label = 'From email address';          Secret = $false }
        ToEmails   = @{ Label = 'To emails (comma-separated)';  Secret = $false }
        Domain     = @{ Label = 'Sending domain';              Secret = $false }
        AccountSid = @{ Label = 'Account SID';                 Secret = $false }
        AuthToken  = @{ Label = 'Auth token';                  Secret = $true  }
        FromNumber = @{ Label = 'From number (e.g. +15551234567)'; Secret = $false }
        ToNumbers  = @{ Label = 'To numbers (comma-separated)'; Secret = $false }
        Server     = @{ Label = 'Syslog server host';          Secret = $false }
        Port       = @{ Label = 'Port (default 514)';          Secret = $false }
    }

    # Populate the type dropdown.
    foreach ($spec in $catalog) {
        $item = New-Object System.Windows.Controls.ComboBoxItem
        $item.Content = $spec.Display
        $item.Tag     = $spec.Type
        [void]$ctl.cb_Type.Items.Add($item)
    }
    $ctl.cb_Type.SelectedIndex = 0

    # Map of field-name -> input control for the currently-shown type.
    $fieldControls = @{}

    $rebuildFields = {
        $sel = $ctl.cb_Type.SelectedItem
        if (-not $sel) { return }
        $type = [string]$sel.Tag
        $spec = $catalog | Where-Object Type -eq $type | Select-Object -First 1
        $ctl.sp_Fields.Children.Clear()
        $fieldControls.Clear()
        if (-not $spec -or $spec.Fields.Count -eq 0) {
            $tb = New-Object System.Windows.Controls.TextBlock
            $tb.Text = 'This provider needs no fields — it is configured by defaults on this host.'
            $tb.Foreground = '#94A3B8'
            $tb.TextWrapping = 'Wrap'
            [void]$ctl.sp_Fields.Children.Add($tb)
            return
        }
        foreach ($f in $spec.Fields) {
            $meta = $fieldMeta[$f]
            $lbl = New-Object System.Windows.Controls.TextBlock
            $lbl.Text = if ($meta) { $meta.Label } else { $f }
            [void]$ctl.sp_Fields.Children.Add($lbl)
            if ($meta -and $meta.Secret) {
                $box = New-Object System.Windows.Controls.PasswordBox
            } else {
                $box = New-Object System.Windows.Controls.TextBox
            }
            [void]$ctl.sp_Fields.Children.Add($box)
            $fieldControls[$f] = $box
        }
    }.GetNewClosure()

    $ctl.cb_Type.Add_SelectionChanged($rebuildFields)
    & $rebuildFields

    $result = @{ Entry = $null }

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

    $ctl.btn_Save.Add_Click({
        $sel = $ctl.cb_Type.SelectedItem
        $type = [string]$sel.Tag
        $fields = @{}
        foreach ($k in $fieldControls.Keys) {
            $box = $fieldControls[$k]
            $fields[$k] = if ($box -is [System.Windows.Controls.PasswordBox]) { $box.Password } else { $box.Text }
        }
        $severity = "$($ctl.cb_Severity.SelectedItem.Content)"

        $built = New-SignalProviderEntry -Type $type -Fields $fields -SeverityThreshold $severity
        if ($built -is [hashtable] -and $built.Errors) {
            $ctl.tb_Error.Text = ($built.Errors -join ' ')
            return
        }
        $result.Entry = $built
        $win.Close()
    }.GetNewClosure())

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