From fbcb261854a3e28b40f8f52533551f24066a7fe7 Mon Sep 17 00:00:00 2001 From: "boston-ai-agent[bot]" <299475768+boston-ai-agent[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:49:29 +0200 Subject: [PATCH 1/5] feat: consolidate admin access and branding for rc.8 --- Directory.Build.props | 2 +- docs/branding.md | 2 +- docs/releases.md | 2 +- .../TenantAdministrationNavigation.razor | 23 ++-- .../Pages/Admin/BrandingField.razor | 20 +++ .../Admin/Customer/EditCustomerDialog.razor | 45 ++++--- .../Pages/Admin/InstanceBrandingAdmin.razor | 117 ++++++++++++++---- .../Organization/OrganizationManagement.razor | 17 ++- .../Role/RoleAssignmentDetailsDialog.razor | 29 +++++ .../Pages/Admin/Role/RoleManagement.razor | 42 ++++--- .../Admin/User/TenantAdministration.razor | 8 +- .../Pages/Admin/User/TenantSettings.razor | 5 +- .../InstanceBrandingAdministrationResponse.cs | 6 + .../Authentication/RoleDefinitionEndpoints.cs | 29 +++++ .../Branding/InstanceBrandingEndpoints.cs | 25 ++-- .../Customers/CustomerAuthEndpoints.cs | 109 +++++++++++++++- .../DTOs/Customer/CustomerAuthStatusDto.cs | 5 + .../Branding/InstanceBrandingProviderTests.cs | 19 +++ ...anceBrandingAdministrationResponseTests.cs | 4 + .../PageAuthorizationConventionsTests.cs | 6 +- 20 files changed, 432 insertions(+), 83 deletions(-) create mode 100644 src/HelpDesk.NewWeb/Components/Pages/Admin/BrandingField.razor create mode 100644 src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleAssignmentDetailsDialog.razor diff --git a/Directory.Build.props b/Directory.Build.props index 3e84a3e1..5530f6ce 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ 0.1.0 - rc.7 + rc.8 $(VersionPrefix) $(VersionPrefix)-$(VersionSuffix) diff --git a/docs/branding.md b/docs/branding.md index 071f8f79..df282856 100644 --- a/docs/branding.md +++ b/docs/branding.md @@ -25,7 +25,7 @@ environment: Branding__EmailFromDisplayName: Acme Service Desk ``` -`ApplicationUrl` is the canonical base for generated application links. Use an HTTPS public URL in production; it must be an absolute HTTP(S) URL. Host logos and favicons on a publicly reachable HTTPS endpoint, or retain the bundled `/branding/` artwork. +`ApplicationUrl`, organization/site URL, and support URL must be absolute HTTP(S) URLs. Asset URLs for the main logo, compact logo, and favicon may instead be safe root-relative application paths such as `/branding/rateldesk-wordmark.webp`; protocol-relative and non-HTTP schemes are rejected. The administrator editor retains database overrides separately from effective values, so saving one field does not turn inherited defaults into overrides. Use **Use default** on a field to remove only that database override. The bundled browser defaults are `/branding/rateldesk-mark.webp`, `/branding/rateldesk-wordmark.webp`, and `/branding/rateldesk-splash.webp`; PNG counterparts are included for transparent-image fallback. The email default is the mail-client-compatible `/email-brand/rateldesk-email-wordmark.png`. `LogoUrl`, `CompactLogoUrl`, and `FaviconUrl` continue to override these upstream defaults, and tenant branding remains more specific where configured. diff --git a/docs/releases.md b/docs/releases.md index b5ec62ab..2cec09da 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,6 +1,6 @@ # Releases -RatelDesk has one repository-owned release line. The root `Directory.Build.props` is the source of truth: maintainers update `VersionPrefix` when preparing the next release. All application projects inherit that value. `VersionSuffix` creates prereleases without changing the release line; for example, `-p:VersionPrefix=0.2.0 -p:VersionSuffix=beta.1` evaluates to `0.2.0-beta.1`. +RatelDesk has one repository-owned release line. The root `Directory.Build.props` is the source of truth: maintainers update `VersionPrefix` when preparing the next release. All application projects inherit that value. `VersionSuffix` creates prereleases without changing the release line; for example, `-p:VersionPrefix=0.2.0 -p:VersionSuffix=beta.1` evaluates to `0.2.0-beta.1`. The current prepared prerelease is `0.1.0-rc.8`. ## Build identity diff --git a/src/HelpDesk.NewWeb/Components/Layout/TenantAdministrationNavigation.razor b/src/HelpDesk.NewWeb/Components/Layout/TenantAdministrationNavigation.razor index b4136348..c61ef0d8 100644 --- a/src/HelpDesk.NewWeb/Components/Layout/TenantAdministrationNavigation.razor +++ b/src/HelpDesk.NewWeb/Components/Layout/TenantAdministrationNavigation.razor @@ -1,11 +1,14 @@ - - Tenant members - - - Tenant roles - - - - - Tenant settings + + + + + + Organizations + + + Roles & Permissions + + + + diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/BrandingField.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/BrandingField.razor new file mode 100644 index 00000000..1ce62f97 --- /dev/null +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/BrandingField.razor @@ -0,0 +1,20 @@ + + + + @if (!Disabled) + { + Use default + } + + @Source + + +@code { + [Parameter, EditorRequired] public string Label { get; set; } = string.Empty; + [Parameter, EditorRequired] public string Field { get; set; } = string.Empty; + [Parameter] public string Value { get; set; } = string.Empty; + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public string Source { get; set; } = "Default"; + [Parameter] public bool Disabled { get; set; } + [Parameter] public EventCallback OnUseDefault { get; set; } +} diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/EditCustomerDialog.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/EditCustomerDialog.razor index 731315a4..c6bd40f7 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/EditCustomerDialog.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/EditCustomerDialog.razor @@ -16,32 +16,38 @@ - - + + + + - + @foreach (var org in Organizations) { @org.Name } - + + @if (authStatus?.IsLinkedLogin == true) + { + A linked login keeps its current organization access. Use the explicit access workflow to move it. + } - - + Label="Enabled" /> + - - - Authentication / Helpdesk Access - + + Account access + @(authStatus?.StatusText ?? "Not invited") @if (authStatus is not null) { - Authentik user: @(authStatus.AuthentikUserId ?? "Not linked") + @(authStatus.IdentitySummary ?? "No linked login") + Account type: @(authStatus.AuthProviderType ?? "None") Last invite: @FormatDate(authStatus.InviteSentAtUtc) Last login: @FormatDate(authStatus.LastLoginAtUtc) @if (!string.IsNullOrWhiteSpace(authStatus.LastAuthError)) @@ -49,7 +55,9 @@ @authStatus.LastAuthError } } - + @if (!string.Equals(authStatus?.AuthProviderType, "Local", StringComparison.OrdinalIgnoreCase) && authStatus?.HasMultipleIdentityLinks != true) + { + Disable access - - + + } + else if (string.Equals(authStatus?.AuthProviderType, "Local", StringComparison.OrdinalIgnoreCase)) + { + Local activation and account lifecycle actions are managed from the organization member editor. + } + + + diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/InstanceBrandingAdmin.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/InstanceBrandingAdmin.razor index 9f0631d3..155a3bb0 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/InstanceBrandingAdmin.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/InstanceBrandingAdmin.razor @@ -4,60 +4,129 @@ @using HelpDesk.NewWeb.Models @inject IHttpClientFactory HttpClientFactory @inject ISnackbar Snackbar +@inject NavigationManager NavigationManager Branding & Identity Branding & Identity - Deployment configuration is authoritative. Environment-managed fields are shown but cannot be edited here. + Organization name identifies the instance operator; it does not rename an organization. Deployment-managed fields are read-only. @if (_administration is null) { } else { + Identity - - - - - - - - - - - + + + - - Effective value sources - - @foreach (var field in _administration.Fields) - { - @field.Name: @field.Source - } - - Save brandingReload + Logos + + + + + + + Main logo preview + Compact logo preview + Favicon preview + @_model.ApplicationName@_model.Tagline + + + + Links & contact + + + + + + + + @(saving ? "Saving…" : "Save branding")Discard changes } @code { private InstanceBrandingAdministrationResponse? _administration; private InstanceBrandingUpdate _model = new(); + private readonly HashSet _useDefaultFields = new(StringComparer.Ordinal); + private bool saving; private HttpClient Api => HttpClientFactory.CreateClient("HelpdeskApi"); protected override Task OnInitializedAsync() => LoadAsync(); private async Task LoadAsync() { _administration = await Api.GetFromJsonAsync("api/v1/admin/branding/"); - if (_administration is not null) _model = InstanceBrandingUpdate.From(_administration.Effective); + if (_administration is not null) + { + _model = InstanceBrandingUpdate.From(_administration.Effective); + _useDefaultFields.Clear(); + } } private bool Managed(string name) => _administration?.Fields.Any(x => x.Name == name && !x.IsAdminEditable) == true; + private string Source(string name) => _administration?.Fields.FirstOrDefault(x => x.Name == name)?.Source switch + { + InstanceBrandingValueSource.Database => "Custom", + InstanceBrandingValueSource.Environment => "Managed by deployment", + _ => "Default" + }; + private void UseDefault(string name) + { + var field = _administration?.Fields.FirstOrDefault(x => x.Name == name); + if (field is null || !field.IsAdminEditable) return; + _useDefaultFields.Add(name); + SetValue(name, field.EffectiveValue); + } + private void SetValue(string name, string? value) + { + var normalized = value ?? string.Empty; + switch (name) + { + case nameof(_model.ApplicationName): _model.ApplicationName = normalized; break; + case nameof(_model.OrganizationName): _model.OrganizationName = normalized; break; + case nameof(_model.ApplicationUrl): _model.ApplicationUrl = normalized; break; + case nameof(_model.OrganizationUrl): _model.OrganizationUrl = normalized; break; + case nameof(_model.SupportUrl): _model.SupportUrl = normalized; break; + case nameof(_model.SupportEmail): _model.SupportEmail = normalized; break; + case nameof(_model.LogoUrl): _model.LogoUrl = normalized; break; + case nameof(_model.CompactLogoUrl): _model.CompactLogoUrl = normalized; break; + case nameof(_model.FaviconUrl): _model.FaviconUrl = normalized; break; + case nameof(_model.EmailFromDisplayName): _model.EmailFromDisplayName = normalized; break; + case nameof(_model.Tagline): _model.Tagline = normalized; break; + default: throw new ArgumentOutOfRangeException(nameof(name)); + } + } + private string? ValueToSave(string name, string value) + { + if (_useDefaultFields.Contains(name)) return null; + var field = _administration!.Fields.Single(x => x.Name == name); + return string.Equals(value, field.EffectiveValue, StringComparison.Ordinal) ? field.PersistedValue : value; + } + private InstanceBrandingUpdate UpdateForSave() => new() + { + ApplicationName = ValueToSave(nameof(_model.ApplicationName), _model.ApplicationName), + OrganizationName = ValueToSave(nameof(_model.OrganizationName), _model.OrganizationName), + ApplicationUrl = ValueToSave(nameof(_model.ApplicationUrl), _model.ApplicationUrl), + OrganizationUrl = ValueToSave(nameof(_model.OrganizationUrl), _model.OrganizationUrl), + SupportUrl = ValueToSave(nameof(_model.SupportUrl), _model.SupportUrl), + SupportEmail = ValueToSave(nameof(_model.SupportEmail), _model.SupportEmail), + LogoUrl = ValueToSave(nameof(_model.LogoUrl), _model.LogoUrl), + CompactLogoUrl = ValueToSave(nameof(_model.CompactLogoUrl), _model.CompactLogoUrl), + FaviconUrl = ValueToSave(nameof(_model.FaviconUrl), _model.FaviconUrl), + EmailFromDisplayName = ValueToSave(nameof(_model.EmailFromDisplayName), _model.EmailFromDisplayName), + Tagline = ValueToSave(nameof(_model.Tagline), _model.Tagline) + }; private async Task SaveAsync() { - var response = await Api.PutAsJsonAsync("api/v1/admin/branding/", _model); - if (!response.IsSuccessStatusCode) { Snackbar.Add("Branding was not saved. Check URL values.", Severity.Error); return; } + saving = true; + using var response = await Api.PutAsJsonAsync("api/v1/admin/branding/", UpdateForSave()); + if (!response.IsSuccessStatusCode) { Snackbar.Add("Branding was not saved. URLs must be HTTP(S), except root-relative logo assets.", Severity.Error); saving = false; return; } _administration = await response.Content.ReadFromJsonAsync(); + if (_administration is not null) _model = InstanceBrandingUpdate.From(_administration.Effective); Snackbar.Add("Branding saved.", Severity.Success); + NavigationManager.NavigateTo(NavigationManager.Uri, forceLoad: true); } } diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/OrganizationManagement.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/OrganizationManagement.razor index 458c821b..892e6d22 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/OrganizationManagement.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/OrganizationManagement.razor @@ -1,15 +1,19 @@ @page "/admin/organizations" -@attribute [Authorize(Roles = "HelpdeskAdmin")] +@attribute [Authorize(Roles = "Tenant.Roles.Assign,Tenant.Settings.Manage,HelpdeskAdmin")] @inject IHttpClientFactory HttpClientFactory @inject IDialogService DialogService @inject NavigationManager NavigationManager +@inject AuthenticationStateProvider AuthenticationState @using Helpdesk.Shared.DTOs.Orchestration @using Helpdesk.Shared.DTOs.Organization +@using HelpDesk.NewWeb.Components.Pages.Admin.User @using MudBlazor Organization Management + + Organization Management @@ -48,6 +52,14 @@ + + + + + + + + @code { [SupplyParameterFromQuery(Name = "search")] @@ -57,6 +69,7 @@ private List organizations = new(); private List orchestrationTenants = new(); private string searchText = string.Empty; + private bool isInstanceAdministrator; private IEnumerable FilteredOrganizations => organizations .Where(org => string.IsNullOrWhiteSpace(searchText) @@ -67,6 +80,8 @@ protected override async Task OnInitializedAsync() { + isInstanceAdministrator = (await AuthenticationState.GetAuthenticationStateAsync()).User.IsInRole("HelpdeskAdmin"); + if (!isInstanceAdministrator) return; searchText = QuerySearch ?? string.Empty; await LoadOrganizations(); } diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleAssignmentDetailsDialog.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleAssignmentDetailsDialog.razor new file mode 100644 index 00000000..72c0cb77 --- /dev/null +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleAssignmentDetailsDialog.razor @@ -0,0 +1,29 @@ +@using System.Net.Http.Json +@inject IHttpClientFactory HttpClientFactory + + + @Role.Name assignments + + + @foreach (var permission in Role.Permissions) { @permission } + + Explicit user-role-organization assignments (up to 200). + @if (loading) { } + else { PersonEmailOrganization@context.Name@context.Email@OrganizationName(context.OrganizationId) } + + + + Close + + +@code { + [CascadingParameter] private IMudDialogInstance Dialog { get; set; } = default!; + [Parameter] public RoleManagement.RoleDefinition Role { get; set; } = default!; + [Parameter] public IReadOnlyList Organizations { get; set; } = []; + private readonly List assignments = []; + private bool loading = true; + private HttpClient Api => HttpClientFactory.CreateClient("HelpdeskApi"); + protected override async Task OnInitializedAsync() { assignments.AddRange(await Api.GetFromJsonAsync>($"api/v1/admin/role-definitions/{Role.Id}/assignments") ?? []); loading = false; } + private string OrganizationName(string id) => Organizations.FirstOrDefault(organization => organization.Id == id)?.Name ?? id; + private sealed record RoleAssignment(string UserId, string Name, string Email, string OrganizationId); +} diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor index 3cf31c59..764459f5 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor @@ -2,7 +2,9 @@ @attribute [Authorize(Roles = "Tenant.Roles.Assign,HelpdeskAdmin")] @using System.Net.Http.Json @using Helpdesk.Shared.Models +@using Helpdesk.Shared.Auth @inject IHttpClientFactory HttpClientFactory +@inject IDialogService DialogService Roles & Permissions @@ -87,12 +89,13 @@ } - @foreach (var permission in context.Permissions) + @foreach (var permission in context.Permissions.Take(3)) { @PermissionLabel(permission) } + @if (context.Permissions.Count > 3) { +@(context.Permissions.Count - 3) more } - @context.AssignmentCount + @context.AssignmentCount assignment@(context.AssignmentCount == 1 ? string.Empty : "s") @if (!context.IsProtected) { @@ -108,17 +111,8 @@ @code { - private static readonly IReadOnlyList PermissionOptions = - [ - new("SelfService.User", "Self-service access"), - new("Incident.User", "Incidents: reader"), - new("Incident.Manager", "Incidents: writer"), - new("Request.User", "Requests: reader"), - new("Request.Manager", "Requests: writer"), - new("Change.User", "Changes: reader"), - new("Change.Manager", "Changes: writer"), - new("DataManagement.Admin", "Data management") - ]; + private static readonly IReadOnlyList PermissionOptions = HelpdeskPermissions.AssignablePermissions + .Select(permission => new PermissionOption(permission, PermissionLabel(permission))).ToArray(); private HttpClient HelpdeskApi => HttpClientFactory.CreateClient("HelpdeskApi"); private List roles = []; @@ -239,6 +233,15 @@ private void CancelEdit() => showEditor = false; + private async Task OpenAssignments(RoleDefinition role) + { + await DialogService.ShowAsync("Role assignments", new DialogParameters + { + ["Role"] = role, + ["Organizations"] = organizations + }, new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true }); + } + private string OrganizationLabel(string? organizationId) => organizations.FirstOrDefault(organization => organization.Id == organizationId)?.Name ?? organizationId ?? "Instance"; private static string ScopeLabel(RoleScopeKind scope) => scope switch { @@ -246,11 +249,18 @@ RoleScopeKind.OwnResource => "Self-service", _ => "Tenant" }; - private static string PermissionLabel(string permission) => PermissionOptions.FirstOrDefault(option => option.Key == permission)?.Label ?? permission; + private static string PermissionLabel(string permission) => permission switch + { + HelpdeskPermissions.IncidentRead => "Read incidents", HelpdeskPermissions.IncidentWrite => "Write incidents", HelpdeskPermissions.IncidentDelete => "Delete incidents", + HelpdeskPermissions.RequestRead => "Read requests", HelpdeskPermissions.RequestWrite => "Write requests", HelpdeskPermissions.RequestDelete => "Delete requests", HelpdeskPermissions.RequestExecute => "Execute requests", + HelpdeskPermissions.ChangeRead => "Read changes", HelpdeskPermissions.ChangeWrite => "Write changes", HelpdeskPermissions.ChangeDelete => "Delete changes", HelpdeskPermissions.ChangeApprove => "Approve changes", + HelpdeskPermissions.TenantUsersManage => "Manage tenant users", HelpdeskPermissions.TenantRolesAssign => "Assign tenant roles", HelpdeskPermissions.TenantSettingsManage => "Manage tenant settings", + HelpdeskPermissions.SelfServiceUser => "Self-service access", _ => permission + }; private sealed record PermissionOption(string Key, string Label); - private sealed record TenantOrganization(string Id, string Name); - private sealed record RoleDefinition(string Id, string Key, string Name, RoleScopeKind Scope, string? OwnerOrganizationId, bool IsBuiltIn, bool IsProtected, IReadOnlyList Permissions, int AssignmentCount); + public sealed record TenantOrganization(string Id, string Name); + public sealed record RoleDefinition(string Id, string Key, string Name, RoleScopeKind Scope, string? OwnerOrganizationId, bool IsBuiltIn, bool IsProtected, IReadOnlyList Permissions, int AssignmentCount); private sealed record CreateRoleDefinitionRequest(string Name, string? Key, string? OwnerOrganizationId, IReadOnlyList Permissions); private sealed record UpdateRoleDefinitionRequest(string Name, IReadOnlyList Permissions); } diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor index bfd1b448..a5285f68 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor @@ -1,6 +1,7 @@ @page "/tenant-administration" @attribute [Authorize(Roles = "Tenant.Roles.Assign,HelpdeskAdmin")] @inject IHttpClientFactory HttpClientFactory +@inject NavigationManager NavigationManager Tenant members @@ -97,6 +98,7 @@ @code { + [Parameter] public bool IsCanonical { get; set; } private HttpClient HelpdeskApi => HttpClientFactory.CreateClient("HelpdeskApi"); private List organizations = new(); private List members = new(); @@ -122,7 +124,11 @@ organizations = await response.Content.ReadFromJsonAsync>() ?? new(); selectedOrganizationId = organizations.FirstOrDefault()?.Id; - if (!string.IsNullOrWhiteSpace(selectedOrganizationId)) + if (!string.IsNullOrWhiteSpace(selectedOrganizationId) && !IsCanonical) + { + NavigationManager.NavigateTo("/admin/organizations", replace: true); + } + else if (!string.IsNullOrWhiteSpace(selectedOrganizationId)) { await LoadMembers(); } diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor index 3094c116..eddfdb11 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor @@ -5,6 +5,7 @@ @inject IHttpClientFactory HttpClientFactory @inject AuthenticationStateProvider AuthenticationState @inject ISnackbar Snackbar +@inject NavigationManager NavigationManager Tenant settings
@@ -27,6 +28,7 @@
@code { + [Parameter] public bool IsCanonical { get; set; } private HttpClient Api => HttpClientFactory.CreateClient("HelpdeskApi"); private List organizations = new(); private TenantSettingsModel? settings; @@ -37,7 +39,8 @@ var profile = CurrentUserAccessProfile.FromClaims((await AuthenticationState.GetAuthenticationStateAsync()).User); organizations = (await Api.GetFromJsonAsync>("api/v1/tenant-admin/organizations?permission=Tenant.Settings.Manage") ?? new()) .Where(organization => profile.HasPermission(HelpdeskPermissions.TenantSettingsManage, organization.Id)).ToList(); - if (organizations.Count > 0) await SelectAsync(organizations[0].Id); + if (organizations.Count > 0 && !IsCanonical) NavigationManager.NavigateTo("/admin/organizations", replace: true); + else if (organizations.Count > 0) await SelectAsync(organizations[0].Id); } private async Task SelectAsync(string? value) { diff --git a/src/HelpDesk.NewWeb/Models/InstanceBrandingAdministrationResponse.cs b/src/HelpDesk.NewWeb/Models/InstanceBrandingAdministrationResponse.cs index 34f20e30..f1d2c0bb 100644 --- a/src/HelpDesk.NewWeb/Models/InstanceBrandingAdministrationResponse.cs +++ b/src/HelpDesk.NewWeb/Models/InstanceBrandingAdministrationResponse.cs @@ -13,6 +13,12 @@ public sealed class InstanceBrandingFieldState { public string Name { get; set; } = string.Empty; + // Keep the override separate from Effective. A missing persisted value means + // that the deployment/default resolver remains responsible for this field. + public string? PersistedValue { get; set; } + + public string EffectiveValue { get; set; } = string.Empty; + public InstanceBrandingValueSource Source { get; set; } public bool IsAdminEditable { get; set; } diff --git a/src/Helpdesk.API/Endpoints/Authentication/RoleDefinitionEndpoints.cs b/src/Helpdesk.API/Endpoints/Authentication/RoleDefinitionEndpoints.cs index 1d7acb57..6150fd70 100644 --- a/src/Helpdesk.API/Endpoints/Authentication/RoleDefinitionEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Authentication/RoleDefinitionEndpoints.cs @@ -80,6 +80,34 @@ public static void MapRoleDefinitionEndpoints(this IEndpointRouteBuilder app) return Results.Ok(ToResponse(role, await assignments.CountAsync(cancellationToken))); }); + group.MapGet("/{id}/assignments", async ( + string id, + HttpContext context, + ICurrentUserAccessService accessService, + HelpdeskDbContext db, + CancellationToken cancellationToken) => + { + var access = await accessService.ResolveAsync(context.User, cancellationToken); + var role = await db.Roles.AsNoTracking().SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken); + if (role is null) return Results.NotFound(); + if (!access.IsHelpdeskAdmin && ManagedOrganizationIds(access).Count == 0) return Results.Forbid(); + if (!role.IsBuiltIn && !CanManageOrganization(access, role.OwnerOrganizationId)) return Results.Forbid(); + + var assignments = db.ScopedRoleAssignments.AsNoTracking().Where(assignment => assignment.RoleKey == role.Key); + if (!access.IsHelpdeskAdmin) + { + var organizationIds = ManagedOrganizationIds(access); + assignments = assignments.Where(assignment => organizationIds.Contains(assignment.OrganizationId)); + } + + return Results.Ok(await ( + from assignment in assignments + join user in db.Users.AsNoTracking() on assignment.UserId equals user.Id + orderby user.Name, assignment.OrganizationId + select new RoleAssignmentResponse(user.Id, user.Name, user.Email, assignment.OrganizationId) + ).Take(200).ToArrayAsync(cancellationToken)); + }); + group.MapPost("/", async ( CreateRoleDefinitionRequest request, HttpContext context, @@ -281,6 +309,7 @@ private static async Task ValidateCustomRoleAsync( public sealed record CreateRoleDefinitionRequest(string Name, string? Key, string? OwnerOrganizationId, IReadOnlyList Permissions); public sealed record UpdateRoleDefinitionRequest(string Name, IReadOnlyList Permissions); public sealed record RoleDefinitionResponse(string Id, string Key, string Name, RoleScopeKind Scope, string? OwnerOrganizationId, bool IsBuiltIn, bool IsProtected, IReadOnlyList Permissions, int AssignmentCount); + public sealed record RoleAssignmentResponse(string UserId, string Name, string Email, string OrganizationId); private sealed record RoleValidation(string? Key, IReadOnlyList? Permissions, ValidationError? Error) { diff --git a/src/Helpdesk.API/Endpoints/Branding/InstanceBrandingEndpoints.cs b/src/Helpdesk.API/Endpoints/Branding/InstanceBrandingEndpoints.cs index 02b5c863..9f3ed3a2 100644 --- a/src/Helpdesk.API/Endpoints/Branding/InstanceBrandingEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Branding/InstanceBrandingEndpoints.cs @@ -36,18 +36,27 @@ public static void MapInstanceBrandingEndpoints(this IEndpointRouteBuilder app) private static IEnumerable FindInvalidUrls(InstanceBrandingUpdate update) { - foreach (var (name, value) in new[] + foreach (var (name, value, allowsRootRelative) in new[] { - (nameof(update.ApplicationUrl), update.ApplicationUrl), (nameof(update.OrganizationUrl), update.OrganizationUrl), - (nameof(update.SupportUrl), update.SupportUrl), (nameof(update.LogoUrl), update.LogoUrl), - (nameof(update.CompactLogoUrl), update.CompactLogoUrl), (nameof(update.FaviconUrl), update.FaviconUrl) + (nameof(update.ApplicationUrl), update.ApplicationUrl, false), (nameof(update.OrganizationUrl), update.OrganizationUrl, false), + (nameof(update.SupportUrl), update.SupportUrl, false), (nameof(update.LogoUrl), update.LogoUrl, true), + (nameof(update.CompactLogoUrl), update.CompactLogoUrl, true), (nameof(update.FaviconUrl), update.FaviconUrl, true) }) { - if (!string.IsNullOrWhiteSpace(value) && - (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || - (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && - !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))) + if (!string.IsNullOrWhiteSpace(value) && !IsAllowedUrl(value, allowsRootRelative)) yield return name; } } + + private static bool IsAllowedUrl(string value, bool allowsRootRelative) + { + if (allowsRootRelative && value.StartsWith("/", StringComparison.Ordinal) && !value.StartsWith("//", StringComparison.Ordinal)) + { + return true; + } + + return Uri.TryCreate(value, UriKind.Absolute, out var uri) && + (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || + string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)); + } } diff --git a/src/Helpdesk.API/Endpoints/Customers/CustomerAuthEndpoints.cs b/src/Helpdesk.API/Endpoints/Customers/CustomerAuthEndpoints.cs index 5048c158..1e929696 100644 --- a/src/Helpdesk.API/Endpoints/Customers/CustomerAuthEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Customers/CustomerAuthEndpoints.cs @@ -1,6 +1,12 @@ using System.Security.Claims; using Helpdesk.Infrastructure.Auth.Authentik; +using Helpdesk.Infrastructure.Identity; +using Helpdesk.Infrastructure.Persistence; +using Helpdesk.Shared.DTOs.Customer; +using Helpdesk.Shared.Models; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; namespace Helpdesk.API.Endpoints.Customers; @@ -15,8 +21,10 @@ public static void MapCustomerAuthEndpoints(this IEndpointRouteBuilder app) group.MapGet("/status", async ( [FromRoute] string customerId, [FromServices] ICustomerInvitationService invitationService, + [FromServices] HelpdeskDbContext db, + [FromServices] UserManager users, CancellationToken ct) => - Results.Ok(await invitationService.GetStatusAsync(customerId, ct))); + Results.Ok(await GetStatusAsync(customerId, invitationService, db, users, ct))); group.MapPost("/invite", async ( [FromRoute] string customerId, @@ -52,8 +60,10 @@ public static void MapCustomerAuthEndpoints(this IEndpointRouteBuilder app) adminGroup.MapGet("/auth-status", async ( [FromRoute] string customerId, [FromServices] ICustomerInvitationService invitationService, + [FromServices] HelpdeskDbContext db, + [FromServices] UserManager users, CancellationToken ct) => - Results.Ok(await invitationService.GetStatusAsync(customerId, ct))); + Results.Ok(await GetStatusAsync(customerId, invitationService, db, users, ct))); adminGroup.MapPost("/invite", async ( [FromRoute] string customerId, @@ -115,6 +125,101 @@ private static async Task RunCustomerAuthActionAsync(Func> a } } + private static async Task GetStatusAsync( + string customerId, + ICustomerInvitationService invitationService, + HelpdeskDbContext db, + UserManager users, + CancellationToken ct) + { + var customer = await db.Customers.AsNoTracking().SingleOrDefaultAsync(x => x.Id == customerId, ct) + ?? throw new InvalidOperationException("Customer not found."); + var links = await db.CustomerAuthLinks.AsNoTracking() + .Where(x => x.CustomerId == customerId) + .OrderBy(x => x.AuthProviderType) + .ThenBy(x => x.Id) + .ToListAsync(ct); + + if (links.Count == 0) + { + return NoLinkedLogin(customer.Id); + } + + if (links.Count > 1) + { + return new CustomerAuthStatusDto + { + CustomerId = customer.Id, + InviteStatus = CustomerInviteStatus.Failed, + StatusText = "Multiple linked identities", + IdentitySummary = "Multiple provider links require instance-administrator review.", + HasMultipleIdentityLinks = true, + IsLinkedLogin = true, + LastAuthError = "Access actions are unavailable until the linked identities are reviewed.", + AuthProviderType = string.Join(", ", links.Select(link => link.AuthProviderType).Distinct(StringComparer.OrdinalIgnoreCase)) + }; + } + + var link = links[0]; + if (string.Equals(link.AuthProviderType, "Local", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(link.LocalAccountId)) + { + return await GetLocalAccountStatusAsync(customer.Id, link, users); + } + + // Existing external flows remain owned by the invitation provider. The + // caller receives its exact provider/link status rather than an inferred + // identity from a matching customer email address. + return await invitationService.GetStatusAsync(customerId, ct); + } + + private static async Task GetLocalAccountStatusAsync( + string customerId, + CustomerAuthLink link, + UserManager users) + { + var account = await users.FindByIdAsync(link.LocalAccountId!); + var status = account switch + { + null => CustomerInviteStatus.Failed, + { IsEnabled: false } => CustomerInviteStatus.Disabled, + { EmailConfirmed: false } or { PasswordHash: null or "" } => CustomerInviteStatus.Pending, + _ => CustomerInviteStatus.Active + }; + + return new CustomerAuthStatusDto + { + CustomerId = customerId, + InviteStatus = status, + StatusText = status switch + { + CustomerInviteStatus.Pending => "Activation pending", + CustomerInviteStatus.Active => "Login active", + CustomerInviteStatus.Disabled => "Login disabled", + _ => "Linked local account unavailable" + }, + AuthProviderType = "Local", + LocalAccountId = link.LocalAccountId, + DomainUserId = link.DomainUserId, + IdentitySummary = account is null ? "The linked local account no longer exists." : account.Email, + IsLinkedLogin = true, + DisabledAtUtc = account?.DisabledAtUtc, + LastAuthError = account is null ? "The linked local account could not be found." : null, + CanInvite = false, + CanResend = false, + CanDisableLogin = false + }; + } + + private static CustomerAuthStatusDto NoLinkedLogin(string customerId) => new() + { + CustomerId = customerId, + InviteStatus = CustomerInviteStatus.NotInvited, + StatusText = "No linked login", + IdentitySummary = "This contact has no login link.", + CanInvite = true + }; + private static string ResolveUserId(ClaimsPrincipal user) => user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub") diff --git a/src/Helpdesk.Shared/DTOs/Customer/CustomerAuthStatusDto.cs b/src/Helpdesk.Shared/DTOs/Customer/CustomerAuthStatusDto.cs index 266eb8e8..80303b7b 100644 --- a/src/Helpdesk.Shared/DTOs/Customer/CustomerAuthStatusDto.cs +++ b/src/Helpdesk.Shared/DTOs/Customer/CustomerAuthStatusDto.cs @@ -8,6 +8,11 @@ public class CustomerAuthStatusDto public CustomerInviteStatus InviteStatus { get; set; } = CustomerInviteStatus.NotInvited; public string StatusText { get; set; } = "Not invited"; public string? AuthProviderType { get; set; } + public string? LocalAccountId { get; set; } + public string? DomainUserId { get; set; } + public string? IdentitySummary { get; set; } + public bool HasMultipleIdentityLinks { get; set; } + public bool IsLinkedLogin { get; set; } public string? OidcIssuer { get; set; } public string? OidcSubject { get; set; } public string? AuthentikUserId { get; set; } diff --git a/tests/Helpdesk.Tests/Infrastructure/Branding/InstanceBrandingProviderTests.cs b/tests/Helpdesk.Tests/Infrastructure/Branding/InstanceBrandingProviderTests.cs index 00163e16..d6b5ba7c 100644 --- a/tests/Helpdesk.Tests/Infrastructure/Branding/InstanceBrandingProviderTests.cs +++ b/tests/Helpdesk.Tests/Infrastructure/Branding/InstanceBrandingProviderTests.cs @@ -56,6 +56,25 @@ public async Task SaveAsync_PersistsEditableValues_AndImmediatelyReturnsTheirEff Assert.Single(administration.Fields, x => x.Name == nameof(InstanceBranding.ApplicationUrl)).Source); } + [Fact] + public async Task SaveAsync_ClearOneOverride_PreservesInheritedDefaultsForOtherFields() + { + var provider = CreateProvider(new ConfigurationBuilder().Build(), out var stored); + stored.Value = new InstanceBranding + { + ApplicationName = "Custom Desk", + LogoUrl = "https://example.test/custom-logo.svg" + }; + + var administration = await provider.SaveAsync(new InstanceBrandingUpdate( + null, null, null, null, null, null, null, null, null, null, null)); + + Assert.Null(stored.Value?.ApplicationName); + Assert.Null(stored.Value?.LogoUrl); + Assert.Equal("RatelDesk", administration.Effective.ApplicationName); + Assert.Equal("/branding/rateldesk-wordmark.webp", administration.Effective.LogoUrl); + } + private static InstanceBrandingProvider CreateProvider(IConfiguration configuration, out BrandingState stored) { stored = new BrandingState(); diff --git a/tests/Helpdesk.Tests/NewWeb/InstanceBrandingAdministrationResponseTests.cs b/tests/Helpdesk.Tests/NewWeb/InstanceBrandingAdministrationResponseTests.cs index c5a0e526..2b18a7dd 100644 --- a/tests/Helpdesk.Tests/NewWeb/InstanceBrandingAdministrationResponseTests.cs +++ b/tests/Helpdesk.Tests/NewWeb/InstanceBrandingAdministrationResponseTests.cs @@ -18,6 +18,8 @@ public void Deserialization_AcceptsNumericAndStringValueSources(string source) "fields": [ { "name": "ApplicationName", + "persistedValue": "Custom Desk", + "effectiveValue": "Deployment Desk", "source": {{source}}, "isAdminEditable": false } @@ -31,6 +33,8 @@ public void Deserialization_AcceptsNumericAndStringValueSources(string source) var field = Assert.Single(response!.Fields); Assert.Equal("ApplicationName", field.Name); + Assert.Equal("Custom Desk", field.PersistedValue); + Assert.Equal("Deployment Desk", field.EffectiveValue); Assert.Equal(InstanceBrandingValueSource.Environment, field.Source); Assert.False(field.IsAdminEditable); } diff --git a/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs b/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs index b9c66fc7..03da0794 100644 --- a/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs +++ b/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs @@ -64,12 +64,14 @@ public void TenantMembershipPage_UsesApiResolvedTenantAccess() Assert.Contains("@page \"/tenant-administration\"", page, StringComparison.Ordinal); Assert.Contains("@attribute [Authorize(Roles = \"Tenant.Roles.Assign,HelpdeskAdmin\")]", page, StringComparison.Ordinal); Assert.Contains("api/v1/tenant-admin/organizations", page, StringComparison.Ordinal); - Assert.Contains("Roles=\"Tenant.Roles.Assign,HelpdeskAdmin\"", navigation, StringComparison.Ordinal); - Assert.Contains("Roles=\"Tenant.Settings.Manage,HelpdeskAdmin\"", navigation, StringComparison.Ordinal); + Assert.Contains("Roles=\"Tenant.Roles.Assign,Tenant.Settings.Manage\"", navigation, StringComparison.Ordinal); + Assert.Contains("Href=\"/admin/organizations\"", navigation, StringComparison.Ordinal); var settingsPage = File.ReadAllText(Path.Combine(repoRoot, "src", "HelpDesk.NewWeb", "Components", "Pages", "Admin", "User", "TenantSettings.razor")); Assert.Contains("api/v1/tenant-admin/organizations?permission=Tenant.Settings.Manage", settingsPage, StringComparison.Ordinal); Assert.Contains("profile.HasPermission(HelpdeskPermissions.TenantSettingsManage, organization.Id)", settingsPage, StringComparison.Ordinal); Assert.Contains("Href=\"/admin/roles\"", navigation, StringComparison.Ordinal); + Assert.DoesNotContain("Href=\"/tenant-administration\"", navigation, StringComparison.Ordinal); + Assert.DoesNotContain("Href=\"/tenant-settings\"", navigation, StringComparison.Ordinal); Assert.Contains("", navMenu, StringComparison.Ordinal); } } From d17905fed533aa0b1b4091ebe4af1db143269f18 Mon Sep 17 00:00:00 2001 From: "boston-ai-agent[bot]" <299475768+boston-ai-agent[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:10:59 +0200 Subject: [PATCH 2/5] fix: harden rc8 tenant access workflows --- .../Admin/Customer/CustomerManagement.razor | 10 ++- .../Customer/CustomerRoleAccessEditor.razor | 74 +++++++++++++++++++ .../Admin/Customer/EditCustomerDialog.razor | 34 ++++++++- .../Pages/Admin/InstanceBrandingAdmin.razor | 5 +- .../Organization/EditOrganizationDialog.razor | 4 + .../Organization/OrganizationManagement.razor | 10 ++- .../Role/RoleAssignmentDetailsDialog.razor | 16 +++- .../Pages/Admin/Role/RoleManagement.razor | 16 +++- .../Admin/User/TenantAdministration.razor | 5 +- .../Pages/Admin/User/TenantSettings.razor | 4 +- .../InstanceBrandingAdministrationResponse.cs | 2 + .../Customers/CustomerAuthEndpoints.cs | 62 ++++++++++++++-- src/Helpdesk.API/Program.cs | 16 +++- .../Branding/IInstanceBrandingProvider.cs | 1 + .../Authentik/CustomerInvitationService.cs | 8 ++ .../Branding/InstanceBrandingProvider.cs | 26 +++---- .../Api/CustomerAuthEndpointsTests.cs | 36 +++++++++ .../Branding/InstanceBrandingProviderTests.cs | 1 + ...anceBrandingAdministrationResponseTests.cs | 2 + .../PageAuthorizationConventionsTests.cs | 5 ++ 20 files changed, 300 insertions(+), 37 deletions(-) create mode 100644 src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/CustomerRoleAccessEditor.razor diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/CustomerManagement.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/CustomerManagement.razor index 6fa56071..5f476324 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/CustomerManagement.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/CustomerManagement.razor @@ -235,8 +235,14 @@ return true; } - var status = customer.AuthStatus?.InviteStatus ?? CustomerInviteStatus.NotInvited; - return status is CustomerInviteStatus.Active or CustomerInviteStatus.Disabled; + var auth = customer.AuthStatus; + var status = auth?.InviteStatus ?? CustomerInviteStatus.NotInvited; + return status switch + { + CustomerInviteStatus.Pending or CustomerInviteStatus.Failed or CustomerInviteStatus.Expired => auth?.CanResend != true, + CustomerInviteStatus.Active or CustomerInviteStatus.Disabled => true, + _ => auth?.CanInvite != true + }; } private static string GetInviteActionLabel(CustomerDto customer) diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/CustomerRoleAccessEditor.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/CustomerRoleAccessEditor.razor new file mode 100644 index 00000000..e2b7dea4 --- /dev/null +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/CustomerRoleAccessEditor.razor @@ -0,0 +1,74 @@ +@using System.Net.Http.Json +@inject IHttpClientFactory HttpClientFactory + + +Organization roles +@if (!string.IsNullOrWhiteSpace(error)) +{ + @error Retry +} +else if (loading) +{ + +} +else +{ + + @foreach (var role in roles) + { + + } + + @(saving ? "Saving…" : "Save organization roles") +} + +@code { + [Parameter, EditorRequired] public string OrganizationId { get; set; } = string.Empty; + [Parameter, EditorRequired] public string UserId { get; set; } = string.Empty; + private HttpClient Api => HttpClientFactory.CreateClient("HelpdeskApi"); + private readonly List roles = []; + private HashSet originalRoleKeys = new(StringComparer.OrdinalIgnoreCase); + private HashSet selectedRoleKeys = new(StringComparer.OrdinalIgnoreCase); + private bool loading = true; + private bool saving; + private string? error; + private bool dirty => !originalRoleKeys.SetEquals(selectedRoleKeys); + + protected override Task OnInitializedAsync() => LoadAsync(); + private async Task LoadAsync() + { + loading = true; + error = null; + try + { + var roleResponse = await Api.GetFromJsonAsync>($"api/v1/tenant-admin/organizations/{Uri.EscapeDataString(OrganizationId)}/users/roles") ?? []; + var membership = await Api.GetFromJsonAsync($"api/v1/tenant-admin/organizations/{Uri.EscapeDataString(OrganizationId)}/users/{Uri.EscapeDataString(UserId)}/assignments"); + roles.Clear(); + roles.AddRange(roleResponse); + originalRoleKeys = membership?.RoleKeys.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new(StringComparer.OrdinalIgnoreCase); + selectedRoleKeys = new HashSet(originalRoleKeys, StringComparer.OrdinalIgnoreCase); + } + catch (HttpRequestException) + { + error = "Organization roles could not be loaded for this account."; + } + finally { loading = false; } + } + private void SetRole(string key, bool enabled) { if (enabled) selectedRoleKeys.Add(key); else selectedRoleKeys.Remove(key); } + private async Task SaveAsync() + { + saving = true; + error = null; + try + { + using var response = await Api.PutAsJsonAsync($"api/v1/tenant-admin/organizations/{Uri.EscapeDataString(OrganizationId)}/users/{Uri.EscapeDataString(UserId)}/assignments", new ReplaceMembership(selectedRoleKeys.Order(StringComparer.OrdinalIgnoreCase).ToArray())); + if (!response.IsSuccessStatusCode) { error = await response.Content.ReadAsStringAsync(); return; } + await LoadAsync(); + } + catch (HttpRequestException) { error = "Organization roles could not be saved."; } + finally { saving = false; } + } + private sealed record RoleOption(string Key, string Name); + private sealed record Membership(string UserId, string OrganizationId, IReadOnlyList RoleKeys); + private sealed record ReplaceMembership(IReadOnlyList RoleKeys); +} diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/EditCustomerDialog.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/EditCustomerDialog.razor index c6bd40f7..c3357954 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/EditCustomerDialog.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/EditCustomerDialog.razor @@ -77,6 +77,23 @@ else if (string.Equals(authStatus?.AuthProviderType, "Local", StringComparison.OrdinalIgnoreCase)) { Local activation and account lifecycle actions are managed from the organization member editor. + @if (authStatus.InviteStatus == CustomerInviteStatus.Pending) + { + Generate activation token + } + @if (!string.IsNullOrWhiteSpace(localActivationToken)) + { + Copy this activation token now. It is shown only in this dialog. + + } + } + @if (!string.IsNullOrWhiteSpace(authStatus?.LocalAccountId)) + { + + } + else if (authStatus?.IsLinkedLogin == true) + { + This provider-managed identity has no supported local role-assignment workflow. } @@ -100,6 +117,7 @@ private MudForm form = default!; private EditCustomerModel model = new(); private CustomerAuthStatusDto? authStatus; + private string? localActivationToken; protected override void OnParametersSet() { @@ -165,6 +183,14 @@ private Task DisableLogin() => RunAuthAction("disable-login", "Login access disabled."); + private async Task GenerateLocalActivationToken() + { + using var response = await HelpdeskApi.PostAsync($"api/v1/customers/{Customer.Id}/auth/local-activation-token", null); + if (!response.IsSuccessStatusCode) { Snackbar.Add(await ReadProblemMessageAsync(response), Severity.Error); return; } + var token = await response.Content.ReadFromJsonAsync(); + localActivationToken = token?.ActivationToken; + } + private async Task RunAuthAction(string route, string successMessage) { try @@ -187,7 +213,12 @@ } } - private bool IsInviteDisabled => !model.Enabled || authStatus?.InviteStatus is CustomerInviteStatus.Active or CustomerInviteStatus.Disabled; + private bool IsInviteDisabled => !model.Enabled || authStatus?.InviteStatus switch + { + CustomerInviteStatus.Pending or CustomerInviteStatus.Failed or CustomerInviteStatus.Expired => authStatus.CanResend != true, + CustomerInviteStatus.Active or CustomerInviteStatus.Disabled => true, + _ => authStatus?.CanInvite != true + }; private static async Task ReadProblemMessageAsync(HttpResponseMessage response) { @@ -209,6 +240,7 @@ } private sealed record ProblemResponse(string? Title, string? Detail); + private sealed record LocalActivationTokenResponse(string UserId, string Email, string ActivationToken); private string InviteButtonText => authStatus?.InviteStatus switch { diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/InstanceBrandingAdmin.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/InstanceBrandingAdmin.razor index 155a3bb0..9c909e88 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/InstanceBrandingAdmin.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/InstanceBrandingAdmin.razor @@ -78,7 +78,7 @@ var field = _administration?.Fields.FirstOrDefault(x => x.Name == name); if (field is null || !field.IsAdminEditable) return; _useDefaultFields.Add(name); - SetValue(name, field.EffectiveValue); + SetValue(name, field.DefaultValue); } private void SetValue(string name, string? value) { @@ -101,8 +101,9 @@ } private string? ValueToSave(string name, string value) { - if (_useDefaultFields.Contains(name)) return null; var field = _administration!.Fields.Single(x => x.Name == name); + if (_useDefaultFields.Contains(name)) + return string.Equals(value, field.DefaultValue, StringComparison.Ordinal) ? null : value; return string.Equals(value, field.EffectiveValue, StringComparison.Ordinal) ? field.PersistedValue : value; } private InstanceBrandingUpdate UpdateForSave() => new() diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/EditOrganizationDialog.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/EditOrganizationDialog.razor index 5c39c8a3..255617d9 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/EditOrganizationDialog.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/EditOrganizationDialog.razor @@ -2,6 +2,7 @@ @using Helpdesk.Shared.DTOs.Orchestration @using Helpdesk.Shared.DTOs.Organization @using Helpdesk.Shared.Models +@using HelpDesk.NewWeb.Components.Pages.Admin.User @inject IHttpClientFactory HttpClientFactory @inject ISnackbar Snackbar @@ -34,6 +35,9 @@ } + + + diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/OrganizationManagement.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/OrganizationManagement.razor index 892e6d22..1246b944 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/OrganizationManagement.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/OrganizationManagement.razor @@ -54,16 +54,18 @@ - - - - + + + +
@code { [SupplyParameterFromQuery(Name = "search")] public string? QuerySearch { get; set; } + [SupplyParameterFromQuery(Name = "section")] public string? Section { get; set; } + [SupplyParameterFromQuery(Name = "organizationId")] public string? OrganizationId { get; set; } private HttpClient HelpdeskApi => HttpClientFactory.CreateClient("HelpdeskApi"); private List organizations = new(); diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleAssignmentDetailsDialog.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleAssignmentDetailsDialog.razor index 72c0cb77..0f76d447 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleAssignmentDetailsDialog.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleAssignmentDetailsDialog.razor @@ -9,6 +9,7 @@ Explicit user-role-organization assignments (up to 200). @if (loading) { } + else if (!string.IsNullOrWhiteSpace(error)) { @error Retry } else { PersonEmailOrganization@context.Name@context.Email@OrganizationName(context.OrganizationId) } @@ -22,8 +23,21 @@ [Parameter] public IReadOnlyList Organizations { get; set; } = []; private readonly List assignments = []; private bool loading = true; + private string? error; private HttpClient Api => HttpClientFactory.CreateClient("HelpdeskApi"); - protected override async Task OnInitializedAsync() { assignments.AddRange(await Api.GetFromJsonAsync>($"api/v1/admin/role-definitions/{Role.Id}/assignments") ?? []); loading = false; } + protected override Task OnInitializedAsync() => LoadAsync(); + private async Task LoadAsync() + { + loading = true; + error = null; + try + { + assignments.Clear(); + assignments.AddRange(await Api.GetFromJsonAsync>($"api/v1/admin/role-definitions/{Role.Id}/assignments") ?? []); + } + catch (HttpRequestException) { error = "Role assignments could not be loaded."; } + finally { loading = false; } + } private string OrganizationName(string id) => Organizations.FirstOrDefault(organization => organization.Id == id)?.Name ?? id; private sealed record RoleAssignment(string UserId, string Name, string Email, string OrganizationId); } diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor index 764459f5..79dc6453 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor @@ -5,6 +5,7 @@ @using Helpdesk.Shared.Auth @inject IHttpClientFactory HttpClientFactory @inject IDialogService DialogService +@inject AuthenticationStateProvider AuthenticationState Roles & Permissions @@ -111,7 +112,10 @@ @code { - private static readonly IReadOnlyList PermissionOptions = HelpdeskPermissions.AssignablePermissions + private IReadOnlyList PermissionOptions => (isInstanceAdministrator + ? HelpdeskPermissions.AssignablePermissions.AsEnumerable() + : RoleDefinitionCatalog.TenantAdministratorPermissionCeiling.AsEnumerable()) + .OrderBy(permission => permission, StringComparer.OrdinalIgnoreCase) .Select(permission => new PermissionOption(permission, PermissionLabel(permission))).ToArray(); private HttpClient HelpdeskApi => HttpClientFactory.CreateClient("HelpdeskApi"); @@ -125,8 +129,13 @@ private string draftName = string.Empty; private string? draftOwnerOrganizationId; private HashSet draftPermissions = new(StringComparer.OrdinalIgnoreCase); + private bool isInstanceAdministrator; - protected override async Task OnInitializedAsync() => await LoadAsync(); + protected override async Task OnInitializedAsync() + { + isInstanceAdministrator = (await AuthenticationState.GetAuthenticationStateAsync()).User.IsInRole("HelpdeskAdmin"); + await LoadAsync(); + } private async Task LoadAsync() { @@ -200,7 +209,8 @@ if (!response.IsSuccessStatusCode) { - error = "The role could not be saved. Confirm the owner and permission dependencies, then try again."; + error = await response.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(error)) error = "The role could not be saved. Confirm the owner and permission dependencies, then try again."; return; } diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor index a5285f68..82dc989f 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor @@ -99,6 +99,7 @@ @code { [Parameter] public bool IsCanonical { get; set; } + [Parameter] public string? OrganizationId { get; set; } private HttpClient HelpdeskApi => HttpClientFactory.CreateClient("HelpdeskApi"); private List organizations = new(); private List members = new(); @@ -123,7 +124,9 @@ } organizations = await response.Content.ReadFromJsonAsync>() ?? new(); - selectedOrganizationId = organizations.FirstOrDefault()?.Id; + selectedOrganizationId = organizations.FirstOrDefault(organization => + string.Equals(organization.Id, OrganizationId, StringComparison.OrdinalIgnoreCase))?.Id + ?? organizations.FirstOrDefault()?.Id; if (!string.IsNullOrWhiteSpace(selectedOrganizationId) && !IsCanonical) { NavigationManager.NavigateTo("/admin/organizations", replace: true); diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor index eddfdb11..17885588 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor @@ -29,6 +29,7 @@ @code { [Parameter] public bool IsCanonical { get; set; } + [Parameter] public string? OrganizationId { get; set; } private HttpClient Api => HttpClientFactory.CreateClient("HelpdeskApi"); private List organizations = new(); private TenantSettingsModel? settings; @@ -40,7 +41,8 @@ organizations = (await Api.GetFromJsonAsync>("api/v1/tenant-admin/organizations?permission=Tenant.Settings.Manage") ?? new()) .Where(organization => profile.HasPermission(HelpdeskPermissions.TenantSettingsManage, organization.Id)).ToList(); if (organizations.Count > 0 && !IsCanonical) NavigationManager.NavigateTo("/admin/organizations", replace: true); - else if (organizations.Count > 0) await SelectAsync(organizations[0].Id); + else if (organizations.Count > 0) await SelectAsync(organizations.FirstOrDefault(organization => + string.Equals(organization.Id, OrganizationId, StringComparison.OrdinalIgnoreCase))?.Id ?? organizations[0].Id); } private async Task SelectAsync(string? value) { diff --git a/src/HelpDesk.NewWeb/Models/InstanceBrandingAdministrationResponse.cs b/src/HelpDesk.NewWeb/Models/InstanceBrandingAdministrationResponse.cs index f1d2c0bb..e32591b2 100644 --- a/src/HelpDesk.NewWeb/Models/InstanceBrandingAdministrationResponse.cs +++ b/src/HelpDesk.NewWeb/Models/InstanceBrandingAdministrationResponse.cs @@ -19,6 +19,8 @@ public sealed class InstanceBrandingFieldState public string EffectiveValue { get; set; } = string.Empty; + public string DefaultValue { get; set; } = string.Empty; + public InstanceBrandingValueSource Source { get; set; } public bool IsAdminEditable { get; set; } diff --git a/src/Helpdesk.API/Endpoints/Customers/CustomerAuthEndpoints.cs b/src/Helpdesk.API/Endpoints/Customers/CustomerAuthEndpoints.cs index 1e929696..f399f95e 100644 --- a/src/Helpdesk.API/Endpoints/Customers/CustomerAuthEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Customers/CustomerAuthEndpoints.cs @@ -30,28 +30,46 @@ public static void MapCustomerAuthEndpoints(this IEndpointRouteBuilder app) [FromRoute] string customerId, ClaimsPrincipal user, [FromServices] ICustomerInvitationService invitationService, + [FromServices] HelpdeskDbContext db, CancellationToken ct) => - await RunCustomerAuthActionAsync(() => invitationService.InviteAsync(customerId, ResolveUserId(user), ct))); + await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.InviteAsync(customerId, ResolveUserId(user), ct), ct)); group.MapPost("/resend-invite", async ( [FromRoute] string customerId, ClaimsPrincipal user, [FromServices] ICustomerInvitationService invitationService, + [FromServices] HelpdeskDbContext db, CancellationToken ct) => - await RunCustomerAuthActionAsync(() => invitationService.ResendInviteAsync(customerId, ResolveUserId(user), ct))); + await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.ResendInviteAsync(customerId, ResolveUserId(user), ct), ct)); group.MapPost("/disable-login", async ( [FromRoute] string customerId, ClaimsPrincipal user, [FromServices] ICustomerInvitationService invitationService, + [FromServices] HelpdeskDbContext db, CancellationToken ct) => - await RunCustomerAuthActionAsync(() => invitationService.DisableLoginAsync(customerId, ResolveUserId(user), ct))); + await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.DisableLoginAsync(customerId, ResolveUserId(user), ct), ct)); group.MapPost("/sync-authentik", async ( [FromRoute] string customerId, [FromServices] ICustomerInvitationService invitationService, + [FromServices] HelpdeskDbContext db, CancellationToken ct) => - await RunCustomerAuthActionAsync(() => invitationService.SyncAuthentikAsync(customerId, ct))); + await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.SyncAuthentikAsync(customerId, ct), ct)); + + group.MapPost("/local-activation-token", async ( + [FromRoute] string customerId, + [FromServices] HelpdeskDbContext db, + [FromServices] UserManager users, + CancellationToken ct) => + { + var links = await db.CustomerAuthLinks.AsNoTracking().Where(link => link.CustomerId == customerId).ToArrayAsync(ct); + if (links.Length != 1 || !string.Equals(links[0].AuthProviderType, "Local", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(links[0].LocalAccountId)) + return Results.Conflict(new { message = "A single linked local account is required to generate an activation token." }); + var account = await users.FindByIdAsync(links[0].LocalAccountId); + if (account is null || !account.IsEnabled) return Results.Conflict(new { message = "The linked local account is unavailable." }); + return Results.Ok(new LocalActivationTokenResponse(account.Id, account.Email ?? string.Empty, await users.GeneratePasswordResetTokenAsync(account))); + }); var adminGroup = app.MapGroup("/api/admin/customers/{customerId}") .WithTags("Customer Authentication") @@ -69,28 +87,32 @@ public static void MapCustomerAuthEndpoints(this IEndpointRouteBuilder app) [FromRoute] string customerId, ClaimsPrincipal user, [FromServices] ICustomerInvitationService invitationService, + [FromServices] HelpdeskDbContext db, CancellationToken ct) => - await RunCustomerAuthActionAsync(() => invitationService.InviteAsync(customerId, ResolveUserId(user), ct))); + await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.InviteAsync(customerId, ResolveUserId(user), ct), ct)); adminGroup.MapPost("/resend-invite", async ( [FromRoute] string customerId, ClaimsPrincipal user, [FromServices] ICustomerInvitationService invitationService, + [FromServices] HelpdeskDbContext db, CancellationToken ct) => - await RunCustomerAuthActionAsync(() => invitationService.ResendInviteAsync(customerId, ResolveUserId(user), ct))); + await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.ResendInviteAsync(customerId, ResolveUserId(user), ct), ct)); adminGroup.MapPost("/disable-login", async ( [FromRoute] string customerId, ClaimsPrincipal user, [FromServices] ICustomerInvitationService invitationService, + [FromServices] HelpdeskDbContext db, CancellationToken ct) => - await RunCustomerAuthActionAsync(() => invitationService.DisableLoginAsync(customerId, ResolveUserId(user), ct))); + await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.DisableLoginAsync(customerId, ResolveUserId(user), ct), ct)); adminGroup.MapPost("/sync-authentik", async ( [FromRoute] string customerId, [FromServices] ICustomerInvitationService invitationService, + [FromServices] HelpdeskDbContext db, CancellationToken ct) => - await RunCustomerAuthActionAsync(() => invitationService.SyncAuthentikAsync(customerId, ct))); + await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.SyncAuthentikAsync(customerId, ct), ct)); } private static async Task RunCustomerAuthActionAsync(Func> action) @@ -125,6 +147,28 @@ private static async Task RunCustomerAuthActionAsync(Func> a } } + private static async Task RunExternalCustomerAuthActionAsync( + string customerId, + HelpdeskDbContext db, + Func> action, + CancellationToken ct) + { + var links = await db.CustomerAuthLinks.AsNoTracking() + .Where(link => link.CustomerId == customerId) + .Select(link => new { link.AuthProviderType, link.LocalAccountId }) + .ToArrayAsync(ct); + if (links.Length > 1) + { + return Results.Conflict(new { message = "Multiple linked identities must be reviewed before an external access action can run." }); + } + if (links.SingleOrDefault() is { } link && + (string.Equals(link.AuthProviderType, "Local", StringComparison.OrdinalIgnoreCase) || !string.IsNullOrWhiteSpace(link.LocalAccountId))) + { + return Results.Conflict(new { message = "This customer has a local account. Use the local activation workflow; external invitation actions are unavailable." }); + } + return await RunCustomerAuthActionAsync(action); + } + private static async Task GetStatusAsync( string customerId, ICustomerInvitationService invitationService, @@ -220,6 +264,8 @@ private static async Task GetLocalAccountStatusAsync( CanInvite = true }; + public sealed record LocalActivationTokenResponse(string UserId, string Email, string ActivationToken); + private static string ResolveUserId(ClaimsPrincipal user) => user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub") diff --git a/src/Helpdesk.API/Program.cs b/src/Helpdesk.API/Program.cs index c7753959..6dd89447 100644 --- a/src/Helpdesk.API/Program.cs +++ b/src/Helpdesk.API/Program.cs @@ -1467,7 +1467,7 @@ static void MapCrudEndpoints(WebApplication app, string route) where T : clas return Results.Problem("ID assignment failed", statusCode: 400); }); - group.MapPut("/{id}", async ([FromRoute] string id, [FromBody] T entity, [FromServices] SharedServices.IRepository repo) => + group.MapPut("/{id}", async ([FromRoute] string id, [FromBody] T entity, [FromServices] SharedServices.IRepository repo, [FromServices] HelpdeskDbContext db) => { var idProperty = typeof(T).GetProperty("Id"); if (idProperty is null) @@ -1475,6 +1475,20 @@ static void MapCrudEndpoints(WebApplication app, string route) where T : clas return Results.Problem("Invalid entity", statusCode: 400); } + if (entity is Customer requestedCustomer) + { + var existingCustomer = await db.Customers.AsNoTracking().SingleOrDefaultAsync(customer => customer.Id == id); + if (existingCustomer is not null && + !string.Equals(existingCustomer.OrganizationId, requestedCustomer.OrganizationId, StringComparison.OrdinalIgnoreCase) && + await db.CustomerAuthLinks.AsNoTracking().AnyAsync(link => link.CustomerId == id)) + { + return Results.ValidationProblem(new Dictionary + { + ["organizationId"] = ["Customers with a linked login cannot be moved here. Use an explicit access move workflow so existing organization access is not stranded."] + }); + } + } + idProperty.SetValue(entity, id); return await repo.UpdateAsync(entity) is T updated ? Results.Ok(updated) diff --git a/src/Helpdesk.Application/Services/Branding/IInstanceBrandingProvider.cs b/src/Helpdesk.Application/Services/Branding/IInstanceBrandingProvider.cs index 68a9270b..6f347f32 100644 --- a/src/Helpdesk.Application/Services/Branding/IInstanceBrandingProvider.cs +++ b/src/Helpdesk.Application/Services/Branding/IInstanceBrandingProvider.cs @@ -41,6 +41,7 @@ public sealed record InstanceBrandingFieldState( string Name, string? PersistedValue, string EffectiveValue, + string DefaultValue, InstanceBrandingValueSource Source, bool IsAdminEditable); diff --git a/src/Helpdesk.Infrastructure/Auth/Authentik/CustomerInvitationService.cs b/src/Helpdesk.Infrastructure/Auth/Authentik/CustomerInvitationService.cs index 152ae488..10b4df78 100644 --- a/src/Helpdesk.Infrastructure/Auth/Authentik/CustomerInvitationService.cs +++ b/src/Helpdesk.Infrastructure/Auth/Authentik/CustomerInvitationService.cs @@ -361,6 +361,14 @@ private static CustomerAuthStatusDto ToStatus(Customer customer, CustomerAuthLin InviteStatus = status, StatusText = GetStatusText(status), AuthProviderType = link?.AuthProviderType, + LocalAccountId = link?.LocalAccountId, + DomainUserId = link?.DomainUserId, + IsLinkedLogin = link is not null, + IdentitySummary = link is null + ? "This contact has no linked login." + : !string.IsNullOrWhiteSpace(link.AuthentikEmail) ? link.AuthentikEmail + : !string.IsNullOrWhiteSpace(link.AuthentikUsername) ? link.AuthentikUsername + : "External identity linked", OidcIssuer = link?.OidcIssuer, OidcSubject = link?.OidcSubject, AuthentikUserId = link?.AuthentikUserId, diff --git a/src/Helpdesk.Infrastructure/Branding/InstanceBrandingProvider.cs b/src/Helpdesk.Infrastructure/Branding/InstanceBrandingProvider.cs index 8e81577c..14a4c705 100644 --- a/src/Helpdesk.Infrastructure/Branding/InstanceBrandingProvider.cs +++ b/src/Helpdesk.Infrastructure/Branding/InstanceBrandingProvider.cs @@ -25,17 +25,17 @@ public async Task GetAdministrationAsync(Cancell var effective = BuildEffective(persisted); return new InstanceBrandingAdministration(effective, [ - State(nameof(InstanceBranding.ApplicationName), persisted?.ApplicationName, effective.ApplicationName), - State(nameof(InstanceBranding.OrganizationName), persisted?.OrganizationName, effective.OrganizationName), - State(nameof(InstanceBranding.ApplicationUrl), persisted?.ApplicationUrl, effective.ApplicationUrl), - State(nameof(InstanceBranding.OrganizationUrl), persisted?.OrganizationUrl, effective.OrganizationUrl), - State(nameof(InstanceBranding.SupportUrl), persisted?.SupportUrl, effective.SupportUrl), - State(nameof(InstanceBranding.SupportEmail), persisted?.SupportEmail, effective.SupportEmail), - State(nameof(InstanceBranding.LogoUrl), persisted?.LogoUrl, effective.LogoUrl), - State(nameof(InstanceBranding.CompactLogoUrl), persisted?.CompactLogoUrl, effective.CompactLogoUrl), - State(nameof(InstanceBranding.FaviconUrl), persisted?.FaviconUrl, effective.FaviconUrl), - State(nameof(InstanceBranding.EmailFromDisplayName), persisted?.EmailFromDisplayName, effective.EmailFromDisplayName), - State(nameof(InstanceBranding.Tagline), persisted?.Tagline, effective.Tagline) + State(nameof(InstanceBranding.ApplicationName), persisted?.ApplicationName, effective.ApplicationName, Defaults.ApplicationName), + State(nameof(InstanceBranding.OrganizationName), persisted?.OrganizationName, effective.OrganizationName, Defaults.OrganizationName), + State(nameof(InstanceBranding.ApplicationUrl), persisted?.ApplicationUrl, effective.ApplicationUrl, Defaults.ApplicationUrl), + State(nameof(InstanceBranding.OrganizationUrl), persisted?.OrganizationUrl, effective.OrganizationUrl, Defaults.OrganizationUrl), + State(nameof(InstanceBranding.SupportUrl), persisted?.SupportUrl, effective.SupportUrl, Defaults.SupportUrl), + State(nameof(InstanceBranding.SupportEmail), persisted?.SupportEmail, effective.SupportEmail, Defaults.SupportEmail), + State(nameof(InstanceBranding.LogoUrl), persisted?.LogoUrl, effective.LogoUrl, Defaults.LogoUrl), + State(nameof(InstanceBranding.CompactLogoUrl), persisted?.CompactLogoUrl, effective.CompactLogoUrl, Defaults.CompactLogoUrl), + State(nameof(InstanceBranding.FaviconUrl), persisted?.FaviconUrl, effective.FaviconUrl, Defaults.FaviconUrl), + State(nameof(InstanceBranding.EmailFromDisplayName), persisted?.EmailFromDisplayName, effective.EmailFromDisplayName, Defaults.EmailFromDisplayName), + State(nameof(InstanceBranding.Tagline), persisted?.Tagline, effective.Tagline, Defaults.Tagline) ]); } @@ -76,8 +76,8 @@ public async Task SaveAsync(InstanceBrandingUpda Resolve(nameof(InstanceBranding.EmailFromDisplayName), persisted?.EmailFromDisplayName, Defaults.EmailFromDisplayName), Resolve(nameof(InstanceBranding.Tagline), persisted?.Tagline, Defaults.Tagline)); - private InstanceBrandingFieldState State(string name, string? persisted, string effective) => new( - name, persisted, effective, Source(name, persisted), !HasDeploymentValue(name)); + private InstanceBrandingFieldState State(string name, string? persisted, string effective, string fallback) => new( + name, persisted, effective, Normalize(configuration[$"Branding:{name}"]) ?? fallback, Source(name, persisted), !HasDeploymentValue(name)); private string Resolve(string name, string? persisted, string fallback) => Normalize(configuration[$"Branding:{name}"]) ?? Normalize(persisted) ?? fallback; diff --git a/tests/Helpdesk.Tests/Api/CustomerAuthEndpointsTests.cs b/tests/Helpdesk.Tests/Api/CustomerAuthEndpointsTests.cs index 69f750f6..4d149afc 100644 --- a/tests/Helpdesk.Tests/Api/CustomerAuthEndpointsTests.cs +++ b/tests/Helpdesk.Tests/Api/CustomerAuthEndpointsTests.cs @@ -6,7 +6,9 @@ using System.Text.Json.Nodes; using Helpdesk.API; using Helpdesk.Infrastructure.Auth.Authentik; +using Helpdesk.Infrastructure.Persistence; using Helpdesk.Shared.DTOs.Customer; +using Helpdesk.Shared.Models; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -76,6 +78,23 @@ public async Task Invite_ReturnsBadGateway_WhenAuthentikFailsServerRequest() Assert.Contains("Authentik rejected create recovery link (500 InternalServerError): upstream failure", body, StringComparison.Ordinal); } + [Theory] + [InlineData("Local", 1, "local account")] + [InlineData("Authentik", 2, "Multiple linked identities")] + public async Task ExternalInviteAction_RejectsLocalOrAmbiguousLinks_BeforeCallingInvitationService(string provider, int linkCount, string expectedMessage) + { + using var factory = CreateFactory(new ThrowingInvitationService( + new InvalidOperationException("The invitation service must not be called."))); + await SeedLinksAsync(factory, provider, linkCount); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test", "HelpdeskAdmin"); + + var response = await client.PostAsync("/api/v1/customers/customer-1/auth/resend-invite", null); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Contains(expectedMessage, (await response.Content.ReadAsStringAsync()), StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task CreateUserAsync_ThrowsAuthentikRequestException_WithSanitizedResponseBody() { @@ -157,6 +176,23 @@ private static WebApplicationFactory CreateFactory(ICustomerInvitationS }); }); + private static async Task SeedLinksAsync(WebApplicationFactory factory, string provider, int count) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Customers.Add(new Customer { Id = "customer-1", Name = "Customer", Email = "customer@example.test", OrganizationId = "organization-1" }); + for (var index = 0; index < count; index++) + { + db.CustomerAuthLinks.Add(new CustomerAuthLink + { + CustomerId = "customer-1", + AuthProviderType = index == 0 ? provider : "Authentik", + LocalAccountId = index == 0 && string.Equals(provider, "Local", StringComparison.OrdinalIgnoreCase) ? "local-account" : null + }); + } + await db.SaveChangesAsync(); + } + private sealed class ThrowingInvitationService(Exception exception) : ICustomerInvitationService { public Task GetStatusAsync(string customerId, CancellationToken ct = default) diff --git a/tests/Helpdesk.Tests/Infrastructure/Branding/InstanceBrandingProviderTests.cs b/tests/Helpdesk.Tests/Infrastructure/Branding/InstanceBrandingProviderTests.cs index d6b5ba7c..6b514391 100644 --- a/tests/Helpdesk.Tests/Infrastructure/Branding/InstanceBrandingProviderTests.cs +++ b/tests/Helpdesk.Tests/Infrastructure/Branding/InstanceBrandingProviderTests.cs @@ -73,6 +73,7 @@ public async Task SaveAsync_ClearOneOverride_PreservesInheritedDefaultsForOtherF Assert.Null(stored.Value?.LogoUrl); Assert.Equal("RatelDesk", administration.Effective.ApplicationName); Assert.Equal("/branding/rateldesk-wordmark.webp", administration.Effective.LogoUrl); + Assert.Equal("RatelDesk", Assert.Single(administration.Fields, x => x.Name == nameof(InstanceBranding.ApplicationName)).DefaultValue); } private static InstanceBrandingProvider CreateProvider(IConfiguration configuration, out BrandingState stored) diff --git a/tests/Helpdesk.Tests/NewWeb/InstanceBrandingAdministrationResponseTests.cs b/tests/Helpdesk.Tests/NewWeb/InstanceBrandingAdministrationResponseTests.cs index 2b18a7dd..dc0549f8 100644 --- a/tests/Helpdesk.Tests/NewWeb/InstanceBrandingAdministrationResponseTests.cs +++ b/tests/Helpdesk.Tests/NewWeb/InstanceBrandingAdministrationResponseTests.cs @@ -20,6 +20,7 @@ public void Deserialization_AcceptsNumericAndStringValueSources(string source) "name": "ApplicationName", "persistedValue": "Custom Desk", "effectiveValue": "Deployment Desk", + "defaultValue": "RatelDesk", "source": {{source}}, "isAdminEditable": false } @@ -35,6 +36,7 @@ public void Deserialization_AcceptsNumericAndStringValueSources(string source) Assert.Equal("ApplicationName", field.Name); Assert.Equal("Custom Desk", field.PersistedValue); Assert.Equal("Deployment Desk", field.EffectiveValue); + Assert.Equal("RatelDesk", field.DefaultValue); Assert.Equal(InstanceBrandingValueSource.Environment, field.Source); Assert.False(field.IsAdminEditable); } diff --git a/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs b/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs index 03da0794..4f00ce64 100644 --- a/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs +++ b/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs @@ -72,6 +72,11 @@ public void TenantMembershipPage_UsesApiResolvedTenantAccess() Assert.Contains("Href=\"/admin/roles\"", navigation, StringComparison.Ordinal); Assert.DoesNotContain("Href=\"/tenant-administration\"", navigation, StringComparison.Ordinal); Assert.DoesNotContain("Href=\"/tenant-settings\"", navigation, StringComparison.Ordinal); + + var organizationPage = File.ReadAllText(Path.Combine(repoRoot, "src", "HelpDesk.NewWeb", "Components", "Pages", "Admin", "Organization", "OrganizationManagement.razor")); + Assert.Contains("General & settings", organizationPage, StringComparison.Ordinal); + Assert.Contains("Members & access", organizationPage, StringComparison.Ordinal); + Assert.Contains("OrganizationId=\"@OrganizationId\"", organizationPage, StringComparison.Ordinal); Assert.Contains("", navMenu, StringComparison.Ordinal); } } From b77b15d47f9de9ca2950e8d496870e92a6d440ab Mon Sep 17 00:00:00 2001 From: "boston-ai-agent[bot]" <299475768+boston-ai-agent[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:47:38 +0200 Subject: [PATCH 3/5] test: stabilize customer invitation guard fixtures --- src/Helpdesk.API/Program.cs | 3 +- .../Api/CustomerAuthEndpointsTests.cs | 33 +++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/Helpdesk.API/Program.cs b/src/Helpdesk.API/Program.cs index 6dd89447..6ace2e37 100644 --- a/src/Helpdesk.API/Program.cs +++ b/src/Helpdesk.API/Program.cs @@ -350,6 +350,7 @@ await BootstrapStartupService.ReconcileSelectedMarkerAsync( if (skipDatabaseStartup) { var testDatabaseRoot = new InMemoryDatabaseRoot(); + var testDatabaseName = builder.Configuration["Helpdesk:TestDatabaseName"] ?? $"helpdesk-tests-{Guid.NewGuid():N}"; #pragma warning disable ASP0000 var testDatabaseProvider = new ServiceCollection() .AddEntityFrameworkInMemoryDatabase() @@ -359,7 +360,7 @@ await BootstrapStartupService.ReconcileSelectedMarkerAsync( builder.Services.RemoveAll>(); builder.Services.AddDbContext(options => options - .UseInMemoryDatabase($"helpdesk-tests-{Guid.NewGuid():N}", testDatabaseRoot) + .UseInMemoryDatabase(testDatabaseName, testDatabaseRoot) .UseInternalServiceProvider(testDatabaseProvider) .ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning))); } diff --git a/tests/Helpdesk.Tests/Api/CustomerAuthEndpointsTests.cs b/tests/Helpdesk.Tests/Api/CustomerAuthEndpointsTests.cs index 4d149afc..ef63adb9 100644 --- a/tests/Helpdesk.Tests/Api/CustomerAuthEndpointsTests.cs +++ b/tests/Helpdesk.Tests/Api/CustomerAuthEndpointsTests.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -83,9 +84,10 @@ public async Task Invite_ReturnsBadGateway_WhenAuthentikFailsServerRequest() [InlineData("Authentik", 2, "Multiple linked identities")] public async Task ExternalInviteAction_RejectsLocalOrAmbiguousLinks_BeforeCallingInvitationService(string provider, int linkCount, string expectedMessage) { - using var factory = CreateFactory(new ThrowingInvitationService( - new InvalidOperationException("The invitation service must not be called."))); + var invitationService = new ThrowingInvitationService(new InvalidOperationException("The invitation service must not be called.")); + using var factory = CreateFactory(invitationService); await SeedLinksAsync(factory, provider, linkCount); + await AssertSeedVisibleFromNewScopeAsync(factory, linkCount); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test", "HelpdeskAdmin"); @@ -93,6 +95,8 @@ public async Task ExternalInviteAction_RejectsLocalOrAmbiguousLinks_BeforeCallin Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); Assert.Contains(expectedMessage, (await response.Content.ReadAsStringAsync()), StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, invitationService.InvocationCount); + await AssertSeedVisibleFromNewScopeAsync(factory, linkCount); } [Fact] @@ -159,6 +163,7 @@ public async Task CreateUserAsync_SendsEmailAsUsername() private static WebApplicationFactory CreateFactory(ICustomerInvitationService invitationService) => new WebApplicationFactory().WithWebHostBuilder(builder => { + builder.UseSetting("Helpdesk:TestDatabaseName", $"customer-auth-{Guid.NewGuid():N}"); builder.UseIsolatedTestStorage(); builder.UseSetting(WebHostDefaults.EnvironmentKey, "Development"); builder.UseEnvironment("Development"); @@ -193,22 +198,36 @@ private static async Task SeedLinksAsync(WebApplicationFactory factory, await db.SaveChangesAsync(); } + private static async Task AssertSeedVisibleFromNewScopeAsync(WebApplicationFactory factory, int expectedLinkCount) + { + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(expectedLinkCount, await db.CustomerAuthLinks.CountAsync(link => link.CustomerId == "customer-1")); + } + private sealed class ThrowingInvitationService(Exception exception) : ICustomerInvitationService { + public int InvocationCount { get; private set; } public Task GetStatusAsync(string customerId, CancellationToken ct = default) - => throw exception; + => Throw(); public Task InviteAsync(string customerId, string invitedByUserId, CancellationToken ct = default) - => throw exception; + => Throw(); public Task ResendInviteAsync(string customerId, string invitedByUserId, CancellationToken ct = default) - => throw exception; + => Throw(); public Task DisableLoginAsync(string customerId, string disabledByUserId, CancellationToken ct = default) - => throw exception; + => Throw(); public Task SyncAuthentikAsync(string customerId, CancellationToken ct = default) - => throw exception; + => Throw(); + + private Task Throw() + { + InvocationCount++; + return Task.FromException(exception); + } } private sealed class TestAuthHandler( From 669f5580ff9b84ce6328855df971b8f221153a5c Mon Sep 17 00:00:00 2001 From: "boston-ai-agent[bot]" <299475768+boston-ai-agent[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:49:04 +0200 Subject: [PATCH 4/5] fix: preserve organization context in admin views --- .../Organization/OrganizationManagement.razor | 10 +++++-- .../Admin/User/TenantAdministration.razor | 30 ++++++++++++------- .../Pages/Admin/User/TenantSettings.razor | 30 ++++++++++++------- 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/OrganizationManagement.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/OrganizationManagement.razor index 1246b944..39980c7c 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/OrganizationManagement.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Organization/OrganizationManagement.razor @@ -54,7 +54,7 @@ - + @@ -72,6 +72,10 @@ private List orchestrationTenants = new(); private string searchText = string.Empty; private bool isInstanceAdministrator; + private bool canAssignTenantRoles; + private int DelegatedTabIndex => string.Equals(Section, "settings", StringComparison.OrdinalIgnoreCase) + ? 0 + : string.Equals(Section, "members", StringComparison.OrdinalIgnoreCase) || canAssignTenantRoles ? 1 : 0; private IEnumerable FilteredOrganizations => organizations .Where(org => string.IsNullOrWhiteSpace(searchText) @@ -82,7 +86,9 @@ protected override async Task OnInitializedAsync() { - isInstanceAdministrator = (await AuthenticationState.GetAuthenticationStateAsync()).User.IsInRole("HelpdeskAdmin"); + var user = (await AuthenticationState.GetAuthenticationStateAsync()).User; + isInstanceAdministrator = user.IsInRole("HelpdeskAdmin"); + canAssignTenantRoles = user.IsInRole("Tenant.Roles.Assign") || isInstanceAdministrator; if (!isInstanceAdministrator) return; searchText = QuerySearch ?? string.Empty; await LoadOrganizations(); diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor index 82dc989f..a2472fbb 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor @@ -22,12 +22,15 @@ } else { - - @foreach (var organization in organizations) - { - @organization.Name - } - + @if (string.IsNullOrWhiteSpace(OrganizationId)) + { + + @foreach (var organization in organizations) + { + @organization.Name + } + + } Invite a local account The account is created only in the selected tenant with self-service access. Share its one-time activation token through an approved secure channel. @@ -99,7 +102,7 @@ @code { [Parameter] public bool IsCanonical { get; set; } - [Parameter] public string? OrganizationId { get; set; } + [Parameter, SupplyParameterFromQuery(Name = "organizationId")] public string? OrganizationId { get; set; } private HttpClient HelpdeskApi => HttpClientFactory.CreateClient("HelpdeskApi"); private List organizations = new(); private List members = new(); @@ -124,12 +127,17 @@ } organizations = await response.Content.ReadFromJsonAsync>() ?? new(); - selectedOrganizationId = organizations.FirstOrDefault(organization => - string.Equals(organization.Id, OrganizationId, StringComparison.OrdinalIgnoreCase))?.Id - ?? organizations.FirstOrDefault()?.Id; + selectedOrganizationId = !string.IsNullOrWhiteSpace(OrganizationId) + ? organizations.FirstOrDefault(organization => string.Equals(organization.Id, OrganizationId, StringComparison.OrdinalIgnoreCase))?.Id + : organizations.FirstOrDefault()?.Id; + if (!string.IsNullOrWhiteSpace(OrganizationId) && string.IsNullOrWhiteSpace(selectedOrganizationId)) + { + error = "The requested organization is unavailable or you do not have permission to manage its members."; + return; + } if (!string.IsNullOrWhiteSpace(selectedOrganizationId) && !IsCanonical) { - NavigationManager.NavigateTo("/admin/organizations", replace: true); + NavigationManager.NavigateTo($"/admin/organizations?section=members&organizationId={Uri.EscapeDataString(selectedOrganizationId)}", replace: true); } else if (!string.IsNullOrWhiteSpace(selectedOrganizationId)) { diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor index 17885588..e97401ed 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor @@ -12,12 +12,15 @@ Tenant settings @if (error is not null) { @error } - - @foreach (var organization in organizations) - { - @organization.Name - } - + @if (string.IsNullOrWhiteSpace(OrganizationId)) + { + + @foreach (var organization in organizations) + { + @organization.Name + } + + } @if (settings is not null) { @@ -29,7 +32,7 @@ @code { [Parameter] public bool IsCanonical { get; set; } - [Parameter] public string? OrganizationId { get; set; } + [Parameter, SupplyParameterFromQuery(Name = "organizationId")] public string? OrganizationId { get; set; } private HttpClient Api => HttpClientFactory.CreateClient("HelpdeskApi"); private List organizations = new(); private TenantSettingsModel? settings; @@ -40,9 +43,16 @@ var profile = CurrentUserAccessProfile.FromClaims((await AuthenticationState.GetAuthenticationStateAsync()).User); organizations = (await Api.GetFromJsonAsync>("api/v1/tenant-admin/organizations?permission=Tenant.Settings.Manage") ?? new()) .Where(organization => profile.HasPermission(HelpdeskPermissions.TenantSettingsManage, organization.Id)).ToList(); - if (organizations.Count > 0 && !IsCanonical) NavigationManager.NavigateTo("/admin/organizations", replace: true); - else if (organizations.Count > 0) await SelectAsync(organizations.FirstOrDefault(organization => - string.Equals(organization.Id, OrganizationId, StringComparison.OrdinalIgnoreCase))?.Id ?? organizations[0].Id); + var selected = !string.IsNullOrWhiteSpace(OrganizationId) + ? organizations.FirstOrDefault(organization => string.Equals(organization.Id, OrganizationId, StringComparison.OrdinalIgnoreCase))?.Id + : organizations.FirstOrDefault()?.Id; + if (!string.IsNullOrWhiteSpace(OrganizationId) && string.IsNullOrWhiteSpace(selected)) + { + error = "The requested organization is unavailable or you do not have permission to edit its settings."; + return; + } + if (!string.IsNullOrWhiteSpace(selected) && !IsCanonical) NavigationManager.NavigateTo($"/admin/organizations?section=settings&organizationId={Uri.EscapeDataString(selected)}", replace: true); + else if (!string.IsNullOrWhiteSpace(selected)) await SelectAsync(selected); } private async Task SelectAsync(string? value) { From f4d37399b2693ec522a4da0dce053d14176428d5 Mon Sep 17 00:00:00 2001 From: "boston-ai-agent[bot]" <299475768+boston-ai-agent[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:47:13 +0200 Subject: [PATCH 5/5] fix: refresh tenant context and guard authentik invites --- .../Admin/User/TenantAdministration.razor | 46 ++++++++++--- .../Admin/User/TenantOrganizationSelection.cs | 36 ++++++++++ .../Pages/Admin/User/TenantSettings.razor | 69 ++++++++++++++++--- .../Customers/CustomerAuthEndpoints.cs | 50 ++++++++++---- .../Auth/Authentik/AuthentikAdminClient.cs | 12 +--- .../Auth/Authentik/AuthentikOptions.cs | 9 +++ .../Authentik/CustomerInvitationService.cs | 22 ++++-- .../Api/CustomerAuthEndpointsTests.cs | 32 ++++++++- .../PageAuthorizationConventionsTests.cs | 4 ++ .../TenantOrganizationSelectionTests.cs | 26 +++++++ 10 files changed, 257 insertions(+), 49 deletions(-) create mode 100644 src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantOrganizationSelection.cs create mode 100644 tests/Helpdesk.Tests/NewWeb/TenantOrganizationSelectionTests.cs diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor index a2472fbb..d5115009 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor @@ -108,11 +108,15 @@ private List members = new(); private List availableRoles = new(); private readonly Dictionary> roleAssignments = new(StringComparer.OrdinalIgnoreCase); - private string? selectedOrganizationId; + private readonly TenantOrganizationSelection organizationSelection = new(); + private string? selectedOrganizationId => organizationSelection.SelectedOrganizationId; private string? savingUserId; private string? error; private bool loading; private bool inviting; + private bool organizationsLoaded; + private bool hasAppliedOrganizationParameter; + private string? appliedOrganizationId; private string inviteName = string.Empty; private string inviteEmail = string.Empty; private TenantLocalAccountInvitation? invitation; @@ -127,14 +131,36 @@ } organizations = await response.Content.ReadFromJsonAsync>() ?? new(); - selectedOrganizationId = !string.IsNullOrWhiteSpace(OrganizationId) - ? organizations.FirstOrDefault(organization => string.Equals(organization.Id, OrganizationId, StringComparison.OrdinalIgnoreCase))?.Id - : organizations.FirstOrDefault()?.Id; - if (!string.IsNullOrWhiteSpace(OrganizationId) && string.IsNullOrWhiteSpace(selectedOrganizationId)) + organizationsLoaded = true; + await ApplyOrganizationParameterAsync(); + } + + protected override Task OnParametersSetAsync() + { + if (!organizationsLoaded || + (hasAppliedOrganizationParameter && string.Equals(appliedOrganizationId, OrganizationId, StringComparison.OrdinalIgnoreCase))) { + return Task.CompletedTask; + } + + return ApplyOrganizationParameterAsync(); + } + + private async Task ApplyOrganizationParameterAsync() + { + error = null; + hasAppliedOrganizationParameter = true; + appliedOrganizationId = OrganizationId; + if (!organizationSelection.ApplyRouteParameter(OrganizationId, organizations.Select(organization => organization.Id))) + { + members = new(); + availableRoles = new(); + roleAssignments.Clear(); error = "The requested organization is unavailable or you do not have permission to manage its members."; return; } + + invitation = null; if (!string.IsNullOrWhiteSpace(selectedOrganizationId) && !IsCanonical) { NavigationManager.NavigateTo($"/admin/organizations?section=members&organizationId={Uri.EscapeDataString(selectedOrganizationId)}", replace: true); @@ -147,7 +173,7 @@ private async Task SelectOrganization(string organizationId) { - selectedOrganizationId = organizationId; + organizationSelection.Select(organizationId); invitation = null; await LoadMembers(); } @@ -168,7 +194,7 @@ try { var response = await HelpdeskApi.PostAsJsonAsync( - $"api/v1/tenant-admin/organizations/{selectedOrganizationId}/users/", + organizationSelection.Path("users/"), new CreateTenantLocalAccountRequest(inviteName, inviteEmail)); if (!response.IsSuccessStatusCode) { @@ -198,14 +224,14 @@ error = null; try { - var rolesResponse = await HelpdeskApi.GetAsync($"api/v1/tenant-admin/organizations/{selectedOrganizationId}/users/roles"); + var rolesResponse = await HelpdeskApi.GetAsync(organizationSelection.Path("users/roles")); if (!rolesResponse.IsSuccessStatusCode) { error = "Delegable tenant roles could not be loaded. Refresh the page and try again."; return; } - var response = await HelpdeskApi.GetAsync($"api/v1/tenant-admin/organizations/{selectedOrganizationId}/users/"); + var response = await HelpdeskApi.GetAsync(organizationSelection.Path("users/")); if (!response.IsSuccessStatusCode) { error = "Tenant members could not be loaded. Refresh the page and try again."; @@ -260,7 +286,7 @@ try { var response = await HelpdeskApi.PutAsJsonAsync( - $"api/v1/tenant-admin/organizations/{selectedOrganizationId}/users/{member.UserId}/assignments", + $"{organizationSelection.Path("users")}/{Uri.EscapeDataString(member.UserId)}/assignments", new ReplaceTenantMembershipRequest(CurrentRoleKeys(member) .OrderBy(roleKey => roleKey, StringComparer.OrdinalIgnoreCase) .ToArray())); diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantOrganizationSelection.cs b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantOrganizationSelection.cs new file mode 100644 index 00000000..8f04c5ac --- /dev/null +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantOrganizationSelection.cs @@ -0,0 +1,36 @@ +namespace HelpDesk.NewWeb.Components.Pages.Admin.User; + +public sealed class TenantOrganizationSelection +{ + public string? SelectedOrganizationId { get; private set; } + + public bool ApplyRouteParameter(string? organizationId, IEnumerable availableOrganizationIds) + { + var availableIds = availableOrganizationIds.ToArray(); + if (string.IsNullOrWhiteSpace(organizationId)) + { + if (!availableIds.Contains(SelectedOrganizationId, StringComparer.OrdinalIgnoreCase)) + { + SelectedOrganizationId = availableIds.FirstOrDefault(); + } + + return true; + } + + SelectedOrganizationId = availableIds.FirstOrDefault(id => + string.Equals(id, organizationId, StringComparison.OrdinalIgnoreCase)); + return !string.IsNullOrWhiteSpace(SelectedOrganizationId); + } + + public void Select(string organizationId) => SelectedOrganizationId = organizationId; + + public string Path(string relativePath) + { + if (string.IsNullOrWhiteSpace(SelectedOrganizationId)) + { + throw new InvalidOperationException("An organization must be selected before a tenant request can be created."); + } + + return $"api/v1/tenant-admin/organizations/{Uri.EscapeDataString(SelectedOrganizationId)}/{relativePath.TrimStart('/')}"; + } +} diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor index e97401ed..1d8a888f 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor @@ -36,30 +36,77 @@ private HttpClient Api => HttpClientFactory.CreateClient("HelpdeskApi"); private List organizations = new(); private TenantSettingsModel? settings; - private string? organizationId, error; + private readonly TenantOrganizationSelection organizationSelection = new(); + private string? organizationId => organizationSelection.SelectedOrganizationId; + private string? error; private bool busy; + private bool organizationsLoaded; + private bool hasAppliedOrganizationParameter; + private string? appliedOrganizationId; + protected override async Task OnInitializedAsync() { var profile = CurrentUserAccessProfile.FromClaims((await AuthenticationState.GetAuthenticationStateAsync()).User); organizations = (await Api.GetFromJsonAsync>("api/v1/tenant-admin/organizations?permission=Tenant.Settings.Manage") ?? new()) .Where(organization => profile.HasPermission(HelpdeskPermissions.TenantSettingsManage, organization.Id)).ToList(); - var selected = !string.IsNullOrWhiteSpace(OrganizationId) - ? organizations.FirstOrDefault(organization => string.Equals(organization.Id, OrganizationId, StringComparison.OrdinalIgnoreCase))?.Id - : organizations.FirstOrDefault()?.Id; - if (!string.IsNullOrWhiteSpace(OrganizationId) && string.IsNullOrWhiteSpace(selected)) + organizationsLoaded = true; + await ApplyOrganizationParameterAsync(); + } + + protected override Task OnParametersSetAsync() + { + if (!organizationsLoaded || + (hasAppliedOrganizationParameter && string.Equals(appliedOrganizationId, OrganizationId, StringComparison.OrdinalIgnoreCase))) + { + return Task.CompletedTask; + } + + return ApplyOrganizationParameterAsync(); + } + + private async Task ApplyOrganizationParameterAsync() + { + error = null; + hasAppliedOrganizationParameter = true; + appliedOrganizationId = OrganizationId; + if (!organizationSelection.ApplyRouteParameter(OrganizationId, organizations.Select(organization => organization.Id))) { + settings = null; error = "The requested organization is unavailable or you do not have permission to edit its settings."; return; } - if (!string.IsNullOrWhiteSpace(selected) && !IsCanonical) NavigationManager.NavigateTo($"/admin/organizations?section=settings&organizationId={Uri.EscapeDataString(selected)}", replace: true); - else if (!string.IsNullOrWhiteSpace(selected)) await SelectAsync(selected); + + if (!string.IsNullOrWhiteSpace(organizationId) && !IsCanonical) + { + NavigationManager.NavigateTo($"/admin/organizations?section=settings&organizationId={Uri.EscapeDataString(organizationId)}", replace: true); + } + else if (!string.IsNullOrWhiteSpace(organizationId)) + { + await LoadSettingsAsync(); + } } + private async Task SelectAsync(string? value) { - organizationId = value; settings = null; error = null; - if (string.IsNullOrWhiteSpace(value)) return; + if (string.IsNullOrWhiteSpace(value)) + { + organizationSelection.ApplyRouteParameter(null, organizations.Select(organization => organization.Id)); + } + else + { + organizationSelection.Select(value); + } + + await LoadSettingsAsync(); + } + + private async Task LoadSettingsAsync() + { + settings = null; + error = null; + if (string.IsNullOrWhiteSpace(organizationId)) return; busy = true; - try { settings = await Api.GetFromJsonAsync($"api/v1/tenant-admin/organizations/{Uri.EscapeDataString(value)}/settings"); } + try { settings = await Api.GetFromJsonAsync(organizationSelection.Path("settings")); } catch (HttpRequestException) { error = "The tenant settings could not be loaded."; } finally { busy = false; } } @@ -70,7 +117,7 @@ busy = true; error = null; try { - using var response = await Api.PutAsJsonAsync($"api/v1/tenant-admin/organizations/{Uri.EscapeDataString(organizationId)}/settings", settings); + using var response = await Api.PutAsJsonAsync(organizationSelection.Path("settings"), settings); if (!response.IsSuccessStatusCode) { error = "The tenant settings could not be saved. Check your access and the entered values."; return; } Snackbar.Add("Tenant settings saved.", Severity.Success); var index = organizations.FindIndex(organization => organization.Id == organizationId); diff --git a/src/Helpdesk.API/Endpoints/Customers/CustomerAuthEndpoints.cs b/src/Helpdesk.API/Endpoints/Customers/CustomerAuthEndpoints.cs index f399f95e..184133e0 100644 --- a/src/Helpdesk.API/Endpoints/Customers/CustomerAuthEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Customers/CustomerAuthEndpoints.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace Helpdesk.API.Endpoints.Customers; @@ -23,39 +24,44 @@ public static void MapCustomerAuthEndpoints(this IEndpointRouteBuilder app) [FromServices] ICustomerInvitationService invitationService, [FromServices] HelpdeskDbContext db, [FromServices] UserManager users, + [FromServices] IOptions authentikOptions, CancellationToken ct) => - Results.Ok(await GetStatusAsync(customerId, invitationService, db, users, ct))); + Results.Ok(await GetStatusAsync(customerId, invitationService, db, users, authentikOptions.Value, ct))); group.MapPost("/invite", async ( [FromRoute] string customerId, ClaimsPrincipal user, [FromServices] ICustomerInvitationService invitationService, [FromServices] HelpdeskDbContext db, + [FromServices] IOptions authentikOptions, CancellationToken ct) => - await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.InviteAsync(customerId, ResolveUserId(user), ct), ct)); + await RunExternalCustomerAuthActionAsync(customerId, db, authentikOptions.Value, () => invitationService.InviteAsync(customerId, ResolveUserId(user), ct), ct)); group.MapPost("/resend-invite", async ( [FromRoute] string customerId, ClaimsPrincipal user, [FromServices] ICustomerInvitationService invitationService, [FromServices] HelpdeskDbContext db, + [FromServices] IOptions authentikOptions, CancellationToken ct) => - await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.ResendInviteAsync(customerId, ResolveUserId(user), ct), ct)); + await RunExternalCustomerAuthActionAsync(customerId, db, authentikOptions.Value, () => invitationService.ResendInviteAsync(customerId, ResolveUserId(user), ct), ct)); group.MapPost("/disable-login", async ( [FromRoute] string customerId, ClaimsPrincipal user, [FromServices] ICustomerInvitationService invitationService, [FromServices] HelpdeskDbContext db, + [FromServices] IOptions authentikOptions, CancellationToken ct) => - await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.DisableLoginAsync(customerId, ResolveUserId(user), ct), ct)); + await RunExternalCustomerAuthActionAsync(customerId, db, authentikOptions.Value, () => invitationService.DisableLoginAsync(customerId, ResolveUserId(user), ct), ct)); group.MapPost("/sync-authentik", async ( [FromRoute] string customerId, [FromServices] ICustomerInvitationService invitationService, [FromServices] HelpdeskDbContext db, + [FromServices] IOptions authentikOptions, CancellationToken ct) => - await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.SyncAuthentikAsync(customerId, ct), ct)); + await RunExternalCustomerAuthActionAsync(customerId, db, authentikOptions.Value, () => invitationService.SyncAuthentikAsync(customerId, ct), ct)); group.MapPost("/local-activation-token", async ( [FromRoute] string customerId, @@ -80,39 +86,44 @@ public static void MapCustomerAuthEndpoints(this IEndpointRouteBuilder app) [FromServices] ICustomerInvitationService invitationService, [FromServices] HelpdeskDbContext db, [FromServices] UserManager users, + [FromServices] IOptions authentikOptions, CancellationToken ct) => - Results.Ok(await GetStatusAsync(customerId, invitationService, db, users, ct))); + Results.Ok(await GetStatusAsync(customerId, invitationService, db, users, authentikOptions.Value, ct))); adminGroup.MapPost("/invite", async ( [FromRoute] string customerId, ClaimsPrincipal user, [FromServices] ICustomerInvitationService invitationService, [FromServices] HelpdeskDbContext db, + [FromServices] IOptions authentikOptions, CancellationToken ct) => - await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.InviteAsync(customerId, ResolveUserId(user), ct), ct)); + await RunExternalCustomerAuthActionAsync(customerId, db, authentikOptions.Value, () => invitationService.InviteAsync(customerId, ResolveUserId(user), ct), ct)); adminGroup.MapPost("/resend-invite", async ( [FromRoute] string customerId, ClaimsPrincipal user, [FromServices] ICustomerInvitationService invitationService, [FromServices] HelpdeskDbContext db, + [FromServices] IOptions authentikOptions, CancellationToken ct) => - await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.ResendInviteAsync(customerId, ResolveUserId(user), ct), ct)); + await RunExternalCustomerAuthActionAsync(customerId, db, authentikOptions.Value, () => invitationService.ResendInviteAsync(customerId, ResolveUserId(user), ct), ct)); adminGroup.MapPost("/disable-login", async ( [FromRoute] string customerId, ClaimsPrincipal user, [FromServices] ICustomerInvitationService invitationService, [FromServices] HelpdeskDbContext db, + [FromServices] IOptions authentikOptions, CancellationToken ct) => - await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.DisableLoginAsync(customerId, ResolveUserId(user), ct), ct)); + await RunExternalCustomerAuthActionAsync(customerId, db, authentikOptions.Value, () => invitationService.DisableLoginAsync(customerId, ResolveUserId(user), ct), ct)); adminGroup.MapPost("/sync-authentik", async ( [FromRoute] string customerId, [FromServices] ICustomerInvitationService invitationService, [FromServices] HelpdeskDbContext db, + [FromServices] IOptions authentikOptions, CancellationToken ct) => - await RunExternalCustomerAuthActionAsync(customerId, db, () => invitationService.SyncAuthentikAsync(customerId, ct), ct)); + await RunExternalCustomerAuthActionAsync(customerId, db, authentikOptions.Value, () => invitationService.SyncAuthentikAsync(customerId, ct), ct)); } private static async Task RunCustomerAuthActionAsync(Func> action) @@ -150,6 +161,7 @@ private static async Task RunCustomerAuthActionAsync(Func> a private static async Task RunExternalCustomerAuthActionAsync( string customerId, HelpdeskDbContext db, + AuthentikOptions authentikOptions, Func> action, CancellationToken ct) { @@ -166,6 +178,13 @@ private static async Task RunExternalCustomerAuthActionAsync( { return Results.Conflict(new { message = "This customer has a local account. Use the local activation workflow; external invitation actions are unavailable." }); } + if (!authentikOptions.IsConfigured) + { + return Results.Problem( + detail: authentikOptions.ConfigurationError, + statusCode: StatusCodes.Status503ServiceUnavailable, + title: "Customer invitation is not configured"); + } return await RunCustomerAuthActionAsync(action); } @@ -174,6 +193,7 @@ private static async Task GetStatusAsync( ICustomerInvitationService invitationService, HelpdeskDbContext db, UserManager users, + AuthentikOptions authentikOptions, CancellationToken ct) { var customer = await db.Customers.AsNoTracking().SingleOrDefaultAsync(x => x.Id == customerId, ct) @@ -186,7 +206,7 @@ private static async Task GetStatusAsync( if (links.Count == 0) { - return NoLinkedLogin(customer.Id); + return NoLinkedLogin(customer.Id, authentikOptions.IsConfigured); } if (links.Count > 1) @@ -255,13 +275,15 @@ private static async Task GetLocalAccountStatusAsync( }; } - private static CustomerAuthStatusDto NoLinkedLogin(string customerId) => new() + private static CustomerAuthStatusDto NoLinkedLogin(string customerId, bool canInvite) => new() { CustomerId = customerId, InviteStatus = CustomerInviteStatus.NotInvited, StatusText = "No linked login", - IdentitySummary = "This contact has no login link.", - CanInvite = true + IdentitySummary = canInvite + ? "This contact has no login link." + : "This contact has no login link. External invitations are not configured.", + CanInvite = canInvite }; public sealed record LocalActivationTokenResponse(string UserId, string Email, string ActivationToken); diff --git a/src/Helpdesk.Infrastructure/Auth/Authentik/AuthentikAdminClient.cs b/src/Helpdesk.Infrastructure/Auth/Authentik/AuthentikAdminClient.cs index 522fd4d7..9854daf6 100644 --- a/src/Helpdesk.Infrastructure/Auth/Authentik/AuthentikAdminClient.cs +++ b/src/Helpdesk.Infrastructure/Auth/Authentik/AuthentikAdminClient.cs @@ -157,18 +157,12 @@ private static async Task EnsureSuccessAsync( private async Task SendAsync(HttpMethod method, string path, object? payload, CancellationToken ct) { - if (string.IsNullOrWhiteSpace(_options.ApiToken)) + if (!_options.IsConfigured) { - throw new AuthentikAdminConfigurationException( - "Customer invitation is not configured. Set Authentication:AuthentikAdmin:ApiToken for this environment."); - } - - if (!Uri.TryCreate(_options.BaseUrl, UriKind.Absolute, out var baseUri)) - { - throw new AuthentikAdminConfigurationException( - "Customer invitation is not configured. Set Authentication:AuthentikAdmin:BaseUrl to an absolute URL."); + throw new AuthentikAdminConfigurationException(_options.ConfigurationError); } + Uri.TryCreate(_options.BaseUrl, UriKind.Absolute, out var baseUri); _httpClient.BaseAddress ??= baseUri; using var request = new HttpRequestMessage(method, path); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiToken); diff --git a/src/Helpdesk.Infrastructure/Auth/Authentik/AuthentikOptions.cs b/src/Helpdesk.Infrastructure/Auth/Authentik/AuthentikOptions.cs index fc6b8bfe..513c34a8 100644 --- a/src/Helpdesk.Infrastructure/Auth/Authentik/AuthentikOptions.cs +++ b/src/Helpdesk.Infrastructure/Auth/Authentik/AuthentikOptions.cs @@ -9,4 +9,13 @@ public sealed class AuthentikOptions public string RecoveryEmailStageId { get; set; } = string.Empty; public string[] CustomerDefaultGroups { get; set; } = []; public int InviteLifetimeDays { get; set; } = 7; + + public bool IsConfigured => + !string.IsNullOrWhiteSpace(ApiToken) && + Uri.TryCreate(BaseUrl, UriKind.Absolute, out var baseUri) && + (baseUri.Scheme == Uri.UriSchemeHttp || baseUri.Scheme == Uri.UriSchemeHttps); + + public string ConfigurationError => string.IsNullOrWhiteSpace(ApiToken) + ? "Customer invitation is not configured. Set Authentication:AuthentikAdmin:ApiToken for this environment." + : "Customer invitation is not configured. Set Authentication:AuthentikAdmin:BaseUrl to an absolute HTTP or HTTPS URL."; } diff --git a/src/Helpdesk.Infrastructure/Auth/Authentik/CustomerInvitationService.cs b/src/Helpdesk.Infrastructure/Auth/Authentik/CustomerInvitationService.cs index 10b4df78..1b062102 100644 --- a/src/Helpdesk.Infrastructure/Auth/Authentik/CustomerInvitationService.cs +++ b/src/Helpdesk.Infrastructure/Auth/Authentik/CustomerInvitationService.cs @@ -123,6 +123,7 @@ private async Task SendInviteAsync( bool allowExistingInvite, CancellationToken ct) { + EnsureConfigured(); var customer = await FindCustomerAsync(customerId, ct); if (!customer.IsEnabled) { @@ -232,6 +233,8 @@ private async Task FindOrCreateLinkAsync(Customer customer, Ca return link; } + EnsureConfigured(); + link = new CustomerAuthLink { CustomerId = customer.Id, @@ -243,6 +246,14 @@ private async Task FindOrCreateLinkAsync(Customer customer, Ca return link; } + private void EnsureConfigured() + { + if (!_options.IsConfigured) + { + throw new AuthentikAdminConfigurationException(_options.ConfigurationError); + } + } + private async Task AuditAsync(string customerId, string userId, string message, CancellationToken ct) { _db.ActivityLogs.Add(new ActivityLog @@ -352,7 +363,7 @@ private static string RenderSubject(string subject, EmailTemplateContext context return rendered; } - private static CustomerAuthStatusDto ToStatus(Customer customer, CustomerAuthLink? link) + private CustomerAuthStatusDto ToStatus(Customer customer, CustomerAuthLink? link) { var status = link?.InviteStatus ?? CustomerInviteStatus.NotInvited; return new CustomerAuthStatusDto @@ -381,9 +392,12 @@ private static CustomerAuthStatusDto ToStatus(Customer customer, CustomerAuthLin LastAuthSyncAtUtc = link?.LastAuthSyncAtUtc, LastAuthError = link?.LastAuthError, InviteLinkExpiresAtUtc = link?.InviteLinkExpiresAtUtc, - CanInvite = customer.IsEnabled && status is CustomerInviteStatus.NotInvited or CustomerInviteStatus.Failed or CustomerInviteStatus.Expired, - CanResend = customer.IsEnabled && status is CustomerInviteStatus.Pending or CustomerInviteStatus.Failed or CustomerInviteStatus.Expired, - CanDisableLogin = status is CustomerInviteStatus.Pending or CustomerInviteStatus.Active + CanInvite = _options.IsConfigured && customer.IsEnabled && + status is CustomerInviteStatus.NotInvited or CustomerInviteStatus.Failed or CustomerInviteStatus.Expired, + CanResend = _options.IsConfigured && customer.IsEnabled && + status is CustomerInviteStatus.Pending or CustomerInviteStatus.Failed or CustomerInviteStatus.Expired, + CanDisableLogin = _options.IsConfigured && + status is CustomerInviteStatus.Pending or CustomerInviteStatus.Active }; } diff --git a/tests/Helpdesk.Tests/Api/CustomerAuthEndpointsTests.cs b/tests/Helpdesk.Tests/Api/CustomerAuthEndpointsTests.cs index ef63adb9..82f83a7a 100644 --- a/tests/Helpdesk.Tests/Api/CustomerAuthEndpointsTests.cs +++ b/tests/Helpdesk.Tests/Api/CustomerAuthEndpointsTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Headers; +using System.Net.Http.Json; using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; @@ -39,6 +40,25 @@ public async Task ResendInvite_ReturnsServiceUnavailable_WhenAuthentikAdminIsNot Assert.Contains("Authentication:AuthentikAdmin:ApiToken", body, StringComparison.Ordinal); } + [Fact] + public async Task Invite_ForUnlinkedCustomer_WhenProviderIsUnconfigured_LeavesNoProvisionalLink() + { + var invitationService = new ThrowingInvitationService(new InvalidOperationException("The invitation service must not be called.")); + using var factory = CreateFactory(invitationService, authentikConfigured: false); + await SeedCustomerAsync(factory); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test", "HelpdeskAdmin"); + + var status = await client.GetFromJsonAsync("/api/v1/customers/customer-1/auth/status"); + var response = await client.PostAsync("/api/v1/customers/customer-1/auth/invite", null); + + Assert.NotNull(status); + Assert.False(status.CanInvite); + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Equal(0, invitationService.InvocationCount); + await AssertSeedVisibleFromNewScopeAsync(factory, 0); + } + [Fact] public async Task Invite_ReturnsBadRequest_WhenAuthentikRejectsClientRequest() { @@ -160,10 +180,12 @@ public async Task CreateUserAsync_SendsEmailAsUsername() Assert.Equal("internal", payload["type"]?.GetValue()); } - private static WebApplicationFactory CreateFactory(ICustomerInvitationService invitationService) + private static WebApplicationFactory CreateFactory(ICustomerInvitationService invitationService, bool authentikConfigured = true) => new WebApplicationFactory().WithWebHostBuilder(builder => { builder.UseSetting("Helpdesk:TestDatabaseName", $"customer-auth-{Guid.NewGuid():N}"); + builder.UseSetting("Authentication:AuthentikAdmin:BaseUrl", "https://auth.example.test/"); + builder.UseSetting("Authentication:AuthentikAdmin:ApiToken", authentikConfigured ? "test-token" : string.Empty); builder.UseIsolatedTestStorage(); builder.UseSetting(WebHostDefaults.EnvironmentKey, "Development"); builder.UseEnvironment("Development"); @@ -198,6 +220,14 @@ private static async Task SeedLinksAsync(WebApplicationFactory factory, await db.SaveChangesAsync(); } + private static async Task SeedCustomerAsync(WebApplicationFactory factory) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Customers.Add(new Customer { Id = "customer-1", Name = "Customer", Email = "customer@example.test", OrganizationId = "organization-1" }); + await db.SaveChangesAsync(); + } + private static async Task AssertSeedVisibleFromNewScopeAsync(WebApplicationFactory factory, int expectedLinkCount) { await using var scope = factory.Services.CreateAsyncScope(); diff --git a/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs b/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs index 4f00ce64..65976f1d 100644 --- a/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs +++ b/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs @@ -64,11 +64,15 @@ public void TenantMembershipPage_UsesApiResolvedTenantAccess() Assert.Contains("@page \"/tenant-administration\"", page, StringComparison.Ordinal); Assert.Contains("@attribute [Authorize(Roles = \"Tenant.Roles.Assign,HelpdeskAdmin\")]", page, StringComparison.Ordinal); Assert.Contains("api/v1/tenant-admin/organizations", page, StringComparison.Ordinal); + Assert.Contains("OnParametersSetAsync", page, StringComparison.Ordinal); + Assert.Contains("ApplyOrganizationParameterAsync", page, StringComparison.Ordinal); Assert.Contains("Roles=\"Tenant.Roles.Assign,Tenant.Settings.Manage\"", navigation, StringComparison.Ordinal); Assert.Contains("Href=\"/admin/organizations\"", navigation, StringComparison.Ordinal); var settingsPage = File.ReadAllText(Path.Combine(repoRoot, "src", "HelpDesk.NewWeb", "Components", "Pages", "Admin", "User", "TenantSettings.razor")); Assert.Contains("api/v1/tenant-admin/organizations?permission=Tenant.Settings.Manage", settingsPage, StringComparison.Ordinal); Assert.Contains("profile.HasPermission(HelpdeskPermissions.TenantSettingsManage, organization.Id)", settingsPage, StringComparison.Ordinal); + Assert.Contains("OnParametersSetAsync", settingsPage, StringComparison.Ordinal); + Assert.Contains("ApplyOrganizationParameterAsync", settingsPage, StringComparison.Ordinal); Assert.Contains("Href=\"/admin/roles\"", navigation, StringComparison.Ordinal); Assert.DoesNotContain("Href=\"/tenant-administration\"", navigation, StringComparison.Ordinal); Assert.DoesNotContain("Href=\"/tenant-settings\"", navigation, StringComparison.Ordinal); diff --git a/tests/Helpdesk.Tests/NewWeb/TenantOrganizationSelectionTests.cs b/tests/Helpdesk.Tests/NewWeb/TenantOrganizationSelectionTests.cs new file mode 100644 index 00000000..55d0fbba --- /dev/null +++ b/tests/Helpdesk.Tests/NewWeb/TenantOrganizationSelectionTests.cs @@ -0,0 +1,26 @@ +extern alias NewWeb; + +using NewWeb::HelpDesk.NewWeb.Components.Pages.Admin.User; + +namespace Helpdesk.Tests.NewWeb; + +public sealed class TenantOrganizationSelectionTests +{ + [Fact] + public void RouteChanges_RefreshTheOrganizationUsedForSubsequentTenantRequests() + { + var selection = new TenantOrganizationSelection(); + var organizations = new[] { "organization-a", "organization-b" }; + + Assert.True(selection.ApplyRouteParameter("organization-a", organizations)); + Assert.Equal("api/v1/tenant-admin/organizations/organization-a/users/", selection.Path("users/")); + + Assert.True(selection.ApplyRouteParameter(null, organizations)); + selection.Select("organization-b"); + Assert.Equal("api/v1/tenant-admin/organizations/organization-b/users/member-1/assignments", selection.Path("users/member-1/assignments")); + + Assert.True(selection.ApplyRouteParameter("organization-a", organizations)); + Assert.Equal("api/v1/tenant-admin/organizations/organization-a/users/", selection.Path("users/")); + Assert.Equal("api/v1/tenant-admin/organizations/organization-a/settings", selection.Path("settings")); + } +}