From fc39d4a42811043b19c43608ee87169960600302 Mon Sep 17 00:00:00 2001 From: "boston-ai-agent[bot]" <299475768+boston-ai-agent[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:16:00 +0200 Subject: [PATCH] feat(ui): consolidate email and automation administration (#58) --- .../Settings/EmailSettingsDialog.razor | 261 ----------- .../Components/Layout/MainLayout.razor | 9 - .../Components/Layout/NavMenu.razor | 82 +++- .../Pages/Admin/Email/EmailSettings.razor | 434 ++++++++++++++++++ .../OrchestrationConnectivity.razor | 7 +- .../Api/EmailSettingsEndpointsTests.cs | 42 +- .../NewWeb/AdministrationNavigationTests.cs | 48 ++ .../NewWeb/EmailRulesAdminUxTests.cs | 8 +- .../NewWeb/EmailSettingsDialogTests.cs | 59 --- .../NewWeb/EmailSettingsPageTests.cs | 85 ++++ .../PageAuthorizationConventionsTests.cs | 10 + .../NewWeb/WebAuthRoutesTests.cs | 47 ++ tests/ux/admin-email-settings.spec.ts | 76 +++ 13 files changed, 816 insertions(+), 352 deletions(-) delete mode 100644 src/HelpDesk.NewWeb/Components/Dialogs/Settings/EmailSettingsDialog.razor create mode 100644 src/HelpDesk.NewWeb/Components/Pages/Admin/Email/EmailSettings.razor create mode 100644 tests/Helpdesk.Tests/NewWeb/AdministrationNavigationTests.cs delete mode 100644 tests/Helpdesk.Tests/NewWeb/EmailSettingsDialogTests.cs create mode 100644 tests/Helpdesk.Tests/NewWeb/EmailSettingsPageTests.cs create mode 100644 tests/ux/admin-email-settings.spec.ts diff --git a/src/HelpDesk.NewWeb/Components/Dialogs/Settings/EmailSettingsDialog.razor b/src/HelpDesk.NewWeb/Components/Dialogs/Settings/EmailSettingsDialog.razor deleted file mode 100644 index 38546879..00000000 --- a/src/HelpDesk.NewWeb/Components/Dialogs/Settings/EmailSettingsDialog.razor +++ /dev/null @@ -1,261 +0,0 @@ -@using System.Net.Http.Json -@inject IHttpClientFactory HttpClientFactory -@inject ISnackbar Snackbar - - - - - - - - Email Settings - - - - - @if (!Model.Enabled) - { - - This mailbox is DISABLED. No emails will be ingested until the mailbox is enabled. - - } - else if (!Model.BackgroundSyncEnabled) - { - - Background ingestion is DISABLED. The mailbox settings are saved, but the worker will not process mail. - - } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Leave blank to keep the existing secret. - - - - - - - - @if (Tested) - { - @if (LastTestOk) - { - - Connection test passed. - - } - else - { - - Connection test failed. @TestMessage - - } - } - - - - - - Close - - - @if (Testing) - { - - } - Test Connection - - - - @if (Saving) - { - - } - Save - - - - -@code { - - [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; - - private sealed class EmailInboxSettingsForm - { - public Guid Id { get; set; } - public string MailHost { get; set; } = "outlook.office365.com"; - public int Port { get; set; } = 993; - public bool UseSsl { get; set; } = true; - public string MailboxAddress { get; set; } = string.Empty; - public string? TenantId { get; set; } - public string? ClientId { get; set; } - public string? ClientSecret { get; set; } - public string MailboxFolder { get; set; } = "INBOX"; - public bool Enabled { get; set; } - public bool BackgroundSyncEnabled { get; set; } - public bool HasClientSecret { get; set; } - } - - private EmailInboxSettingsForm Model = new(); - private bool Loading; - private bool Saving; - private bool Testing; - - private bool Tested; - private bool LastTestOk; - private string? TestMessage; - - private HttpClient Api => HttpClientFactory.CreateClient("HelpdeskApi"); - - protected override async Task OnInitializedAsync() - { - Loading = true; - try - { - var settings = await Api.GetFromJsonAsync>("/api/v1/email-settings"); - if (settings?.Count > 0) - { - Model = settings[0]; - Model.ClientSecret = string.Empty; - } - } - catch (Exception ex) - { - Snackbar.Add($"Failed to load settings: {ex.Message}", Severity.Error); - } - finally - { - Loading = false; - } - } - - private async Task TestAsync() - { - Testing = true; - Tested = false; - TestMessage = null; - try - { - var res = await Api.PostAsJsonAsync("/api/v1/email-settings/test", BuildPayload()); - if (res.IsSuccessStatusCode) - { - LastTestOk = true; - TestMessage = "OK"; - Snackbar.Add("IMAP connection successful.", Severity.Success); - } - else - { - LastTestOk = false; - TestMessage = await res.Content.ReadAsStringAsync(); - Snackbar.Add("IMAP connection failed.", Severity.Error); - } - } - catch (Exception ex) - { - LastTestOk = false; - TestMessage = ex.Message; - Snackbar.Add($"IMAP test error: {ex.Message}", Severity.Error); - } - finally - { - Tested = true; - Testing = false; - } - } - - private async Task SaveAsync() - { - Saving = true; - try - { - var payload = BuildPayload(); - var res = Model.Id == Guid.Empty - ? await Api.PostAsJsonAsync("/api/v1/email-settings", payload) - : await Api.PutAsJsonAsync($"/api/v1/email-settings/{Model.Id}", payload); - if (res.IsSuccessStatusCode) - { - Snackbar.Add("Settings saved.", Severity.Success); - MudDialog.Close(DialogResult.Ok(true)); - } - else - { - var err = await res.Content.ReadAsStringAsync(); - Snackbar.Add($"Save failed: {err}", Severity.Error); - } - } - catch (Exception ex) - { - Snackbar.Add($"Save error: {ex.Message}", Severity.Error); - } - finally - { - Saving = false; - } - } - - private EmailInboxSettingsForm BuildPayload() - { - return new EmailInboxSettingsForm - { - Id = Model.Id, - MailHost = Model.MailHost, - Port = Model.Port, - UseSsl = Model.UseSsl, - MailboxAddress = Model.MailboxAddress, - TenantId = Model.TenantId, - ClientId = Model.ClientId, - ClientSecret = string.IsNullOrWhiteSpace(Model.ClientSecret) ? null : Model.ClientSecret, - MailboxFolder = Model.MailboxFolder, - Enabled = Model.Enabled, - BackgroundSyncEnabled = Model.BackgroundSyncEnabled, - HasClientSecret = Model.HasClientSecret - }; - } - - private void Cancel() => MudDialog.Cancel(); -} diff --git a/src/HelpDesk.NewWeb/Components/Layout/MainLayout.razor b/src/HelpDesk.NewWeb/Components/Layout/MainLayout.razor index fbbd273e..ce189d73 100644 --- a/src/HelpDesk.NewWeb/Components/Layout/MainLayout.razor +++ b/src/HelpDesk.NewWeb/Components/Layout/MainLayout.razor @@ -62,7 +62,6 @@ Profile Preferences - Email Settings @if (SupportsLocalAccounts) { @@ -90,7 +89,6 @@ Profile Preferences - Email Settings @if (SupportsLocalAccounts) { @@ -139,7 +137,6 @@ string.Equals(Configuration["Authentication:Mode"], "Local", StringComparison.OrdinalIgnoreCase) || string.Equals(Configuration["Authentication:Mode"], "Hybrid", StringComparison.OrdinalIgnoreCase); - [Inject] private IDialogService DialogService { get; set; } = default!; private bool _drawerOpen = true; private bool searchOpen; private Helpdesk.Shared.Build.BuildInfo _appBarVersion = new("dev", "unknown", "unknown", "HelpDesk.NewWeb", "unknown"); @@ -200,12 +197,6 @@ return Task.CompletedTask; } - private async Task OpenEmailSettings() - { - var options = new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true }; - await DialogService.ShowAsync("IMAP Settings", options); - } - public async ValueTask DisposeAsync() { if (hotkeyRegistrationId.HasValue) diff --git a/src/HelpDesk.NewWeb/Components/Layout/NavMenu.razor b/src/HelpDesk.NewWeb/Components/Layout/NavMenu.razor index 1bd7bcd7..f34ad2fe 100644 --- a/src/HelpDesk.NewWeb/Components/Layout/NavMenu.razor +++ b/src/HelpDesk.NewWeb/Components/Layout/NavMenu.razor @@ -41,8 +41,6 @@ Hangfire Ops - Connectivity - Pending Emails @@ -67,7 +65,7 @@ Notifications - + Team @@ -82,18 +80,25 @@ Tenant SLA Settings - Roles & Permissions - Automation Rules - Service Explorer - Ticket Categories - Webhooks - Branding & Identity + Roles & Permissions - - Email Rules - Email Templates - Email Layouts + + Mailbox Configuration + Pending Emails + Email Rules + Email Templates + Email Layouts + + + Orchestrator + Automation Rules + Service Explorer + Webhooks + + + Ticket Categories + Branding & Identity Logout @@ -106,20 +111,65 @@ @code { - private string? currentUrl; + private bool administrationExpanded; + private bool emailSettingsExpanded; + private bool automationExpanded; protected override void OnInitialized() { - currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); + UpdateExpandedGroups(NavigationManager.Uri); NavigationManager.LocationChanged += OnLocationChanged; } private void OnLocationChanged(object? sender, LocationChangedEventArgs e) { - currentUrl = NavigationManager.ToBaseRelativePath(e.Location); + UpdateExpandedGroups(e.Location); StateHasChanged(); } + private void OnAdministrationExpandedChanged(bool expanded) => administrationExpanded = expanded; + + private void OnEmailSettingsExpandedChanged(bool expanded) => emailSettingsExpanded = expanded; + + private void OnAutomationExpandedChanged(bool expanded) => automationExpanded = expanded; + + private void UpdateExpandedGroups(string location) + { + var path = NavigationManager.ToBaseRelativePath(location) + .Split(['?', '#'], 2)[0] + .TrimStart('/'); + + var isEmailSettingsPath = MatchesPath(path, "admin/email-settings") + || MatchesPath(path, "admin/pending-emails") + || MatchesPath(path, "admin/email-rules") + || MatchesPath(path, "admin/templates") + || MatchesPath(path, "admin/layouts"); + var isAutomationPath = MatchesPath(path, "settings/connectivity") + || MatchesPath(path, "admin/orchestration/orchestration") + || MatchesPath(path, "admin/automation") + || MatchesPath(path, "admin/requests/services") + || MatchesPath(path, "admin/ai-assistant-webhooks"); + + if (isEmailSettingsPath || isAutomationPath) + { + administrationExpanded = true; + } + + if (isEmailSettingsPath) + { + emailSettingsExpanded = true; + } + + if (isAutomationPath) + { + automationExpanded = true; + } + } + + private static bool MatchesPath(string path, string target) => + string.Equals(path, target, StringComparison.OrdinalIgnoreCase) + || path.StartsWith($"{target}/", StringComparison.OrdinalIgnoreCase); + public void Dispose() { NavigationManager.LocationChanged -= OnLocationChanged; diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Email/EmailSettings.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Email/EmailSettings.razor new file mode 100644 index 00000000..eea44d1d --- /dev/null +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Email/EmailSettings.razor @@ -0,0 +1,434 @@ +@page "/admin/email-settings" +@attribute [Authorize(Roles = "HelpdeskAdmin")] +@using System.Net.Http.Json +@implements IDisposable +@inject IHttpClientFactory HttpClientFactory +@inject IJSRuntime JS +@inject ISnackbar Snackbar + +Email Settings / Mailbox Configuration + + + +
+
+ Email Settings / Mailbox Configuration + Configure the mailbox connection and control background ingestion. +
+
+ + @if (loading) + { + + + Loading mailbox configuration… + + } + else if (!loaded) + { + + @loadError + Retry + + } + else + { + + + + + Mailbox connection + Connection details used to read incoming mail. + + + + + + + + + + + + + + + + + + + + + + + Provider credentials + These identify the mail provider; Tenant ID is not a RatelDesk organization selection. + + + + + + @if (model.HasClientSecret) + { + A client secret is configured. Leave this field blank to keep it. + } + else + { + No client secret is configured. + } + + + + + + + + Ingestion + Mailbox availability and background processing are saved independently. + + + + + + + + + @if (!model.Enabled) + { + This mailbox is disabled. No email will be ingested until it is enabled and saved. + } + else if (!model.BackgroundSyncEnabled) + { + Background ingestion is disabled. The saved mailbox will not process mail. + } + + + + + @if (testResult is not null) + { + + @testResult.Message + + } + +
+ Discard changes + + @if (testing) + { + + } + Test Connection + + + @if (saving) + { + + } + Save + +
+
+ } +
+ +@code { + private readonly CancellationTokenSource disposeCts = new(); + private MudForm form = default!; + private EmailInboxSettingsForm model = new(); + private EmailInboxSettingsForm? savedBaseline; + private ConnectionTestResult? testResult; + private bool loading = true; + private bool loaded; + private bool saving; + private bool testing; + private bool isDirty; + private bool disposed; + private string loadError = "Mailbox settings could not be loaded."; + + private HttpClient HelpdeskApi => HttpClientFactory.CreateClient("HelpdeskApi"); + private bool mutationsDisabled => loading || !loaded || saving || testing; + + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() + { + if (loading && loaded) + { + return; + } + + loading = true; + loaded = false; + testResult = null; + try + { + var settings = await HelpdeskApi.GetFromJsonAsync>("/api/v1/email-settings", disposeCts.Token); + model = settings is { Count: > 0 } ? EmailInboxSettingsForm.FromDto(settings[0]) : new EmailInboxSettingsForm(); + savedBaseline = model.Clone(); + isDirty = false; + loaded = true; + } + catch (OperationCanceledException) when (disposeCts.IsCancellationRequested) + { + return; + } + catch (Exception) + { + loadError = "Mailbox settings could not be loaded. Retry before making changes."; + } + finally + { + if (!disposed) + { + loading = false; + } + } + } + + private async Task SaveAsync() + { + if (mutationsDisabled) + { + return; + } + + saving = true; + try + { + if (!await ValidateAsync()) + { + return; + } + + using var response = model.Id == Guid.Empty + ? await HelpdeskApi.PostAsJsonAsync("/api/v1/email-settings", BuildPayload(), disposeCts.Token) + : await HelpdeskApi.PutAsJsonAsync($"/api/v1/email-settings/{model.Id}", BuildPayload(), disposeCts.Token); + + if (!response.IsSuccessStatusCode) + { + Snackbar.Add("Mailbox settings could not be saved. Your changes are still available.", Severity.Error); + return; + } + + var saved = await response.Content.ReadFromJsonAsync(disposeCts.Token); + if (saved is null) + { + Snackbar.Add("Mailbox settings were not confirmed by the server. Your changes are still available.", Severity.Error); + return; + } + + model = EmailInboxSettingsForm.FromDto(saved); + savedBaseline = model.Clone(); + isDirty = false; + testResult = null; + Snackbar.Add("Mailbox settings saved.", Severity.Success); + } + catch (OperationCanceledException) when (disposeCts.IsCancellationRequested) + { + return; + } + catch (Exception) + { + Snackbar.Add("Mailbox settings could not be saved. Your changes are still available.", Severity.Error); + } + finally + { + if (!disposed) + { + saving = false; + } + } + } + + private async Task TestAsync() + { + if (mutationsDisabled) + { + return; + } + + testing = true; + testResult = null; + try + { + if (!await ValidateAsync()) + { + return; + } + + using var response = await HelpdeskApi.PostAsJsonAsync("/api/v1/email-settings/test", BuildPayload(), disposeCts.Token); + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync(disposeCts.Token); + testResult = new(true, result?.Message ?? "Connection test passed."); + Snackbar.Add("Mailbox connection test passed.", Severity.Success); + } + else + { + testResult = new(false, "Connection test failed. Check the mailbox connection details and try again."); + Snackbar.Add("Mailbox connection test failed.", Severity.Error); + } + } + catch (OperationCanceledException) when (disposeCts.IsCancellationRequested) + { + return; + } + catch (Exception) + { + testResult = new(false, "Connection test could not be completed. Check the mailbox connection details and try again."); + Snackbar.Add("Mailbox connection test could not be completed.", Severity.Error); + } + finally + { + if (!disposed) + { + testing = false; + } + } + } + + private async Task DiscardChangesAsync() + { + if (mutationsDisabled || savedBaseline is null) + { + return; + } + + model = savedBaseline.Clone(); + isDirty = false; + testResult = null; + await InvokeAsync(StateHasChanged); + } + + private async Task ConfirmInternalNavigationAsync(LocationChangingContext context) + { + if (!isDirty) + { + return; + } + + if (saving || testing || !await JS.InvokeAsync("confirm", "Discard unsaved Email Settings changes?")) + { + context.PreventNavigation(); + } + } + + private async Task ValidateAsync() + { + await form.ValidateAsync(); + if (form.IsValid) + { + return true; + } + + Snackbar.Add("Complete the required mailbox connection fields before continuing.", Severity.Warning); + return false; + } + + private void MarkDirty() + { + if (!loaded || savedBaseline is null) + { + return; + } + + isDirty = !model.Matches(savedBaseline); + testResult = null; + } + + private EmailInboxSettings BuildPayload() => new() + { + Id = model.Id, + MailHost = model.MailHost, + Port = model.Port, + UseSsl = model.UseSsl, + MailboxAddress = model.MailboxAddress, + TenantId = model.TenantId, + ClientId = model.ClientId, + ClientSecret = string.IsNullOrWhiteSpace(model.ClientSecret) ? null : model.ClientSecret, + MailboxFolder = model.MailboxFolder, + Enabled = model.Enabled, + BackgroundSyncEnabled = model.BackgroundSyncEnabled + }; + + private static IEnumerable ValidatePort(int port) + => port is >= 1 and <= 65535 ? [] : ["Enter a port between 1 and 65535."]; + + private static IEnumerable ValidateMailboxAddress(string? value) + => !string.IsNullOrWhiteSpace(value) && value.Contains('@', StringComparison.Ordinal) + ? [] + : ["Enter a valid mailbox address."]; + + public void Dispose() + { + disposed = true; + disposeCts.Cancel(); + disposeCts.Dispose(); + } + + private sealed class EmailInboxSettingsForm + { + public Guid Id { get; set; } + public string MailHost { get; set; } = "outlook.office365.com"; + public int Port { get; set; } = 993; + public bool UseSsl { get; set; } = true; + public string MailboxAddress { get; set; } = string.Empty; + public string? TenantId { get; set; } + public string? ClientId { get; set; } + public string? ClientSecret { get; set; } + public string MailboxFolder { get; set; } = "INBOX"; + public bool Enabled { get; set; } + public bool BackgroundSyncEnabled { get; set; } + public bool HasClientSecret { get; set; } + + public static EmailInboxSettingsForm FromDto(EmailInboxSettingsDto dto) => new() + { + Id = dto.Id, + MailHost = dto.MailHost, + Port = dto.Port, + UseSsl = dto.UseSsl, + MailboxAddress = dto.MailboxAddress, + TenantId = dto.TenantId, + ClientId = dto.ClientId, + MailboxFolder = dto.MailboxFolder, + Enabled = dto.Enabled, + BackgroundSyncEnabled = dto.BackgroundSyncEnabled, + HasClientSecret = dto.HasClientSecret + }; + + public EmailInboxSettingsForm Clone() => new() + { + Id = Id, + MailHost = MailHost, + Port = Port, + UseSsl = UseSsl, + MailboxAddress = MailboxAddress, + TenantId = TenantId, + ClientId = ClientId, + ClientSecret = ClientSecret, + MailboxFolder = MailboxFolder, + Enabled = Enabled, + BackgroundSyncEnabled = BackgroundSyncEnabled, + HasClientSecret = HasClientSecret + }; + + public bool Matches(EmailInboxSettingsForm other) => + Id == other.Id && + MailHost == other.MailHost && + Port == other.Port && + UseSsl == other.UseSsl && + MailboxAddress == other.MailboxAddress && + TenantId == other.TenantId && + ClientId == other.ClientId && + ClientSecret == other.ClientSecret && + MailboxFolder == other.MailboxFolder && + Enabled == other.Enabled && + BackgroundSyncEnabled == other.BackgroundSyncEnabled && + HasClientSecret == other.HasClientSecret; + } + + private sealed record ConnectionTestResult(bool Success, string Message); + private sealed record ConnectionTestResponse(bool Success, string? Message); +} diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Orchestration/OrchestrationConnectivity.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Orchestration/OrchestrationConnectivity.razor index 5e8306cd..357c8160 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/Orchestration/OrchestrationConnectivity.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Orchestration/OrchestrationConnectivity.razor @@ -7,11 +7,14 @@ @inject IHttpClientFactory HttpClientFactory @inject ISnackbar Snackbar -Connectivity Status +Orchestrator - External orchestration Connectivity Status +
+ Orchestrator + Connectivity and validation +
@if (settings is not null) { diff --git a/tests/Helpdesk.Tests/Api/EmailSettingsEndpointsTests.cs b/tests/Helpdesk.Tests/Api/EmailSettingsEndpointsTests.cs index 5f353184..23bb5142 100644 --- a/tests/Helpdesk.Tests/Api/EmailSettingsEndpointsTests.cs +++ b/tests/Helpdesk.Tests/Api/EmailSettingsEndpointsTests.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Claims; @@ -23,6 +24,26 @@ namespace Helpdesk.Tests.Api; public class EmailSettingsEndpointsTests { + [Fact] + public async Task Get_RequiresHelpdeskAdminAndDoesNotReturnSettingsToDeniedCallers() + { + await using var harness = await Harness.CreateAsync(); + await harness.SeedAsync(clientSecret: "stored-secret"); + + using var anonymousClient = harness.CreateClient(); + var anonymous = await anonymousClient.GetAsync("/api/v1/email-settings"); + + Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); + + using var ordinaryClient = harness.CreateClient("Technician"); + var ordinaryUser = await ordinaryClient.GetAsync("/api/v1/email-settings"); + var deniedContent = await ordinaryUser.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.Forbidden, ordinaryUser.StatusCode); + Assert.DoesNotContain("stored-secret", deniedContent, StringComparison.Ordinal); + Assert.DoesNotContain("helpdesk@example.com", deniedContent, StringComparison.Ordinal); + } + [Fact] public async Task Get_ReturnsDisabledSettings() { @@ -225,6 +246,17 @@ private Harness(WebApplication app, HttpClient client, IImapEmailService imap) public HttpClient Client { get; } public IImapEmailService Imap { get; } + public HttpClient CreateClient(string? role = null) + { + var client = _app.GetTestClient(); + if (role is not null) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test", role); + } + + return client; + } + public static async Task CreateAsync() { var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Development" }); @@ -313,10 +345,18 @@ public TestAuthHandler(IOptionsMonitor options, ILo protected override Task HandleAuthenticateAsync() { + if (!Request.Headers.TryGetValue("Authorization", out var authorization)) + { + return Task.FromResult(AuthenticateResult.Fail("No authorization header")); + } + + var role = authorization.ToString().Contains(HelpdeskPermissions.HelpdeskAdmin, StringComparison.OrdinalIgnoreCase) + ? HelpdeskPermissions.HelpdeskAdmin + : "Technician"; var claims = new[] { new Claim(ClaimTypes.Name, "email-settings-test"), - new Claim(ClaimTypes.Role, HelpdeskPermissions.HelpdeskAdmin) + new Claim(ClaimTypes.Role, role) }; var identity = new ClaimsIdentity(claims, Scheme.Name); var principal = new ClaimsPrincipal(identity); diff --git a/tests/Helpdesk.Tests/NewWeb/AdministrationNavigationTests.cs b/tests/Helpdesk.Tests/NewWeb/AdministrationNavigationTests.cs new file mode 100644 index 00000000..45ec8b2a --- /dev/null +++ b/tests/Helpdesk.Tests/NewWeb/AdministrationNavigationTests.cs @@ -0,0 +1,48 @@ +namespace Helpdesk.Tests.NewWeb; + +public class AdministrationNavigationTests +{ + private static readonly string Source = File.ReadAllText(Path.Combine( + TestEnvironment.RepositoryRoot, + "src", + "HelpDesk.NewWeb", + "Components", + "Layout", + "NavMenu.razor")); + + [Fact] + public void Administration_HasOneEmailSettingsGroupWithTheRequiredDestinations() + { + Assert.Equal(1, Count("Title=\"Email Settings\"")); + Assert.Equal(1, Count("Href=\"/admin/email-settings\"")); + Assert.Equal(1, Count("Href=\"/admin/pending-emails\"")); + Assert.Equal(1, Count("Href=\"/admin/email-rules\"")); + Assert.Equal(1, Count("Href=\"/admin/templates\"")); + Assert.Equal(1, Count("Href=\"/admin/layouts\"")); + Assert.DoesNotContain("Title=\"Email Design\"", Source, StringComparison.Ordinal); + } + + [Fact] + public void Administration_HasOneAutomationGroupAndPreservesRoutesAndAliases() + { + Assert.Equal(1, Count("Title=\"Automation\"")); + Assert.Equal(1, Count("Href=\"/settings/connectivity\"")); + Assert.Equal(1, Count("Href=\"/admin/automation\"")); + Assert.Equal(1, Count("Href=\"/admin/requests/services\"")); + Assert.Equal(1, Count("Href=\"/admin/ai-assistant-webhooks\"")); + Assert.Contains("admin/orchestration/orchestration", Source, StringComparison.Ordinal); + Assert.Contains("MatchesPath(path, \"admin/requests/services\")", Source, StringComparison.Ordinal); + } + + [Fact] + public void RelocatedGroups_UseLocationAwareExpansionWithoutDuplicatingOperationsLinks() + { + Assert.Contains("ExpandedChanged=\"OnEmailSettingsExpandedChanged\"", Source, StringComparison.Ordinal); + Assert.Contains("ExpandedChanged=\"OnAutomationExpandedChanged\"", Source, StringComparison.Ordinal); + Assert.Contains("UpdateExpandedGroups(e.Location);", Source, StringComparison.Ordinal); + Assert.Equal(1, Count("Href=\"/admin/pending-emails\"")); + Assert.Equal(1, Count("Href=\"/settings/connectivity\"")); + } + + private static int Count(string value) => Source.Split(value, StringSplitOptions.None).Length - 1; +} diff --git a/tests/Helpdesk.Tests/NewWeb/EmailRulesAdminUxTests.cs b/tests/Helpdesk.Tests/NewWeb/EmailRulesAdminUxTests.cs index 82bf5fbc..a7f8261e 100644 --- a/tests/Helpdesk.Tests/NewWeb/EmailRulesAdminUxTests.cs +++ b/tests/Helpdesk.Tests/NewWeb/EmailRulesAdminUxTests.cs @@ -12,16 +12,16 @@ public void EmailRulesPage_IsRoutableAndHelpdeskAdminOnly() } [Fact] - public void NavMenu_LinksEmailRulesUnderAdminEmailDesign() + public void NavMenu_LinksEmailRulesUnderAdminEmailSettings() { var source = ReadNewWebSource("Components/Layout/NavMenu.razor"); - var emailDesignIndex = source.IndexOf("Title=\"Email Design\"", StringComparison.Ordinal); + var emailSettingsIndex = source.IndexOf("Title=\"Email Settings\"", StringComparison.Ordinal); var emailRulesIndex = source.IndexOf("Href=\"/admin/email-rules\"", StringComparison.Ordinal); var templatesIndex = source.IndexOf("Href=\"/admin/templates\"", StringComparison.Ordinal); - Assert.True(emailDesignIndex >= 0); - Assert.True(emailRulesIndex > emailDesignIndex); + Assert.True(emailSettingsIndex >= 0); + Assert.True(emailRulesIndex > emailSettingsIndex); Assert.True(templatesIndex > emailRulesIndex); Assert.Contains(">Email Rules", source, StringComparison.Ordinal); } diff --git a/tests/Helpdesk.Tests/NewWeb/EmailSettingsDialogTests.cs b/tests/Helpdesk.Tests/NewWeb/EmailSettingsDialogTests.cs deleted file mode 100644 index 1d3079a5..00000000 --- a/tests/Helpdesk.Tests/NewWeb/EmailSettingsDialogTests.cs +++ /dev/null @@ -1,59 +0,0 @@ -namespace Helpdesk.Tests.NewWeb; - -public class EmailSettingsDialogTests -{ - private static readonly string Source = ReadDialogSource(); - - [Fact] - public void DialogLoadsFirstEmailSettingsRecord() - { - Assert.Contains("GetFromJsonAsync>(\"/api/v1/email-settings\")", Source); - Assert.Contains("Model = settings[0];", Source); - Assert.Contains("Model.ClientSecret = string.Empty;", Source); - } - - [Fact] - public void DialogSavesExistingRecordWithPut() - { - Assert.Contains("Api.PutAsJsonAsync($\"/api/v1/email-settings/{Model.Id}\"", Source); - Assert.DoesNotContain("/api/v1/email/imap/save", Source); - } - - [Fact] - public void DialogTestsConnectionWithoutLegacyImapEndpoint() - { - Assert.Contains("Api.PostAsJsonAsync(\"/api/v1/email-settings/test\"", Source); - Assert.DoesNotContain("/api/v1/email/imap/test", Source); - } - - [Fact] - public void DialogBindsMailboxSslAndEnabledFields() - { - Assert.Contains("@bind-Value=\"Model.MailboxAddress\"", Source); - Assert.Contains("@bind-Value=\"Model.UseSsl\"", Source); - Assert.Contains("@bind-Value=\"Model.Enabled\"", Source); - Assert.Contains("@bind-Value=\"Model.BackgroundSyncEnabled\"", Source); - Assert.Contains("Label=\"Mailbox enabled\"", Source); - Assert.Contains("Label=\"Background ingestion enabled\"", Source); - } - - [Fact] - public void BlankClientSecretIsNotSentAsANewSecret() - { - Assert.Contains("ClientSecret = string.IsNullOrWhiteSpace(Model.ClientSecret) ? null : Model.ClientSecret", Source); - Assert.Contains("Leave blank to keep the existing secret.", Source); - } - - private static string ReadDialogSource() - { - var path = Path.Combine( - TestEnvironment.RepositoryRoot, - "src", - "HelpDesk.NewWeb", - "Components", - "Dialogs", - "Settings", - "EmailSettingsDialog.razor"); - return File.ReadAllText(path); - } -} diff --git a/tests/Helpdesk.Tests/NewWeb/EmailSettingsPageTests.cs b/tests/Helpdesk.Tests/NewWeb/EmailSettingsPageTests.cs new file mode 100644 index 00000000..3df2c359 --- /dev/null +++ b/tests/Helpdesk.Tests/NewWeb/EmailSettingsPageTests.cs @@ -0,0 +1,85 @@ +namespace Helpdesk.Tests.NewWeb; + +public class EmailSettingsPageTests +{ + private static readonly string Source = ReadPageSource(); + + [Fact] + public void PageLoadsTheFirstOrderedEmailSettingsRecordWithoutWritingDefaults() + { + Assert.Contains("@page \"/admin/email-settings\"", Source); + Assert.Contains("@attribute [Authorize(Roles = \"HelpdeskAdmin\")]", Source); + Assert.Contains("GetFromJsonAsync>(\"/api/v1/email-settings\"", Source); + Assert.Contains("settings is { Count: > 0 } ? EmailInboxSettingsForm.FromDto(settings[0])", Source); + Assert.Contains("savedBaseline = model.Clone();", Source); + Assert.DoesNotContain("PostAsJsonAsync(\"/api/v1/email-settings\"", Source[..Source.IndexOf("private async Task SaveAsync", StringComparison.Ordinal)]); + } + + [Fact] + public void PageSavesExistingRecordWithPutAndConsumesTheSavedDto() + { + Assert.Contains("HelpdeskApi.PutAsJsonAsync($\"/api/v1/email-settings/{model.Id}\"", Source); + Assert.Contains("ReadFromJsonAsync", Source); + Assert.Contains("model = EmailInboxSettingsForm.FromDto(saved);", Source); + Assert.Contains("isDirty = false;", Source); + Assert.DoesNotContain("/api/v1/email/imap/save", Source); + } + + [Fact] + public void PageTestsTheCurrentDraftWithoutLegacyImapEndpoint() + { + Assert.Contains("HelpdeskApi.PostAsJsonAsync(\"/api/v1/email-settings/test\"", Source); + Assert.DoesNotContain("/api/v1/email/imap/test", Source); + Assert.Contains("testResult = null;", Source); + } + + [Fact] + public void PageBindsMailboxSslAndIndependentIngestionFields() + { + Assert.Contains("@bind-Value=\"model.MailboxAddress\"", Source); + Assert.Contains("@bind-Value=\"model.UseSsl\"", Source); + Assert.Contains("@bind-Value=\"model.Enabled\"", Source); + Assert.Contains("@bind-Value=\"model.BackgroundSyncEnabled\"", Source); + Assert.Contains("Label=\"Mailbox enabled\"", Source); + Assert.Contains("Label=\"Background ingestion enabled\"", Source); + } + + [Fact] + public void BlankClientSecretIsNotSentAsANewSecret() + { + Assert.Contains("ClientSecret = string.IsNullOrWhiteSpace(model.ClientSecret) ? null : model.ClientSecret", Source); + Assert.Contains("A client secret is configured. Leave this field blank to keep it.", Source); + Assert.Contains("model = EmailInboxSettingsForm.FromDto(saved);", Source); + } + + [Fact] + public void PageProtectsUnsavedChangesAndCanDiscardThem() + { + Assert.Contains(" loading || !loaded || saving || testing;", Source); + } + + private static string ReadPageSource() + { + var path = Path.Combine( + TestEnvironment.RepositoryRoot, + "src", + "HelpDesk.NewWeb", + "Components", + "Pages", + "Admin", + "Email", + "EmailSettings.razor"); + return File.ReadAllText(path); + } +} diff --git a/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs b/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs index fce0845a..56866605 100644 --- a/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs +++ b/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs @@ -53,6 +53,16 @@ public void NavMenu_ShowsDataManagementForDataManagementAdmin() Assert.Contains("Href=\"/admin/resources/data-management\"", contents, StringComparison.Ordinal); } + [Fact] + public void EmailSettingsPage_IsRestrictedToInstanceAdministrators() + { + var pagePath = Path.Combine(TestEnvironment.RepositoryRoot, "src", "HelpDesk.NewWeb", "Components", "Pages", "Admin", "Email", "EmailSettings.razor"); + var contents = File.ReadAllText(pagePath); + + Assert.Contains("@page \"/admin/email-settings\"", contents, StringComparison.Ordinal); + Assert.Contains("@attribute [Authorize(Roles = \"HelpdeskAdmin\")]", contents, StringComparison.Ordinal); + } + [Fact] public void TenantMembershipPage_UsesApiResolvedTenantAccess() { diff --git a/tests/Helpdesk.Tests/NewWeb/WebAuthRoutesTests.cs b/tests/Helpdesk.Tests/NewWeb/WebAuthRoutesTests.cs index 3c2e0f02..785d8b97 100644 --- a/tests/Helpdesk.Tests/NewWeb/WebAuthRoutesTests.cs +++ b/tests/Helpdesk.Tests/NewWeb/WebAuthRoutesTests.cs @@ -434,6 +434,24 @@ public async Task Anonymous_User_Is_Challenged_By_Authentik_For_Authorized_Page( StringComparison.Ordinal); } + [Fact] + public async Task Anonymous_User_Is_Challenged_By_Authentik_For_Email_Settings() + { + using var factory = CreateFactory(); + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + var response = await client.GetAsync("/admin/email-settings"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.NotNull(response.Headers.Location); + Assert.StartsWith("https://id.example.com/application/o/rateldesk/authorize", + response.Headers.Location!.ToString(), + StringComparison.Ordinal); + } + [Fact] public async Task Authenticated_User_Can_Open_Authorized_Page() { @@ -449,6 +467,20 @@ public async Task Authenticated_User_Can_Open_Authorized_Page() Assert.Contains("stub-access-token", content, StringComparison.Ordinal); } + [Fact] + public async Task HelpdeskAdmin_Can_Open_Email_Settings_Page() + { + using var factory = CreateFactory(enableTestAuth: true); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test", "HelpdeskAdmin"); + + var response = await client.GetAsync("/admin/email-settings"); + var content = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Email Settings / Mailbox Configuration", content, StringComparison.Ordinal); + } + [Fact] public async Task Change_Detail_Does_Not_Call_Api_During_Production_Prerender() { @@ -495,6 +527,21 @@ public async Task Authenticated_User_Without_Admin_Role_Gets_Forbidden_For_Admin Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } + [Fact] + public async Task Authenticated_User_Without_Admin_Role_Gets_Forbidden_For_Email_Settings() + { + using var factory = CreateFactory(enableTestAuth: true); + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test", "Technician"); + + var response = await client.GetAsync("/admin/email-settings"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + private static WebApplicationFactory CreateFactory( bool enableTestAuth = false, bool useAzureFallback = false, diff --git a/tests/ux/admin-email-settings.spec.ts b/tests/ux/admin-email-settings.spec.ts new file mode 100644 index 00000000..46ccf374 --- /dev/null +++ b/tests/ux/admin-email-settings.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from '@playwright/test'; +import { assertNoHorizontalOverflow, authenticate } from './auth'; + +async function expectDrawerFullyClosed(page: Parameters[0]): Promise { + const drawer = page.getByTestId('app-navigation-drawer'); + + await expect.poll(async () => drawer.evaluate((element) => { + const drawerRect = element.getBoundingClientRect(); + const mainRect = document.querySelector('[data-testid="app-main-content"]')?.getBoundingClientRect(); + const visibleNavigation = Array.from(element.querySelectorAll('.mud-icon-root, .mud-nav-link')) + .some((navigation) => { + const rect = navigation.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && rect.left < window.innerWidth && rect.right > 0; + }); + + return element.classList.contains('mud-drawer--closed') && + drawerRect.right <= 0 && + (mainRect?.left ?? Number.POSITIVE_INFINITY) <= 1 && + !visibleNavigation; + })).toBe(true); +} + +test.beforeEach(async ({ page }) => { + await authenticate(page); +}); + +test('Email Settings opens as a standalone page from the expanded administration navigation', async ({ page }, testInfo) => { + await page.goto('/admin/email-settings'); + + await expect(page.getByRole('heading', { name: 'Email Settings / Mailbox Configuration' })).toBeVisible(); + await expect(page.getByLabel('Mail host')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Save' })).toBeVisible(); + await expect(page.locator('.mud-dialog')).toHaveCount(0); + + const drawer = page.getByTestId('app-navigation-drawer'); + await expect(drawer.getByRole('link', { name: 'Mailbox Configuration' })).toBeVisible(); + await expect(drawer.getByRole('link', { name: 'Pending Emails' })).toBeVisible(); + await expect(drawer.getByText('Email Design', { exact: true })).toHaveCount(0); + await expect(drawer.getByText('Connectivity', { exact: true })).toHaveCount(0); + await assertNoHorizontalOverflow(page); + await page.screenshot({ path: testInfo.outputPath('email-settings-desktop.png'), fullPage: true }); +}); + +test('Automation navigation preserves the Orchestrator destination and collapsible groups', async ({ page }) => { + const drawer = page.getByTestId('app-navigation-drawer'); + await page.goto('/admin/email-settings'); + + await drawer.getByText('Automation', { exact: true }).click(); + const orchestrator = drawer.getByRole('link', { name: 'Orchestrator' }); + await expect(orchestrator).toBeVisible(); + await orchestrator.click(); + + await expect(page).toHaveURL(/\/settings\/connectivity$/); + await expect(page.getByRole('heading', { name: 'Orchestrator' })).toBeVisible(); + await expect(page.getByText('Connectivity and validation', { exact: true })).toBeVisible(); +}); + +test('Email Settings is usable from the phone drawer without horizontal overflow', async ({ browser }, testInfo) => { + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const page = await context.newPage(); + await authenticate(page); + + await page.getByTestId('navigation-toggle').click(); + const drawer = page.getByTestId('app-navigation-drawer'); + await expect(drawer.getByRole('link', { name: 'Home' })).toBeVisible(); + await drawer.getByText('Administration', { exact: true }).click(); + await drawer.getByText('Email Settings', { exact: true }).click(); + await drawer.getByRole('link', { name: 'Mailbox Configuration' }).click(); + + await expect(page).toHaveURL(/\/admin\/email-settings$/); + await expect(page.getByRole('heading', { name: 'Email Settings / Mailbox Configuration' })).toBeVisible(); + await expectDrawerFullyClosed(page); + await assertNoHorizontalOverflow(page); + await page.screenshot({ path: testInfo.outputPath('email-settings-mobile.png'), fullPage: true }); + await context.close(); +});