Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor
T

373 lines
16 KiB
Plaintext

@page "/notifications/smtp"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using SmtpConfigurationEntity = ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmtpConfiguration
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject INotificationRepository NotificationRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">SMTP Configuration</h4>
</div>
<ToastNotification @ref="_toast" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
@if (_smtpConfigs.Count == 0 && !_showForm)
{
<div class="text-center py-5 text-muted">
<p class="mb-3">No SMTP configuration set.</p>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">
Add SMTP configuration
</button>
</div>
}
else
{
@foreach (var smtp in _smtpConfigs)
{
<div class="card mb-3" @key="smtp.Id">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>@smtp.Host</strong>
@if (_editingSmtp?.Id != smtp.Id || !_showForm)
{
<button class="btn btn-outline-primary btn-sm" @onclick="() => StartEdit(smtp)">Edit</button>
}
</div>
<div class="card-body small">
<div class="row g-2">
<div class="col-md-4 text-muted">Transport</div>
<div class="col-md-8"><span class="badge bg-secondary">@(smtp.Transport ?? "Smtp")</span></div>
@if (IsEws(smtp.Transport))
{
<div class="col-md-4 text-muted">EWS URL</div>
<div class="col-md-8">@smtp.Host</div>
}
else
{
<div class="col-md-4 text-muted">Host</div>
<div class="col-md-8">@smtp.Host:@smtp.Port</div>
}
<div class="col-md-4 text-muted">Auth Type</div>
<div class="col-md-8"><span class="badge bg-secondary">@smtp.AuthType</span></div>
@if (!IsEws(smtp.Transport))
{
<div class="col-md-4 text-muted">TLS Mode</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.TlsMode) ? "(not set)" : smtp.TlsMode)</div>
}
<div class="col-md-4 text-muted">From Address</div>
<div class="col-md-8">@smtp.FromAddress</div>
<div class="col-md-4 text-muted">Credentials</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.Credentials) ? "(not set)" : "(stored)")</div>
@if (string.Equals(smtp.AuthType, "OAuth2", StringComparison.OrdinalIgnoreCase))
{
<div class="col-md-4 text-muted">OAuth2 Authority</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.OAuth2Authority) ? "(M365 default)" : smtp.OAuth2Authority)</div>
<div class="col-md-4 text-muted">OAuth2 Scope</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.OAuth2Scope) ? "(M365 default)" : smtp.OAuth2Scope)</div>
}
</div>
</div>
</div>
}
@if (_showForm)
{
<div class="card mb-3">
<div class="card-header">@(_editingSmtp != null ? "Edit SMTP Configuration" : "Add SMTP Configuration")</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Transport</label>
<select class="form-select" @bind="Transport">
<option>Smtp</option>
<option>Ews</option>
</select>
</div>
<div class="col-12">
<label class="form-label">@(IsEwsForm ? "EWS URL" : "Host")</label>
<input type="text" class="form-control" @bind="_host"
placeholder="@(IsEwsForm ? "https://mail.example.com/ews/exchange.asmx" : "smtp.example.com")" />
</div>
@if (!IsEwsForm)
{
<div class="col-md-4">
<label class="form-label">Port</label>
<input type="number" class="form-control" @bind="_port" min="1" max="65535" />
</div>
<div class="col-md-4">
<label class="form-label">Auth Type</label>
<select class="form-select" @bind="_authType">
<option>OAuth2</option>
<option>Basic</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">TLS Mode</label>
<select class="form-select" @bind="_tlsMode">
<option>None</option>
<option>StartTLS</option>
<option>SSL</option>
</select>
</div>
}
<div class="col-12">
<label class="form-label">Credentials</label>
<input type="password" class="form-control" @bind="_credentials"
placeholder="@(IsEwsForm ? @"domain\username:password" : "OAuth2 client secret or SMTP password")" />
<div class="form-text">Treat as sensitive — visible to admins only.</div>
</div>
<div class="col-12">
<label class="form-label">From Address</label>
<input type="email" class="form-control" @bind="_fromAddress"
placeholder="noreply@example.com" />
</div>
@if (_authType == "OAuth2")
{
<div class="col-12">
<label class="form-label">OAuth2 Authority</label>
<input type="text" class="form-control" @bind="_oauth2Authority"
placeholder="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" />
<div class="form-text">Token-endpoint URL; leave blank for the Microsoft 365 default. A <code>{tenant}</code> placeholder is substituted from the credential.</div>
</div>
<div class="col-12">
<label class="form-label">OAuth2 Scope</label>
<input type="text" class="form-control" @bind="_oauth2Scope"
placeholder="https://outlook.office365.com/.default" />
<div class="form-text">Scope requested from the token endpoint; leave blank for the Microsoft 365 default.</div>
</div>
}
@if (_formError != null)
{
<div class="col-12"><div class="text-danger small">@_formError</div></div>
}
<div class="col-12 text-end">
<button class="btn btn-outline-secondary me-1" @onclick="CancelForm">Cancel</button>
<button class="btn btn-success" @onclick="Save">Save</button>
</div>
</div>
</div>
</div>
}
else if (_smtpConfigs.Count == 0)
{
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add SMTP configuration</button>
}
}
}
</div>
@code {
private bool _loading = true;
private string? _errorMessage;
private List<SmtpConfigurationEntity> _smtpConfigs = new();
private bool _showForm;
private SmtpConfigurationEntity? _editingSmtp;
private string _host = string.Empty;
private int _port = 587;
private string _transport = "Smtp";
// AuthType as it was before the operator switched to Ews, so switching back to Smtp
// restores their choice rather than leaving the forced "Basic" behind.
private string _authTypeBeforeEws = "OAuth2";
private string _authType = "OAuth2";
private string? _tlsMode;
private string? _credentials;
private string _fromAddress = string.Empty;
private string? _oauth2Authority;
private string? _oauth2Scope;
private string? _formError;
private ToastNotification _toast = default!;
/// <summary>
/// Bound by the Transport select. The setter is where the transport's shape rules are
/// applied: EWS authenticates with HTTP Basic only, so selecting it forces the auth type
/// (which also hides the OAuth2 inputs, gated on AuthType as before). Switching back to
/// Smtp restores the auth type the operator had chosen.
/// </summary>
private string Transport
{
get => _transport;
set
{
if (string.Equals(_transport, value, StringComparison.Ordinal))
{
return;
}
if (IsEws(value))
{
_authTypeBeforeEws = _authType;
_authType = "Basic";
}
else
{
_authType = _authTypeBeforeEws;
}
_transport = value;
}
}
/// <summary>Gets a value indicating whether the edit form is currently in EWS mode.</summary>
private bool IsEwsForm => IsEws(_transport);
/// <summary>Null-safe, case-insensitive test for the EWS transport; null/blank means Smtp.</summary>
private static bool IsEws(string? transport)
=> string.Equals(transport?.Trim(), "Ews", StringComparison.OrdinalIgnoreCase);
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
_errorMessage = null;
try
{
_smtpConfigs = (await NotificationRepository.GetAllSmtpConfigurationsAsync()).ToList();
}
catch (Exception ex)
{
_errorMessage = ex.Message;
}
_loading = false;
}
private void ShowAddForm()
{
_editingSmtp = null;
_host = string.Empty;
_port = 587;
_transport = "Smtp";
_authTypeBeforeEws = "OAuth2";
_authType = "OAuth2";
_tlsMode = "None";
_credentials = null;
_fromAddress = string.Empty;
_oauth2Authority = null;
_oauth2Scope = null;
_formError = null;
_showForm = true;
}
private void StartEdit(SmtpConfigurationEntity smtp)
{
_editingSmtp = smtp;
_host = smtp.Host;
_port = smtp.Port;
_transport = smtp.Transport ?? "Smtp";
_authTypeBeforeEws = smtp.AuthType;
_authType = smtp.AuthType;
_tlsMode = smtp.TlsMode;
_credentials = smtp.Credentials;
_fromAddress = smtp.FromAddress;
_oauth2Authority = smtp.OAuth2Authority;
_oauth2Scope = smtp.OAuth2Scope;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_formError = null;
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_host) || string.IsNullOrWhiteSpace(_fromAddress))
{
_formError = "Host and From Address are required.";
return;
}
// This page writes through the repository, so the ManagementActor's EWS shape gate
// is not on this path — mirror its Host rule here (auth type is already forced to
// Basic by the Transport setter) so an unusable row cannot be saved from the UI.
if (IsEwsForm
&& (!Uri.TryCreate(_host.Trim(), UriKind.Absolute, out var ewsEndpoint)
|| !string.Equals(ewsEndpoint.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)))
{
_formError = "EWS transport requires an absolute https:// EWS endpoint URL "
+ "(e.g. https://mail.example.com/ews/exchange.asmx).";
return;
}
// Mirror of the same gate's credentials rule, with the identical FIRST-colon-only
// split: a domain-qualified user name contains no colon but a password may. This
// page has no preserve-if-blank behaviour — the field's value IS what gets
// persisted, and a blank field clears the stored credential — so the rule applies
// on edit as well as add, exactly as the management gate validates the effective row.
if (IsEwsForm)
{
var credentialParts = _credentials?.Trim().Split(':', 2) ?? [];
if (credentialParts.Length != 2
|| credentialParts[0].Length == 0
|| credentialParts[1].Length == 0)
{
_formError = "EWS credentials must be in 'username:password' form "
+ @"(username may be 'domain\username').";
return;
}
}
try
{
if (_editingSmtp != null)
{
_editingSmtp.Host = _host.Trim();
_editingSmtp.Port = _port;
_editingSmtp.Transport = _transport;
_editingSmtp.AuthType = _authType;
_editingSmtp.TlsMode = _tlsMode;
_editingSmtp.Credentials = _credentials?.Trim();
_editingSmtp.FromAddress = _fromAddress.Trim();
_editingSmtp.OAuth2Authority = NormalizeOptional(_oauth2Authority);
_editingSmtp.OAuth2Scope = NormalizeOptional(_oauth2Scope);
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
}
else
{
var smtp = new SmtpConfigurationEntity(_host.Trim(), _authType, _fromAddress.Trim())
{
Port = _port,
Transport = _transport,
TlsMode = _tlsMode,
Credentials = _credentials?.Trim(),
OAuth2Authority = NormalizeOptional(_oauth2Authority),
OAuth2Scope = NormalizeOptional(_oauth2Scope)
};
await NotificationRepository.AddSmtpConfigurationAsync(smtp);
}
await NotificationRepository.SaveChangesAsync();
_showForm = false;
_toast.ShowSuccess("SMTP configuration saved.");
await LoadAsync();
}
catch (Exception ex)
{
_formError = ex.Message;
}
}
// Blank OAuth2 authority/scope means "use the Microsoft 365 default" — persist null,
// not an empty string, so the delivery adapter's default-substitution path fires.
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}