feat(notifications): OAuth2 authority/scope management + CLI + UI surfaces
Additive UpdateSmtpConfigCommand params (OAuth2Authority/OAuth2Scope), preserve-on-null handler application, CLI --oauth2-authority/--oauth2-scope on smtp update, Central UI text inputs shown only for the OAuth2 auth type, and the Component-NotificationService doc paragraph with M365 defaults. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -142,6 +142,8 @@ public static class NotificationCommands
|
||||
updateCmd.Add(SmtpFromOption);
|
||||
updateCmd.Add(SmtpTlsModeOption);
|
||||
updateCmd.Add(SmtpCredentialsOption);
|
||||
updateCmd.Add(SmtpOAuth2AuthorityOption);
|
||||
updateCmd.Add(SmtpOAuth2ScopeOption);
|
||||
updateCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
@@ -172,6 +174,18 @@ public static class NotificationCommands
|
||||
Description = "SMTP credentials — 'username:password' for Basic, or client secret " +
|
||||
"for OAuth2 (optional; preserves existing if omitted)",
|
||||
};
|
||||
private static readonly Option<string?> SmtpOAuth2AuthorityOption =
|
||||
new("--oauth2-authority")
|
||||
{
|
||||
Description = "OAuth2 token-endpoint URL (optional; a '{tenant}' placeholder is " +
|
||||
"substituted from the credential; preserves existing / M365 default if omitted)",
|
||||
};
|
||||
private static readonly Option<string?> SmtpOAuth2ScopeOption =
|
||||
new("--oauth2-scope")
|
||||
{
|
||||
Description = "OAuth2 scope requested from the token endpoint " +
|
||||
"(optional; preserves existing / M365 default if omitted)",
|
||||
};
|
||||
|
||||
private static Option<string?> CreateTlsModeOption()
|
||||
{
|
||||
@@ -185,8 +199,9 @@ public static class NotificationCommands
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="UpdateSmtpConfigCommand"/> from a parsed <c>smtp update</c>
|
||||
/// invocation. The optional <c>--tls-mode</c> / <c>--credentials</c> flags map to
|
||||
/// null when omitted so the server-side handler preserves the existing values.
|
||||
/// invocation. The optional <c>--tls-mode</c> / <c>--credentials</c> /
|
||||
/// <c>--oauth2-authority</c> / <c>--oauth2-scope</c> flags map to null when omitted
|
||||
/// so the server-side handler preserves the existing values.
|
||||
/// </summary>
|
||||
/// <param name="result">The parsed command-line result from the <c>smtp update</c> invocation.</param>
|
||||
/// <returns>An <see cref="UpdateSmtpConfigCommand"/> populated from the parsed result.</returns>
|
||||
@@ -199,7 +214,9 @@ public static class NotificationCommands
|
||||
var from = result.GetValue(SmtpFromOption)!;
|
||||
var tlsMode = result.GetValue(SmtpTlsModeOption);
|
||||
var credentials = result.GetValue(SmtpCredentialsOption);
|
||||
return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials);
|
||||
var oauth2Authority = result.GetValue(SmtpOAuth2AuthorityOption);
|
||||
var oauth2Scope = result.GetValue(SmtpOAuth2ScopeOption);
|
||||
return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials, oauth2Authority, oauth2Scope);
|
||||
}
|
||||
|
||||
private static Command BuildSms(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
|
||||
+38
-1
@@ -56,6 +56,13 @@
|
||||
<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>
|
||||
@@ -101,6 +108,21 @@
|
||||
<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>
|
||||
@@ -135,6 +157,8 @@
|
||||
private string? _tlsMode;
|
||||
private string? _credentials;
|
||||
private string _fromAddress = string.Empty;
|
||||
private string? _oauth2Authority;
|
||||
private string? _oauth2Scope;
|
||||
private string? _formError;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
@@ -168,6 +192,8 @@
|
||||
_tlsMode = "None";
|
||||
_credentials = null;
|
||||
_fromAddress = string.Empty;
|
||||
_oauth2Authority = null;
|
||||
_oauth2Scope = null;
|
||||
_formError = null;
|
||||
_showForm = true;
|
||||
}
|
||||
@@ -181,6 +207,8 @@
|
||||
_tlsMode = smtp.TlsMode;
|
||||
_credentials = smtp.Credentials;
|
||||
_fromAddress = smtp.FromAddress;
|
||||
_oauth2Authority = smtp.OAuth2Authority;
|
||||
_oauth2Scope = smtp.OAuth2Scope;
|
||||
_formError = null;
|
||||
_showForm = true;
|
||||
}
|
||||
@@ -210,6 +238,8 @@
|
||||
_editingSmtp.TlsMode = _tlsMode;
|
||||
_editingSmtp.Credentials = _credentials?.Trim();
|
||||
_editingSmtp.FromAddress = _fromAddress.Trim();
|
||||
_editingSmtp.OAuth2Authority = NormalizeOptional(_oauth2Authority);
|
||||
_editingSmtp.OAuth2Scope = NormalizeOptional(_oauth2Scope);
|
||||
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
|
||||
}
|
||||
else
|
||||
@@ -218,7 +248,9 @@
|
||||
{
|
||||
Port = _port,
|
||||
TlsMode = _tlsMode,
|
||||
Credentials = _credentials?.Trim()
|
||||
Credentials = _credentials?.Trim(),
|
||||
OAuth2Authority = NormalizeOptional(_oauth2Authority),
|
||||
OAuth2Scope = NormalizeOptional(_oauth2Scope)
|
||||
};
|
||||
await NotificationRepository.AddSmtpConfigurationAsync(smtp);
|
||||
}
|
||||
@@ -232,4 +264,9 @@
|
||||
_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();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ public record CreateNotificationListCommand(string Name, IReadOnlyList<string> R
|
||||
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email, IReadOnlyList<string>? RecipientPhones = null);
|
||||
public record DeleteNotificationListCommand(int NotificationListId);
|
||||
public record ListSmtpConfigsCommand;
|
||||
public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null);
|
||||
public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null, string? OAuth2Authority = null, string? OAuth2Scope = null);
|
||||
public record ListSmsConfigsCommand;
|
||||
// FromNumber is optional: a Twilio Messaging-Service-only config supplies MessagingServiceSid
|
||||
// instead. At-least-one-of (FromNumber, MessagingServiceSid) is validated at the CLI/UI boundary.
|
||||
|
||||
@@ -1820,6 +1820,8 @@ public class ManagementActor : ReceiveActor
|
||||
c.MaxConcurrentConnections,
|
||||
c.MaxRetries,
|
||||
c.RetryDelay,
|
||||
c.OAuth2Authority,
|
||||
c.OAuth2Scope,
|
||||
HasCredentials = !string.IsNullOrEmpty(c.Credentials),
|
||||
};
|
||||
|
||||
@@ -1846,6 +1848,10 @@ public class ManagementActor : ReceiveActor
|
||||
// existing values intact (non-breaking for callers that do not send them).
|
||||
if (cmd.TlsMode is not null) config.TlsMode = cmd.TlsMode;
|
||||
if (cmd.Credentials is not null) config.Credentials = cmd.Credentials;
|
||||
// Preserve-if-null likewise for the OAuth2 token-endpoint overrides: an
|
||||
// omitted authority/scope leaves the stored value (or its M365 default) intact.
|
||||
if (cmd.OAuth2Authority is not null) config.OAuth2Authority = cmd.OAuth2Authority;
|
||||
if (cmd.OAuth2Scope is not null) config.OAuth2Scope = cmd.OAuth2Scope;
|
||||
await repo.UpdateSmtpConfigurationAsync(config);
|
||||
await repo.SaveChangesAsync();
|
||||
// Audit the credential-free shape — the *fact of* the change
|
||||
|
||||
Reference in New Issue
Block a user