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/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 731315a4..c3357954 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. + @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. + } + + + @@ -85,6 +117,7 @@ private MudForm form = default!; private EditCustomerModel model = new(); private CustomerAuthStatusDto? authStatus; + private string? localActivationToken; protected override void OnParametersSet() { @@ -150,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 @@ -172,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) { @@ -194,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 9f0631d3..9c909e88 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/InstanceBrandingAdmin.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/InstanceBrandingAdmin.razor @@ -4,60 +4,130 @@ @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.DefaultValue); + } + 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) + { + 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() + { + 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/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 458c821b..39980c7c 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,15 +52,30 @@ + + + + + + + + @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(); 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) @@ -67,6 +86,10 @@ protected override async Task OnInitializedAsync() { + 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/Role/RoleAssignmentDetailsDialog.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleAssignmentDetailsDialog.razor new file mode 100644 index 00000000..0f76d447 --- /dev/null +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleAssignmentDetailsDialog.razor @@ -0,0 +1,43 @@ +@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 if (!string.IsNullOrWhiteSpace(error)) { @error Retry } + 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 string? error; + private HttpClient Api => HttpClientFactory.CreateClient("HelpdeskApi"); + 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 3cf31c59..79dc6453 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor @@ -2,7 +2,10 @@ @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 +@inject AuthenticationStateProvider AuthenticationState Roles & Permissions @@ -87,12 +90,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 +112,11 @@ @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 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"); private List roles = []; @@ -131,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() { @@ -206,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; } @@ -239,6 +243,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 +259,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..d5115009 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 @@ -21,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. @@ -97,16 +101,22 @@ @code { + [Parameter] public bool IsCanonical { 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(); 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; @@ -121,8 +131,41 @@ } organizations = await response.Content.ReadFromJsonAsync>() ?? new(); - selectedOrganizationId = organizations.FirstOrDefault()?.Id; - if (!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); + } + else if (!string.IsNullOrWhiteSpace(selectedOrganizationId)) { await LoadMembers(); } @@ -130,7 +173,7 @@ private async Task SelectOrganization(string organizationId) { - selectedOrganizationId = organizationId; + organizationSelection.Select(organizationId); invitation = null; await LoadMembers(); } @@ -151,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) { @@ -181,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."; @@ -243,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 3094c116..1d8a888f 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantSettings.razor @@ -5,18 +5,22 @@ @inject IHttpClientFactory HttpClientFactory @inject AuthenticationStateProvider AuthenticationState @inject ISnackbar Snackbar +@inject NavigationManager NavigationManager Tenant settings
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) { @@ -27,24 +31,82 @@
@code { + [Parameter] public bool IsCanonical { get; set; } + [Parameter, SupplyParameterFromQuery(Name = "organizationId")] public string? OrganizationId { get; set; } 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(); - if (organizations.Count > 0) await SelectAsync(organizations[0].Id); + 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(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; } } @@ -55,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.NewWeb/Models/InstanceBrandingAdministrationResponse.cs b/src/HelpDesk.NewWeb/Models/InstanceBrandingAdministrationResponse.cs index 34f20e30..e32591b2 100644 --- a/src/HelpDesk.NewWeb/Models/InstanceBrandingAdministrationResponse.cs +++ b/src/HelpDesk.NewWeb/Models/InstanceBrandingAdministrationResponse.cs @@ -13,6 +13,14 @@ 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 string DefaultValue { 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..184133e0 100644 --- a/src/Helpdesk.API/Endpoints/Customers/CustomerAuthEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Customers/CustomerAuthEndpoints.cs @@ -1,6 +1,13 @@ 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; +using Microsoft.Extensions.Options; namespace Helpdesk.API.Endpoints.Customers; @@ -15,35 +22,60 @@ public static void MapCustomerAuthEndpoints(this IEndpointRouteBuilder app) group.MapGet("/status", async ( [FromRoute] string customerId, [FromServices] ICustomerInvitationService invitationService, + [FromServices] HelpdeskDbContext db, + [FromServices] UserManager users, + [FromServices] IOptions authentikOptions, CancellationToken ct) => - Results.Ok(await invitationService.GetStatusAsync(customerId, 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 RunCustomerAuthActionAsync(() => invitationService.InviteAsync(customerId, ResolveUserId(user), 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 RunCustomerAuthActionAsync(() => invitationService.ResendInviteAsync(customerId, ResolveUserId(user), 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 RunCustomerAuthActionAsync(() => invitationService.DisableLoginAsync(customerId, ResolveUserId(user), 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 RunCustomerAuthActionAsync(() => invitationService.SyncAuthentikAsync(customerId, ct))); + await RunExternalCustomerAuthActionAsync(customerId, db, authentikOptions.Value, () => 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") @@ -52,35 +84,46 @@ public static void MapCustomerAuthEndpoints(this IEndpointRouteBuilder app) adminGroup.MapGet("/auth-status", async ( [FromRoute] string customerId, [FromServices] ICustomerInvitationService invitationService, + [FromServices] HelpdeskDbContext db, + [FromServices] UserManager users, + [FromServices] IOptions authentikOptions, CancellationToken ct) => - Results.Ok(await invitationService.GetStatusAsync(customerId, 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 RunCustomerAuthActionAsync(() => invitationService.InviteAsync(customerId, ResolveUserId(user), 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 RunCustomerAuthActionAsync(() => invitationService.ResendInviteAsync(customerId, ResolveUserId(user), 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 RunCustomerAuthActionAsync(() => invitationService.DisableLoginAsync(customerId, ResolveUserId(user), 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 RunCustomerAuthActionAsync(() => invitationService.SyncAuthentikAsync(customerId, ct))); + await RunExternalCustomerAuthActionAsync(customerId, db, authentikOptions.Value, () => invitationService.SyncAuthentikAsync(customerId, ct), ct)); } private static async Task RunCustomerAuthActionAsync(Func> action) @@ -115,6 +158,136 @@ private static async Task RunCustomerAuthActionAsync(Func> a } } + private static async Task RunExternalCustomerAuthActionAsync( + string customerId, + HelpdeskDbContext db, + AuthentikOptions authentikOptions, + 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." }); + } + if (!authentikOptions.IsConfigured) + { + return Results.Problem( + detail: authentikOptions.ConfigurationError, + statusCode: StatusCodes.Status503ServiceUnavailable, + title: "Customer invitation is not configured"); + } + return await RunCustomerAuthActionAsync(action); + } + + private static async Task GetStatusAsync( + string customerId, + ICustomerInvitationService invitationService, + HelpdeskDbContext db, + UserManager users, + AuthentikOptions authentikOptions, + 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, authentikOptions.IsConfigured); + } + + 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, bool canInvite) => new() + { + CustomerId = customerId, + InviteStatus = CustomerInviteStatus.NotInvited, + StatusText = "No linked login", + 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); + 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..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))); } @@ -1467,7 +1468,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 +1476,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/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 152ae488..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 @@ -361,6 +372,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, @@ -373,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/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/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/Api/CustomerAuthEndpointsTests.cs b/tests/Helpdesk.Tests/Api/CustomerAuthEndpointsTests.cs index 69f750f6..82f83a7a 100644 --- a/tests/Helpdesk.Tests/Api/CustomerAuthEndpointsTests.cs +++ b/tests/Helpdesk.Tests/Api/CustomerAuthEndpointsTests.cs @@ -1,15 +1,19 @@ 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; 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; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -36,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() { @@ -76,6 +99,26 @@ 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) + { + 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"); + + 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); + Assert.Equal(0, invitationService.InvocationCount); + await AssertSeedVisibleFromNewScopeAsync(factory, linkCount); + } + [Fact] public async Task CreateUserAsync_ThrowsAuthentikRequestException_WithSanitizedResponseBody() { @@ -137,9 +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"); @@ -157,22 +203,61 @@ 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 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(); + 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( diff --git a/tests/Helpdesk.Tests/Infrastructure/Branding/InstanceBrandingProviderTests.cs b/tests/Helpdesk.Tests/Infrastructure/Branding/InstanceBrandingProviderTests.cs index 00163e16..6b514391 100644 --- a/tests/Helpdesk.Tests/Infrastructure/Branding/InstanceBrandingProviderTests.cs +++ b/tests/Helpdesk.Tests/Infrastructure/Branding/InstanceBrandingProviderTests.cs @@ -56,6 +56,26 @@ 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); + Assert.Equal("RatelDesk", Assert.Single(administration.Fields, x => x.Name == nameof(InstanceBranding.ApplicationName)).DefaultValue); + } + 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..dc0549f8 100644 --- a/tests/Helpdesk.Tests/NewWeb/InstanceBrandingAdministrationResponseTests.cs +++ b/tests/Helpdesk.Tests/NewWeb/InstanceBrandingAdministrationResponseTests.cs @@ -18,6 +18,9 @@ public void Deserialization_AcceptsNumericAndStringValueSources(string source) "fields": [ { "name": "ApplicationName", + "persistedValue": "Custom Desk", + "effectiveValue": "Deployment Desk", + "defaultValue": "RatelDesk", "source": {{source}}, "isAdminEditable": false } @@ -31,6 +34,9 @@ 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("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 b9c66fc7..65976f1d 100644 --- a/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs +++ b/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs @@ -64,12 +64,23 @@ 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("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); + + 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); } } 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")); + } +}