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:
Joseph Doherty
2026-07-10 04:25:52 -04:00
parent 2e7be1e852
commit 0ee9975111
6 changed files with 126 additions and 5 deletions
@@ -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();
}