diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/IResilientHttpRequest.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/IResilientHttpRequest.cs index ea461b2d2c..e61844102a 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/IResilientHttpRequest.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/IResilientHttpRequest.cs @@ -1,4 +1,5 @@ -using System.Net.Http; +using System.Collections.Generic; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Volo.Abp; @@ -18,6 +19,7 @@ Task HttpAsync( string? authToken = null, (string username, string password)? basicAuth = null, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + IReadOnlyDictionary? extraHeaders = null, CancellationToken cancellationToken = default); /// diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/ResilientHttpRequest.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/ResilientHttpRequest.cs index 109c0a816d..80814ccca6 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/ResilientHttpRequest.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/ResilientHttpRequest.cs @@ -1,6 +1,7 @@ using Polly; using Polly.Retry; using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; @@ -114,10 +115,11 @@ public async Task HttpAsync( string? authToken = null, (string username, string password)? basicAuth = null, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + IReadOnlyDictionary? extraHeaders = null, CancellationToken cancellationToken = default) { return await SendWithClientAsync( - _httpClient, httpVerb, resource, body, authToken, basicAuth, completionOption, cancellationToken); + _httpClient, httpVerb, resource, body, authToken, basicAuth, completionOption, extraHeaders, cancellationToken); } @@ -138,7 +140,7 @@ public Task HttpAsyncSecured( EnsureMutualTlsClient(certPath, certPassword); return SendWithClientAsync( - _mtlsClient!, httpVerb, resource, body, authToken, basicAuth, HttpCompletionOption.ResponseContentRead, cancellationToken); + _mtlsClient!, httpVerb, resource, body, authToken, basicAuth, HttpCompletionOption.ResponseContentRead, null, cancellationToken); } @@ -193,6 +195,7 @@ private async Task SendWithClientAsync( string? authToken, (string username, string password)? basicAuth, HttpCompletionOption completionOption, + IReadOnlyDictionary? extraHeaders, CancellationToken cancellationToken) { // Build final URL @@ -208,7 +211,7 @@ private async Task SendWithClientAsync( return await _pipeline.ExecuteAsync(async ct => { using var requestMessage = - BuildRequestMessage(httpVerb, fullUrl, body, authToken, basicAuth); + BuildRequestMessage(httpVerb, fullUrl, body, authToken, basicAuth, extraHeaders); return await client.SendAsync(requestMessage, completionOption, ct) .ConfigureAwait(false); @@ -226,7 +229,8 @@ private static HttpRequestMessage BuildRequestMessage( Uri fullUrl, object? body, string? authToken, - (string username, string password)? basicAuth) + (string username, string password)? basicAuth, + IReadOnlyDictionary? extraHeaders = null) { var requestMessage = new HttpRequestMessage(httpVerb, fullUrl); requestMessage.Headers.Accept.Clear(); @@ -248,6 +252,16 @@ private static HttpRequestMessage BuildRequestMessage( requestMessage.Headers.Add(AuthorizationHeader, $"Basic {encoded}"); } + // Additional headers (e.g. API keys) not covered by the auth-token/basic-auth cases above + if (extraHeaders != null) + { + foreach (var header in extraHeaders) + { + requestMessage.Headers.Remove(header.Key); + requestMessage.Headers.Add(header.Key, header.Value); + } + } + // Body if (body != null) { diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/PostTenantCreation/IPostTenantCreationStep.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/PostTenantCreation/IPostTenantCreationStep.cs new file mode 100644 index 0000000000..05144e1db6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/PostTenantCreation/IPostTenantCreationStep.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading.Tasks; + +namespace Unity.Modules.Shared.PostTenantCreation; + +/// +/// A single step in the post-tenant-creation sequence, run as part of +/// PostTenantCreationSequenceJob after a new tenant is created. Implement this in any +/// module and register it as an to +/// have it picked up automatically - no changes to the sequencing job are needed. +/// +public interface IPostTenantCreationStep +{ + /// Determines execution order relative to other steps (ascending). + int Order { get; } + + /// Short, human-readable name used in logging. + string StepName { get; } + + /// + /// When true, a failure in this step is logged and the sequence continues to the next step. + /// When false, a failure stops the sequence - later steps do not run. + /// + bool ContinueOnError { get; } + + /// + /// Validated before runs. When false, the step is skipped (logged, + /// not treated as a failure) and the sequence moves on to the next step. Defaults to true - + /// override to check preconditions such as required configuration being present. + /// + Task CanExecuteAsync(Guid tenantId) => Task.FromResult(true); + + Task ExecuteAsync(Guid tenantId); +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs index 10cfd4acc0..24c0f6fcee 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs @@ -11,7 +11,7 @@ public interface IOnboardingRequestAppService : IApplicationService { Task> GetListAsync(OnboardingListRequestDto input); Task GetAsync(Guid id); - Task ValidateAsync(Guid id, string? tenantNameFieldKey, string? superUsersFieldKey, string? branchFieldKey = null, string? featuresFieldKey = null, string? ministryFieldKey = null, string? programAreaFieldKey = null); + Task ValidateAsync(Guid id, string? tenantNameFieldKey, string? superUsersFieldKey, string? branchFieldKey = null, string? featuresFieldKey = null, string? ministryFieldKey = null, string? programAreaFieldKey = null, string? displayNameFieldKey = null, string? divisionFieldKey = null); Task CreateTenantAsync(Guid id, CreateTenantInputDto? input); Task GetColumnSchemaAsync(string? category = null); Task> GetAvailableCategoriesAsync(); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/Metabase/MetabaseSettings.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/Metabase/MetabaseSettings.cs new file mode 100644 index 0000000000..3b59d51480 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/Metabase/MetabaseSettings.cs @@ -0,0 +1,11 @@ +namespace Unity.TenantManagement.Metabase; + +public static class MetabaseSettings +{ + /// + /// Comma-separated list of user emails to add to a tenant's Metabase group. + /// Stored Global (the running default applied to new tenants) and per-tenant, "T" provider + /// (the resolved snapshot captured when that tenant was created). + /// + public const string UserEmails = "Metabase.UserEmails"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs index 2a95c6a0af..20338724e1 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs @@ -15,19 +15,32 @@ public class OnboardingColumnSchemaDto { public List Columns { get; set; } = []; public string? TenantNameFieldKey { get; set; } + public string? DisplayNameFieldKey { get; set; } public string? SuperUsersFieldKey { get; set; } public string? BranchFieldKey { get; set; } public string? FeaturesFieldKey { get; set; } public string? MinistryFieldKey { get; set; } + public string? DivisionFieldKey { get; set; } public string? ProgramAreaFieldKey { get; set; } } public class CreateTenantInputDto { public string? TenantNameFieldKey { get; set; } + public string? DisplayNameFieldKey { get; set; } public string? SuperUsersFieldKey { get; set; } public string? BranchFieldKey { get; set; } public string? FeaturesFieldKey { get; set; } public string? MinistryFieldKey { get; set; } + public string? DivisionFieldKey { get; set; } public string? ProgramAreaFieldKey { get; set; } + + /// Comma-separated emails checked in the Metabase tab - sent to TenantCreateDto as-is. + public string? MetabaseUserEmails { get; set; } + + /// Comma-separated subset of newly-added Metabase emails to persist as the new Global default. + public string? MetabaseNewDefaultUserEmails { get; set; } + + /// Comma-separated default Metabase emails explicitly removed - deleted from the Global default. + public string? MetabaseRemovedDefaultUserEmails { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs index c251999c11..4a773b44b2 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs @@ -9,6 +9,7 @@ public class OnboardingRequestDto public Guid Id { get; set; } public string SubmissionNumber { get; set; } = string.Empty; public string TenantName { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; public string TenantDescription { get; set; } = string.Empty; public string ProgramAreaName { get; set; } = string.Empty; public string ProgramAreaDescription { get; set; } = string.Empty; @@ -18,6 +19,7 @@ public class OnboardingRequestDto public string ExecutiveDirector { get; set; } = string.Empty; public string Branch { get; set; } = string.Empty; public string Ministry { get; set; } = string.Empty; + public string Division { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; public string Category { get; set; } = string.Empty; public DateTime? SubmissionDate { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs index d9b7998e85..a751daa659 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs @@ -6,4 +6,6 @@ public class TenantCreateDto : TenantCreateOrUpdateDtoBase public string UserIdentifier { get; set; } = string.Empty; /// Comma-separated ABP feature keys to enable on the new tenant (e.g. "Unity.Payments,Unity.Reporting"). public string? FeatureKeys { get; set; } + /// Comma-separated user emails to add to this tenant's Metabase group. + public string? MetabaseUserEmails { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateOrUpdateDtoBase.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateOrUpdateDtoBase.cs index 0b89d2ddf3..33495c44ab 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateOrUpdateDtoBase.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateOrUpdateDtoBase.cs @@ -12,6 +12,7 @@ public abstract class TenantCreateOrUpdateDtoBase : ExtensibleObject [Display(Name = "TenantName")] public string Name { get; set; } + public string DisplayName { get; set; } = string.Empty; public string Division { get; set; } = string.Empty; public string Branch { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs index 5dc338b00c..ef8d496847 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs @@ -7,6 +7,7 @@ namespace Unity.TenantManagement; public class TenantDto : ExtensibleEntityDto, IHasConcurrencyStamp { public string Name { get; set; } + public string DisplayName { get; set; } = string.Empty; public string Division { get; set; } = string.Empty; public string Branch { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Metabase/MetabaseSettingDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Metabase/MetabaseSettingDefinitionProvider.cs new file mode 100644 index 0000000000..1f3874faed --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Metabase/MetabaseSettingDefinitionProvider.cs @@ -0,0 +1,20 @@ +using Volo.Abp.Settings; +using Volo.Abp.SettingManagement; + +namespace Unity.TenantManagement.Metabase; + +public class MetabaseSettingDefinitionProvider : SettingDefinitionProvider +{ + public override void Define(ISettingDefinitionContext context) + { + context.Add( + new SettingDefinition( + MetabaseSettings.UserEmails, + defaultValue: null, + isVisibleToClients: false, + isInherited: false, + isEncrypted: false) + .WithProviders(GlobalSettingValueProvider.ProviderName, TenantSettingValueProvider.ProviderName) + ); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettingDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettingDefinitionProvider.cs index c0000d45fc..d9affde02e 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettingDefinitionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettingDefinitionProvider.cs @@ -13,10 +13,12 @@ public override void Define(ISettingDefinitionContext context) { context.Add( OnboardingDef(OnboardingColumnConfigSettings.TenantNameFieldKey), + OnboardingDef(OnboardingColumnConfigSettings.DisplayNameFieldKey), OnboardingDef(OnboardingColumnConfigSettings.SuperUsersFieldKey), OnboardingDef(OnboardingColumnConfigSettings.BranchFieldKey), OnboardingDef(OnboardingColumnConfigSettings.FeaturesFieldKey), OnboardingDef(OnboardingColumnConfigSettings.MinistryFieldKey), + OnboardingDef(OnboardingColumnConfigSettings.DivisionFieldKey), OnboardingDef(OnboardingColumnConfigSettings.ProgramAreaFieldKey) ); } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettings.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettings.cs index a2041e1c22..49bc404a06 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettings.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettings.cs @@ -3,9 +3,11 @@ namespace Unity.TenantManagement.Onboarding; public static class OnboardingColumnConfigSettings { public const string TenantNameFieldKey = "Onboarding.ColumnConfig.TenantNameFieldKey"; + public const string DisplayNameFieldKey = "Onboarding.ColumnConfig.DisplayNameFieldKey"; public const string SuperUsersFieldKey = "Onboarding.ColumnConfig.SuperUsersFieldKey"; public const string BranchFieldKey = "Onboarding.ColumnConfig.BranchFieldKey"; public const string FeaturesFieldKey = "Onboarding.ColumnConfig.FeaturesFieldKey"; public const string MinistryFieldKey = "Onboarding.ColumnConfig.MinistryFieldKey"; + public const string DivisionFieldKey = "Onboarding.ColumnConfig.DivisionFieldKey"; public const string ProgramAreaFieldKey = "Onboarding.ColumnConfig.ProgramAreaFieldKey"; } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs index 7b60631ad3..6c440ef31a 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs @@ -10,6 +10,7 @@ using Unity.Flex.WorksheetInstances; using Unity.Modules.Shared.Correlation; using Unity.Modules.Shared.Permissions; +using Unity.TenantManagement.Metabase; using Unity.TenantManagement.Onboarding; using Unity.TenantManagement.Validation; using Volo.Abp; @@ -256,13 +257,13 @@ public virtual async Task> GetAvailableCategoriesAsync() return categories; } - public virtual async Task ValidateAsync(Guid id, string? tenantNameFieldKey, string? superUsersFieldKey, string? branchFieldKey = null, string? featuresFieldKey = null, string? ministryFieldKey = null, string? programAreaFieldKey = null) + public virtual async Task ValidateAsync(Guid id, string? tenantNameFieldKey, string? superUsersFieldKey, string? branchFieldKey = null, string? featuresFieldKey = null, string? ministryFieldKey = null, string? programAreaFieldKey = null, string? displayNameFieldKey = null, string? divisionFieldKey = null) { var request = await GetAsync(id); if (request == null) return new OnboardingValidationResultDto { IsValid = false, Issues = ["Onboarding request not found."] }; - await ResolveFieldMappings(request, tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey); + await ResolveFieldMappings(request, tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey, displayNameFieldKey, divisionFieldKey); var issues = await RunValidationStepsAsync(request); @@ -274,10 +275,10 @@ public virtual async Task CreateTenantAsync(Guid id, CreateTenantInputDto? input var request = await GetAsync(id) ?? throw new UserFriendlyException("Onboarding request not found."); - await ResolveFieldMappings(request, input?.TenantNameFieldKey, input?.SuperUsersFieldKey, input?.BranchFieldKey, input?.FeaturesFieldKey, input?.MinistryFieldKey, input?.ProgramAreaFieldKey); + await ResolveFieldMappings(request, input?.TenantNameFieldKey, input?.SuperUsersFieldKey, input?.BranchFieldKey, input?.FeaturesFieldKey, input?.MinistryFieldKey, input?.ProgramAreaFieldKey, input?.DisplayNameFieldKey, input?.DivisionFieldKey); if (input != null) - await SaveFieldMappingAsync(input.TenantNameFieldKey, input.SuperUsersFieldKey, input.BranchFieldKey, input.FeaturesFieldKey, input.MinistryFieldKey, input.ProgramAreaFieldKey); + await SaveFieldMappingAsync(input.TenantNameFieldKey, input.SuperUsersFieldKey, input.BranchFieldKey, input.FeaturesFieldKey, input.MinistryFieldKey, input.ProgramAreaFieldKey, input.DisplayNameFieldKey, input.DivisionFieldKey); // Re-validate server-side even if the client already called ValidateAsync — the client // cannot be trusted to have done so, and skipping this would let a duplicate tenant name @@ -307,10 +308,13 @@ public virtual async Task CreateTenantAsync(Guid id, CreateTenantInputDto? input var tenantDto = await TenantAppService.CreateAsync(new TenantCreateDto { Name = request.TenantName, + DisplayName = request.DisplayName, Branch = request.Branch, + Division = request.Division, Description = request.TenantDescription, UserIdentifier = userGuids[0], - FeatureKeys = featureKeys.Count > 0 ? string.Join(',', featureKeys) : null + FeatureKeys = featureKeys.Count > 0 ? string.Join(',', featureKeys) : null, + MetabaseUserEmails = input?.MetabaseUserEmails }); foreach (var userGuid in userGuids.Skip(1)) @@ -322,10 +326,29 @@ await TenantAppService.AssignManagerAsync(new TenantAssignManagerDto }); } + if (!string.IsNullOrWhiteSpace(input?.MetabaseNewDefaultUserEmails) || !string.IsNullOrWhiteSpace(input?.MetabaseRemovedDefaultUserEmails)) + await UpdateMetabaseDefaultUserEmailsAsync(input.MetabaseNewDefaultUserEmails, input.MetabaseRemovedDefaultUserEmails); + if (ApplicationProvider != null) await ApplicationProvider.CloseApplicationAsync(id); } + private async Task UpdateMetabaseDefaultUserEmailsAsync(string? newEmailsCsv, string? removedEmailsCsv) + { + var removed = SplitEmails(removedEmailsCsv); + var updated = SplitEmails(await _settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails)) + .Concat(SplitEmails(newEmailsCsv)) + .Where(email => !removed.Contains(email, StringComparer.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + await _settingManager.SetGlobalAsync(MetabaseSettings.UserEmails, string.Join(",", updated)); + } + + private static List SplitEmails(string? emailsCsv) => + (emailsCsv ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + private async Task> RunValidationStepsAsync(OnboardingRequestDto request) { var issues = new List(); @@ -341,7 +364,8 @@ private async Task> RunValidationStepsAsync(OnboardingRequestDto re private async Task ResolveFieldMappings(OnboardingRequestDto request, string? tenantNameKey = null, string? superUsersKey = null, string? branchKey = null, string? featuresKey = null, - string? ministryKey = null, string? programAreaKey = null) + string? ministryKey = null, string? programAreaKey = null, + string? displayNameKey = null, string? divisionKey = null) { var saved = await ReadTenantMappingAsync(); tenantNameKey ??= saved.TenantNameFieldKey; @@ -350,9 +374,13 @@ private async Task ResolveFieldMappings(OnboardingRequestDto request, featuresKey ??= saved.FeaturesFieldKey; ministryKey ??= saved.MinistryFieldKey; programAreaKey ??= saved.ProgramAreaFieldKey; + displayNameKey ??= saved.DisplayNameFieldKey; + divisionKey ??= saved.DivisionFieldKey; if (!string.IsNullOrEmpty(tenantNameKey) && request.Fields.TryGetValue(tenantNameKey, out var tenantNameVal) && tenantNameVal is not null) request.TenantName = tenantNameVal.ToString()!; + if (!string.IsNullOrEmpty(displayNameKey) && request.Fields.TryGetValue(displayNameKey, out var displayNameVal) && displayNameVal is not null) + request.DisplayName = displayNameVal.ToString()!; if (!string.IsNullOrEmpty(superUsersKey) && request.Fields.TryGetValue(superUsersKey, out var superUsersVal) && superUsersVal is not null) request.SuperUsers = superUsersVal.ToString()!; if (!string.IsNullOrEmpty(branchKey) && request.Fields.TryGetValue(branchKey, out var branchVal) && branchVal is not null) @@ -361,51 +389,61 @@ private async Task ResolveFieldMappings(OnboardingRequestDto request, request.Features = featuresVal.ToString()!; if (!string.IsNullOrEmpty(ministryKey) && request.Fields.TryGetValue(ministryKey, out var ministryVal) && ministryVal is not null) request.Ministry = ministryVal.ToString()!; + if (!string.IsNullOrEmpty(divisionKey) && request.Fields.TryGetValue(divisionKey, out var divisionVal) && divisionVal is not null) + request.Division = divisionVal.ToString()!; if (!string.IsNullOrEmpty(programAreaKey) && request.Fields.TryGetValue(programAreaKey, out var programAreaVal) && programAreaVal is not null) request.ProgramAreaName = programAreaVal.ToString()!; } - private async Task SaveFieldMappingAsync(string? tenantNameKey, string? superUsersKey, string? branchKey, string? featuresKey, string? ministryKey, string? programAreaKey) + private async Task SaveFieldMappingAsync(string? tenantNameKey, string? superUsersKey, string? branchKey, string? featuresKey, string? ministryKey, string? programAreaKey, string? displayNameKey, string? divisionKey) { var userId = CurrentUser.Id?.ToString(); if (string.IsNullOrEmpty(userId)) return; await _settingManager.SetAsync(OnboardingColumnConfigSettings.TenantNameFieldKey, tenantNameKey, UserProvider, userId); + await _settingManager.SetAsync(OnboardingColumnConfigSettings.DisplayNameFieldKey, displayNameKey, UserProvider, userId); await _settingManager.SetAsync(OnboardingColumnConfigSettings.SuperUsersFieldKey, superUsersKey, UserProvider, userId); await _settingManager.SetAsync(OnboardingColumnConfigSettings.BranchFieldKey, branchKey, UserProvider, userId); await _settingManager.SetAsync(OnboardingColumnConfigSettings.FeaturesFieldKey, featuresKey, UserProvider, userId); await _settingManager.SetAsync(OnboardingColumnConfigSettings.MinistryFieldKey, ministryKey, UserProvider, userId); + await _settingManager.SetAsync(OnboardingColumnConfigSettings.DivisionFieldKey, divisionKey, UserProvider, userId); await _settingManager.SetAsync(OnboardingColumnConfigSettings.ProgramAreaFieldKey, programAreaKey, UserProvider, userId); } private async Task ReadTenantMappingAsync() { var userId = CurrentUser.Id?.ToString(); - string? tenantNameKey = null, superUsersKey = null, branchKey = null, featuresKey = null, ministryKey = null, programAreaKey = null; + string? tenantNameKey = null, displayNameKey = null, superUsersKey = null, branchKey = null, featuresKey = null, ministryKey = null, divisionKey = null, programAreaKey = null; if (!string.IsNullOrEmpty(userId)) { tenantNameKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.TenantNameFieldKey, UserProvider, userId); + displayNameKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.DisplayNameFieldKey, UserProvider, userId); superUsersKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.SuperUsersFieldKey, UserProvider, userId); branchKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.BranchFieldKey, UserProvider, userId); featuresKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.FeaturesFieldKey, UserProvider, userId); ministryKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.MinistryFieldKey, UserProvider, userId); + divisionKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.DivisionFieldKey, UserProvider, userId); programAreaKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.ProgramAreaFieldKey, UserProvider, userId); } tenantNameKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.TenantNameFieldKey); + displayNameKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.DisplayNameFieldKey); superUsersKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.SuperUsersFieldKey); branchKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.BranchFieldKey); featuresKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.FeaturesFieldKey); ministryKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.MinistryFieldKey); + divisionKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.DivisionFieldKey); programAreaKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.ProgramAreaFieldKey); return new OnboardingColumnSchemaDto { TenantNameFieldKey = tenantNameKey, + DisplayNameFieldKey = displayNameKey, SuperUsersFieldKey = superUsersKey, BranchFieldKey = branchKey, FeaturesFieldKey = featuresKey, MinistryFieldKey = ministryKey, + DivisionFieldKey = divisionKey, ProgramAreaFieldKey = programAreaKey }; } @@ -526,6 +564,7 @@ private static OnboardingRequestDto MapToDto( switch (fv.Key.ToLowerInvariant().Replace("-", "").Replace("_", "").Replace(" ", "")) { + case "displayname": dto.DisplayName = fv.Value; break; case "tenantdescription": case "description": dto.TenantDescription = fv.Value; break; case "programareaname": case "programarea": dto.ProgramAreaName = fv.Value; break; case "programareadescription": dto.ProgramAreaDescription = fv.Value; break; @@ -534,6 +573,7 @@ private static OnboardingRequestDto MapToDto( case "executivedirector": dto.ExecutiveDirector = fv.Value; break; case "branch": dto.Branch = fv.Value; break; case "ministry": dto.Ministry = fv.Value; break; + case "division": dto.Division = fv.Value; break; } } } @@ -545,6 +585,8 @@ private static OnboardingRequestDto MapToDto( if (!string.IsNullOrEmpty(mapping.TenantNameFieldKey) && dto.Fields.TryGetValue(mapping.TenantNameFieldKey, out var tn) && tn != null) dto.TenantName = tn.ToString()!; + if (!string.IsNullOrEmpty(mapping.DisplayNameFieldKey) && dto.Fields.TryGetValue(mapping.DisplayNameFieldKey, out var dn) && dn != null) + dto.DisplayName = dn.ToString()!; if (!string.IsNullOrEmpty(mapping.SuperUsersFieldKey) && dto.Fields.TryGetValue(mapping.SuperUsersFieldKey, out var su) && su != null) dto.SuperUsers = su.ToString()!; if (!string.IsNullOrEmpty(mapping.BranchFieldKey) && dto.Fields.TryGetValue(mapping.BranchFieldKey, out var br) && br != null) @@ -553,6 +595,8 @@ private static OnboardingRequestDto MapToDto( dto.Features = ft.ToString()!; if (!string.IsNullOrEmpty(mapping.MinistryFieldKey) && dto.Fields.TryGetValue(mapping.MinistryFieldKey, out var mn) && mn != null) dto.Ministry = mn.ToString()!; + if (!string.IsNullOrEmpty(mapping.DivisionFieldKey) && dto.Fields.TryGetValue(mapping.DivisionFieldKey, out var dv) && dv != null) + dto.Division = dv.ToString()!; if (!string.IsNullOrEmpty(mapping.ProgramAreaFieldKey) && dto.Fields.TryGetValue(mapping.ProgramAreaFieldKey, out var pa) && pa != null) dto.ProgramAreaName = pa.ToString()!; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs index 3e5bed3287..bb645051b7 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs @@ -34,6 +34,7 @@ public class TenantAppService( { private IIdentityUserRepository IdentityUserRepository => LazyServiceProvider.LazyGetRequiredService(); + private const string ExtraPropDisplayName = "DisplayName"; private const string ExtraPropDivision = "Division"; private const string ExtraPropBranch = "Branch"; private const string ExtraPropDescription = "Description"; @@ -59,7 +60,7 @@ public virtual async Task> GetListAsync(GetTenantsInpu var extraPropertySortFields = new HashSet(StringComparer.OrdinalIgnoreCase) { - ExtraPropDivision, ExtraPropBranch, ExtraPropDescription, ExtraPropCasClientCode + ExtraPropDisplayName, ExtraPropDivision, ExtraPropBranch, ExtraPropDescription, ExtraPropCasClientCode }; var dbSortFields = new HashSet(StringComparer.OrdinalIgnoreCase) @@ -90,10 +91,13 @@ public virtual async Task> GetListAsync(GetTenantsInpu ); } - // In-memory path: needed when filtering on ExtraProperties or sorting on ExtraProperties - // Keep native name filtering in SQL and only layer ExtraProperties matching on top. + // In-memory path: needed when filtering on ExtraProperties or sorting on ExtraProperties. + // Fetch unfiltered here - the underlying repository's filter only matches against Name, so + // passing it through would exclude an ExtraProperty-only match (e.g. filtering by Division + // text that isn't also in the tenant's Name) before the OR-based filter below ever runs. + // Name + ExtraProperties matching is applied together, in memory, further down. var dbSorting = dbSortFields.Contains(sortField) ? input.Sorting : nameof(Tenant.Name); - var filteredTenants = await tenantRepository.GetListAsync(dbSorting, int.MaxValue, 0, input.Filter); + var filteredTenants = await tenantRepository.GetListAsync(dbSorting, int.MaxValue, 0, filter: null); IEnumerable result = filteredTenants; @@ -103,6 +107,7 @@ public virtual async Task> GetListAsync(GetTenantsInpu var filter = input.Filter.Trim(); result = result.Where(t => (t.Name != null && t.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)) || + MatchesExtraProperty(t, ExtraPropDisplayName, filter) || MatchesExtraProperty(t, ExtraPropDivision, filter) || MatchesExtraProperty(t, ExtraPropBranch, filter) || MatchesExtraProperty(t, ExtraPropDescription, filter) || @@ -169,6 +174,7 @@ public virtual async Task CreateAsync(TenantCreateDto input) // Set ExtraProperties from input tenant.ExtraProperties[UnityTenantManagementConsts.TenantLicencePlateExtraPropertyKey] = credentials.DbName; + tenant.ExtraProperties[ExtraPropDisplayName] = input.DisplayName ?? string.Empty; tenant.ExtraProperties[ExtraPropDivision] = input.Division ?? string.Empty; tenant.ExtraProperties[ExtraPropBranch] = input.Branch ?? string.Empty; tenant.ExtraProperties[ExtraPropDescription] = input.Description ?? string.Empty; @@ -188,7 +194,8 @@ await localEventBus.PublishAsync( Properties = { { "UserIdentifier", input.UserIdentifier }, - { "FeatureKeys", input.FeatureKeys ?? string.Empty } + { "FeatureKeys", input.FeatureKeys ?? string.Empty }, + { "MetabaseUserEmails", input.MetabaseUserEmails ?? string.Empty } } } ); @@ -206,6 +213,7 @@ public virtual async Task UpdateAsync(Guid id, TenantUpdateDto input) tenant.SetConcurrencyStampIfNotNull(input.ConcurrencyStamp); // Update ExtraProperties from input + tenant.ExtraProperties[ExtraPropDisplayName] = input.DisplayName ?? string.Empty; tenant.ExtraProperties[ExtraPropDivision] = input.Division ?? string.Empty; tenant.ExtraProperties[ExtraPropBranch] = input.Branch ?? string.Empty; tenant.ExtraProperties[ExtraPropDescription] = input.Description ?? string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs index c0af541474..d42c060cd1 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs @@ -19,6 +19,7 @@ public override void Map(Tenant source, TenantDto destination) destination.Id = source.Id; destination.Name = source.Name; destination.ConcurrencyStamp = source.ConcurrencyStamp; + destination.DisplayName = GetExtraProperty(source, "DisplayName") ?? string.Empty; destination.CasClientCode = GetExtraProperty(source, "CasClientCode") ?? string.Empty; destination.LicencePlate = GetExtraProperty(source, "LicencePlate") ?? string.Empty; destination.Division = GetExtraProperty(source, "Division") ?? string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs index e00ada72a8..8443ff3990 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs @@ -40,10 +40,12 @@ public virtual Task ValidateAsync( [FromQuery] string? branchFieldKey = null, [FromQuery] string? featuresFieldKey = null, [FromQuery] string? ministryFieldKey = null, - [FromQuery] string? programAreaFieldKey = null) + [FromQuery] string? programAreaFieldKey = null, + [FromQuery] string? displayNameFieldKey = null, + [FromQuery] string? divisionFieldKey = null) { if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->ValidateAsync: ModelState Invalid"); - return OnboardingRequestAppService.ValidateAsync(id, tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey); + return OnboardingRequestAppService.ValidateAsync(id, tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey, displayNameFieldKey, divisionFieldKey); } [HttpPost("{id}/create-tenant")] diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml index f396127cdf..c0f0c0d763 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml @@ -15,8 +15,46 @@ + + +
+
+ +
@L["CreateTenantModal:Validating"] diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml.cs index bf3d068f36..5ed6f9b8a8 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml.cs @@ -1,14 +1,18 @@ #nullable enable using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Unity.Modules.Shared.Permissions; +using Unity.TenantManagement.Metabase; +using Volo.Abp.SettingManagement; namespace Unity.TenantManagement.Web.Pages.TenantManagement.Onboarding; [Authorize(IdentityConsts.ITOperationsPolicyName)] -public class CreateTenantModalModel(IOnboardingRequestAppService onboardingRequestAppService) +public class CreateTenantModalModel(IOnboardingRequestAppService onboardingRequestAppService, ISettingManager settingManager) : OnboardingPageModel { [BindProperty(SupportsGet = true)] @@ -16,10 +20,18 @@ public class CreateTenantModalModel(IOnboardingRequestAppService onboardingReque public OnboardingRequestDto? OnboardingRequest { get; set; } + public List DefaultMetabaseUserEmails { get; set; } = []; + public virtual async Task OnGetAsync() { OnboardingRequest = await onboardingRequestAppService.GetAsync(Id); if (OnboardingRequest == null) return NotFound(); + + var defaultEmailsCsv = await settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails); + DefaultMetabaseUserEmails = (defaultEmailsCsv ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + return Page(); } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js index eb7317d294..3d97ca663c 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js @@ -359,18 +359,22 @@ $('#btn-confirm-create-tenant').prop('disabled', true); const tenantNameFieldKey = $('#create-tenant-tenant-name-field').val() || null; + const displayNameFieldKey = $('#create-tenant-display-name-field').val() || null; const superUsersFieldKey = $('#create-tenant-super-users-field').val() || null; const branchFieldKey = $('#create-tenant-branch-field').val() || null; const featuresFieldKey = $('#create-tenant-features-field').val() || null; const ministryFieldKey = $('#create-tenant-ministry-field').val() || null; + const divisionFieldKey = $('#create-tenant-division-field').val() || null; const programAreaFieldKey = $('#create-tenant-program-area-field').val() || null; const params = new URLSearchParams(); if (tenantNameFieldKey) params.append('tenantNameFieldKey', tenantNameFieldKey); + if (displayNameFieldKey) params.append('displayNameFieldKey', displayNameFieldKey); if (superUsersFieldKey) params.append('superUsersFieldKey', superUsersFieldKey); if (branchFieldKey) params.append('branchFieldKey', branchFieldKey); if (featuresFieldKey) params.append('featuresFieldKey', featuresFieldKey); if (ministryFieldKey) params.append('ministryFieldKey', ministryFieldKey); + if (divisionFieldKey) params.append('divisionFieldKey', divisionFieldKey); if (programAreaFieldKey) params.append('programAreaFieldKey', programAreaFieldKey); const query = params.size ? '?' + params.toString() : ''; @@ -389,16 +393,21 @@ $('#onboarding-creating').removeClass('d-none'); const tenantNameFieldKey = $('#create-tenant-tenant-name-field').val() || null; + const displayNameFieldKey = $('#create-tenant-display-name-field').val() || null; const superUsersFieldKey = $('#create-tenant-super-users-field').val() || null; const branchFieldKey = $('#create-tenant-branch-field').val() || null; const featuresFieldKey = $('#create-tenant-features-field').val() || null; const ministryFieldKey = $('#create-tenant-ministry-field').val() || null; + const divisionFieldKey = $('#create-tenant-division-field').val() || null; const programAreaFieldKey = $('#create-tenant-program-area-field').val() || null; + const metabaseUserEmails = $('#create-tenant-metabase-user-emails').val() || null; + const metabaseNewDefaultUserEmails = $('#create-tenant-metabase-new-default-user-emails').val() || null; + const metabaseRemovedDefaultUserEmails = $('#create-tenant-metabase-removed-default-user-emails').val() || null; abp.ajax({ url: abp.appPath + 'api/onboarding-requests/' + applicationId + '/create-tenant', type: 'POST', - data: JSON.stringify({ tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey }), + data: JSON.stringify({ tenantNameFieldKey, displayNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, divisionFieldKey, programAreaFieldKey, metabaseUserEmails, metabaseNewDefaultUserEmails, metabaseRemovedDefaultUserEmails }), contentType: 'application/json' }).done(function () { abp.notify.success(l('OnboardingModal:CreateSuccess')); @@ -417,6 +426,9 @@ $('#create-tenant-ministry-field').on('change', function () { _updateFieldPreview('create-tenant-ministry-field', 'create-tenant-ministry-value'); }); + $('#create-tenant-division-field').on('change', function () { + _updateFieldPreview('create-tenant-division-field', 'create-tenant-division-value'); + }); $('#create-tenant-branch-field').on('change', function () { _updateFieldPreview('create-tenant-branch-field', 'create-tenant-branch-value'); }); @@ -430,6 +442,10 @@ _updateFieldPreview('create-tenant-tenant-name-field', 'create-tenant-tenant-name-value'); _triggerValidation(applicationId); }); + $('#create-tenant-display-name-field').on('change', function () { + _updateFieldPreview('create-tenant-display-name-field', 'create-tenant-display-name-value'); + _triggerValidation(applicationId); + }); $('#create-tenant-super-users-field').on('change', function () { _updateFieldPreview('create-tenant-super-users-field', 'create-tenant-super-users-value', _buildSuperUsersPreview); _triggerValidation(applicationId); @@ -455,16 +471,20 @@ return; } _renderMappingDropdown('create-tenant-ministry-field', schema.columns, MINISTRY_CANONICALS, schema.ministryFieldKey); + _renderMappingDropdown('create-tenant-division-field', schema.columns, DIVISION_CANONICALS, schema.divisionFieldKey); _renderMappingDropdown('create-tenant-branch-field', schema.columns, BRANCH_CANONICALS, schema.branchFieldKey); _renderMappingDropdown('create-tenant-program-area-field', schema.columns, PROGRAM_AREA_CANONICALS, schema.programAreaFieldKey); _renderMappingDropdown('create-tenant-features-field', schema.columns, FEATURES_CANONICALS, schema.featuresFieldKey); _renderMappingDropdown('create-tenant-tenant-name-field', schema.columns, TENANT_NAME_CANONICALS, schema.tenantNameFieldKey); + _renderMappingDropdown('create-tenant-display-name-field', schema.columns, DISPLAY_NAME_CANONICALS, schema.displayNameFieldKey); _renderMappingDropdown('create-tenant-super-users-field', schema.columns, SUPER_USERS_CANONICALS, schema.superUsersFieldKey); _updateFieldPreview('create-tenant-ministry-field', 'create-tenant-ministry-value'); + _updateFieldPreview('create-tenant-division-field', 'create-tenant-division-value'); _updateFieldPreview('create-tenant-branch-field', 'create-tenant-branch-value'); _updateFieldPreview('create-tenant-program-area-field', 'create-tenant-program-area-value'); _updateFieldPreview('create-tenant-features-field', 'create-tenant-features-value', _buildCheckboxBadgesPreview); _updateFieldPreview('create-tenant-tenant-name-field', 'create-tenant-tenant-name-value'); + _updateFieldPreview('create-tenant-display-name-field', 'create-tenant-display-name-value'); _updateFieldPreview('create-tenant-super-users-field', 'create-tenant-super-users-value', _buildSuperUsersPreview); $('#create-tenant-field-mapping').show(); _wireCreateTenantMappingHandlers(applicationId); @@ -474,6 +494,76 @@ }); } + // ─── Metabase tab: user list ─────────────────────────────────────────────── + + let _metabaseNewlyAddedEmails = []; + let _metabaseRemovedDefaultEmails = []; + + function _captureMetabaseUsersToForm() { + let checked = []; + $('#create-tenant-metabase-user-list .create-tenant-metabase-user-checkbox:checked').each(function () { + checked.push($(this).val()); + }); + $('#create-tenant-metabase-user-emails').val(checked.join(',')); + $('#create-tenant-metabase-removed-default-user-emails').val(_metabaseRemovedDefaultEmails.join(',')); + + if ($('#create-tenant-metabase-save-as-default').prop('checked')) { + let newDefaults = _metabaseNewlyAddedEmails.filter(function (email) { + return checked.includes(email); + }); + $('#create-tenant-metabase-new-default-user-emails').val(newDefaults.join(',')); + } else { + $('#create-tenant-metabase-new-default-user-emails').val(''); + } + } + + function _addMetabaseUser(email) { + email = (email || '').trim(); + if (!email) return; + + let exists = $('#create-tenant-metabase-user-list .create-tenant-metabase-user-checkbox').toArray().some(function (el) { + return $(el).val().toLowerCase() === email.toLowerCase(); + }); + if (exists) { + abp.notify.warn(l('CreateTenantModal:MetabaseAlreadyInList')); + return; + } + + let id = 'create-tenant-metabase-user-' + $('#create-tenant-metabase-user-list .create-tenant-metabase-user-checkbox').length + '-' + Date.now(); + let $checkbox = $('') + .attr('id', id).val(email); + let $label = $('').attr('for', id).text(email); + $('
').append($checkbox).append($label).appendTo('#create-tenant-metabase-user-list'); + + _metabaseNewlyAddedEmails.push(email); + _captureMetabaseUsersToForm(); + } + + function _wireMetabaseTabHandlers() { + _metabaseNewlyAddedEmails = []; + _metabaseRemovedDefaultEmails = []; + _captureMetabaseUsersToForm(); + $('#create-tenant-metabase-user-list').on('change', '.create-tenant-metabase-user-checkbox', _captureMetabaseUsersToForm); + $('#create-tenant-metabase-save-as-default').on('change', _captureMetabaseUsersToForm); + $('#create-tenant-metabase-add-user-btn').on('click', function (e) { + e.preventDefault(); + _addMetabaseUser($('#create-tenant-metabase-new-user-email').val()); + $('#create-tenant-metabase-new-user-email').val(''); + }); + $('#create-tenant-metabase-new-user-email').on('keypress', function (e) { + if (e.which === 13) { + e.preventDefault(); + $('#create-tenant-metabase-add-user-btn').click(); + } + }); + $('#create-tenant-metabase-user-list').on('click', '.create-tenant-metabase-remove-default-btn', function (e) { + e.preventDefault(); + _metabaseRemovedDefaultEmails.push($(this).data('email')); + $(this).closest('.form-check').remove(); + _captureMetabaseUsersToForm(); + }); + } + abp.modals.createTenantModal = function () { return { initModal: function (publicApi, args) { @@ -485,6 +575,7 @@ } catch { _fieldValues = {}; } _loadCreateTenantFields(applicationId); + _wireMetabaseTabHandlers(); $('#btn-confirm-create-tenant').on('click', _onCreateTenantConfirm(applicationId)); } }; @@ -541,15 +632,26 @@ return jaro + prefix * 0.1 * (1 - jaro); } - const TENANT_NAME_CANONICALS = ['tenant name', 'organization name', 'company name', 'program name', 'applicant name', 'tenant abbreviation']; + const TENANT_NAME_CANONICALS = ['name', 'tenant name', 'organization name', 'company name', 'program name', 'applicant name', 'tenant abbreviation']; + const DISPLAY_NAME_CANONICALS = ['display name', 'tenant display name', 'organization display name', 'public name']; const SUPER_USERS_CANONICALS = ['super user', 'super users', 'admin email', 'program manager', 'manager email', 'administrator', 'user email']; const MINISTRY_CANONICALS = ['ministry', 'ministry name', 'government ministry', 'responsible ministry']; + const DIVISION_CANONICALS = ['division', 'ministry division', 'division name', 'responsible division']; const BRANCH_CANONICALS = ['branch', 'division branch', 'ministry branch', 'business branch']; const PROGRAM_AREA_CANONICALS = ['program area', 'program area name', 'program name', 'program']; const FEATURES_CANONICALS = ['features', 'feature flags', 'program features', 'modules', 'enabled features', 'features to be enabled']; const MATCH_THRESHOLD = 0.85; function _bestMatch(fields, canonicals) { + // Exact-match short-circuit: a field whose normalized label exactly equals one of the + // canonicals is an unambiguous match, so it wins outright without fuzzy scoring. This + // avoids e.g. "ProgramManagerEmail" (which shares a "program " prefix with the "program + // name" canonical, and so scores highly under Jaro-Winkler's prefix bonus) out-scoring a + // field that's literally labeled "Name" — "name" only appears as a *suffix* of every + // Tenant Name canonical, so it gets no prefix bonus and loses on fuzzy score alone. + const exact = fields.find(function (f) { return canonicals.includes(_normalizeLabel(f.label || f.key)); }); + if (exact) return exact.key; + let best = null, bestScore = 0; fields.forEach(function (f) { const norm = _normalizeLabel(f.label || f.key); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml index 8cea4764ca..11441b601f 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml @@ -65,6 +65,7 @@
+ diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs index 6966884120..1ce3203b66 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs @@ -121,6 +121,7 @@ public class TenantInfoModel : ExtensibleObject, IHasConcurrencyStamp [Display(Name = "DisplayName:TenantName")] public string Name { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; public string Division { get; set; } = string.Empty; public string Branch { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml index 81dccb8d65..47f190aa55 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml @@ -1,4 +1,4 @@ -@page +@page @using Microsoft.AspNetCore.Mvc.Localization @using Microsoft.Extensions.Localization @using Unity.TenantManagement.Web.Pages.TenantManagement.Tenants @@ -15,52 +15,162 @@ @{ Layout = null; } -
- + + - - @foreach (ObjectExtensionPropertyInfo propertyInfo in ObjectExtensionManager.Instance.GetProperties().Where(p => !p.Name.EndsWith("_Text"))) - { - if (propertyInfo.Type.IsEnum || !propertyInfo.Lookup.Url.IsNullOrEmpty()) + + +
+ +
+ + + + + + +
+ + +
+ + @foreach (ObjectExtensionPropertyInfo propertyInfo in ObjectExtensionManager.Instance.GetProperties().Where(p => !p.Name.EndsWith("_Text"))) { - Model.Tenant.ExtraProperties.ToEnum(propertyInfo.Name, propertyInfo.Type); + if (propertyInfo.Type.IsEnum || !propertyInfo.Lookup.Url.IsNullOrEmpty()) + { + if (propertyInfo.Type.IsEnum) + { + Model.Tenant.ExtraProperties.ToEnum(propertyInfo.Name, propertyInfo.Type); + } + + } + else + { + + } } - - } - else +
+ +
+
Assign Program Manager
+
+ + +
+ + + + + + +
+ + @if (Model.CanManageFeatures) { - +
+ +
+ +
+ +
+

Users checked here will be granted access to this tenant's data in Metabase.

+

Note: a user must already have a Metabase account (via SSO login, or created under Admin > People) before they can be added to a group. Emails without an existing account are skipped during registration.

+ +
+ @foreach (var email in Model.DefaultMetabaseUserEmails) + { +
+ + + +
+ } +
+ +
+ + +
+
+ + +
+ + + + +
} - } -
- - - -
- - diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml.cs index a00e4f525e..85e09d9058 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml.cs @@ -1,8 +1,16 @@ -using System.ComponentModel; +#nullable enable +using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Unity.GrantManager.Integrations; +using Unity.Modules.Shared.Permissions; +using Unity.TenantManagement.Metabase; using Volo.Abp.ObjectExtending; +using Volo.Abp.SettingManagement; using Volo.Abp.TenantManagement; using Volo.Abp.Validation; @@ -11,54 +19,109 @@ namespace Unity.TenantManagement.Web.Pages.TenantManagement.Tenants; public class CreateModalModel : TenantManagementPageModel { [BindProperty] - public TenantInfoModel Tenant { get; set; } + public TenantInfoModel Tenant { get; set; } = null!; + + public List CasClientOptions { get; set; } = []; + + public List DefaultMetabaseUserEmails { get; set; } = []; + + public bool CanManageFeatures { get; set; } protected ITenantAppService TenantAppService { get; } + protected ICasClientCodeLookupService LookupService { get; } + protected ISettingManager SettingManager { get; } - public CreateModalModel(ITenantAppService tenantAppService) + public CreateModalModel(ITenantAppService tenantAppService, ICasClientCodeLookupService lookupService, ISettingManager settingManager) { TenantAppService = tenantAppService; + LookupService = lookupService; + SettingManager = settingManager; } - public virtual Task OnGetAsync() + public virtual async Task OnGetAsync() { Tenant = new TenantInfoModel(); - return Task.FromResult(Page()); + CasClientOptions = await LookupService.GetActiveOptionsAsync(); + DefaultMetabaseUserEmails = SplitEmails(await SettingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails)); + + CanManageFeatures = (await AuthorizationService + .AuthorizeAsync(User, IdentityConsts.ITAdminOrITOperationsPolicyName)).Succeeded; + + return Page(); } public virtual async Task OnPostAsync() { ValidateModel(); + // The Features/Metabase tabs are only hidden client-side for non-IT-Admin/Ops callers - + // TenantAppService.CreateAsync itself is reachable by anyone with plain Tenants.Create + // permission (TenantsCreateOrITOps), so a forged POST could otherwise set arbitrary + // FeatureKeys/MetabaseUserEmails. Re-check the same policy server-side and strip these + // privileged fields when it fails, mirroring ConfigurationModalModel's FeaturesJson guard. + var canManageFeatures = (await AuthorizationService + .AuthorizeAsync(User, IdentityConsts.ITAdminOrITOperationsPolicyName)).Succeeded; + + if (!canManageFeatures) + { + Tenant.FeatureKeys = null; + Tenant.MetabaseUserEmails = null; + Tenant.MetabaseNewDefaultUserEmails = null; + Tenant.MetabaseRemovedDefaultUserEmails = null; + } + var input = ObjectMapper.Map(Tenant); await TenantAppService.CreateAsync(input); + if (!string.IsNullOrWhiteSpace(Tenant.MetabaseNewDefaultUserEmails) || !string.IsNullOrWhiteSpace(Tenant.MetabaseRemovedDefaultUserEmails)) + { + await UpdateMetabaseDefaultUserEmailsAsync(Tenant.MetabaseNewDefaultUserEmails, Tenant.MetabaseRemovedDefaultUserEmails); + } + return NoContent(); } - public class TenantInfoModel : ExtensibleObject + private async Task UpdateMetabaseDefaultUserEmailsAsync(string? newEmailsCsv, string? removedEmailsCsv) { - [Required] - [DynamicStringLength(typeof(TenantConsts), nameof(TenantConsts.MaxNameLength))] - [Display(Name = "DisplayName:TenantName")] - public string Name { get; set; } + var removed = SplitEmails(removedEmailsCsv); + var updated = SplitEmails(await SettingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails)) + .Concat(SplitEmails(newEmailsCsv)) + .Where(email => !removed.Contains(email, StringComparer.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase); - [DisplayName("First Name")] - [MinLength(2, ErrorMessage = "At least 2 characters are required")] - public string FirstName { get; set; } + await SettingManager.SetGlobalAsync(MetabaseSettings.UserEmails, string.Join(",", updated)); + } - [DisplayName("Last Name")] - [MinLength(2, ErrorMessage = "At least 2 characters are required")] - public string LastName { get; set; } + private static List SplitEmails(string? emailsCsv) => + (emailsCsv ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + public class TenantInfoModel : ExtensibleObject + { [Required] - public string Directory { get; set; } = "IDIR"; + [DynamicStringLength(typeof(TenantConsts), nameof(TenantConsts.MaxNameLength))] + [Display(Name = "DisplayName:TenantName")] + public string Name { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; public string Division { get; set; } = string.Empty; public string Branch { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; - public string CasClientCode { get; set; } = string.Empty; + [Display(Name = "CAS Client Code")] + public string? CasClientCode { get; set; } + + public string? FeatureKeys { get; set; } + + /// Comma-separated emails checked in the Metabase tab - sent to TenantCreateDto as-is. + public string? MetabaseUserEmails { get; set; } + + /// Comma-separated subset of newly-added Metabase emails to persist as the new Global default. + public string? MetabaseNewDefaultUserEmails { get; set; } + + /// Comma-separated default Metabase emails explicitly removed - deleted from the Global default. + public string? MetabaseRemovedDefaultUserEmails { get; set; } [Required] public string UserIdentifier { get; set; } = string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml index 1421d24d89..22926d0357 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml @@ -20,6 +20,7 @@ + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml.cs index 703aadd6fe..55773d9503 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml.cs @@ -49,6 +49,7 @@ public class TenantInfoModel : ExtensibleObject, IHasConcurrencyStamp [DynamicStringLength(typeof(TenantConsts), nameof(TenantConsts.MaxNameLength))] [Display(Name = "DisplayName:TenantName")] public string Name { get; set; } + public string DisplayName { get; set; } = string.Empty; public string Division { get; set; } = string.Empty; public string Branch { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js index 5ca39ac3bb..1344561c3a 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js @@ -52,15 +52,16 @@ } }, { title: l('TenantName'), data: 'name', name: 'name', index: 1 }, - { title: lGm('TenantList:LicencePlate'), data: 'licencePlate', name: 'licencePlate', index: 2 }, - { title: l('Division'), data: 'division', name: 'division', index: 3 }, - { title: l('Branch'), data: 'branch', name: 'branch', index: 4 }, - { title: l('Description'), data: 'description', name: 'description', index: 5 }, + { title: lGm('TenantList:DisplayName'), data: 'displayName', name: 'displayName', index: 2 }, + { title: lGm('TenantList:LicencePlate'), data: 'licencePlate', name: 'licencePlate', index: 3 }, + { title: l('Division'), data: 'division', name: 'division', index: 4 }, + { title: l('Branch'), data: 'branch', name: 'branch', index: 5 }, + { title: l('Description'), data: 'description', name: 'description', index: 6 }, { title: lGm('TenantList:CasClientCode'), data: 'casClientCode', name: 'casClientCode', - index: 6, + index: 7, render: function (data, type, row) { if (type === 'display') { return _casClientCodeHash[row.casClientCode || ''] || ''; @@ -68,10 +69,10 @@ return data; } }, - { title: l('Id'), data: 'id', name: 'id', index: 7 } + { title: l('Id'), data: 'id', name: 'id', index: 8 } ]; - let defaultVisibleColumns = ['actions', 'name', 'licencePlate', 'division', 'branch', 'description', 'casClientCode']; + let defaultVisibleColumns = ['actions', 'name', 'displayName', 'licencePlate', 'division', 'branch', 'description', 'casClientCode']; let responseCallback = function (result) { return { @@ -85,6 +86,32 @@ let _filterDataTable = null; let _configFilterDataTable = null; + let _createFeaturesLoaded = false; + let _createFeatureProviderKey = null; + + function _searchFieldInputAction(fieldSelectId, valueInputId) { + let field = $('#' + fieldSelectId).val(); + let value = $('#' + valueInputId).val(); + if (field === 'firstAndLast') { + let parts = value.trim().replaceAll(/\s+/g, ' ').split(' '); + return { + directory: 'IDIR', + firstName: parts[0] || '', + lastName: parts[1] || '', + email: '' + }; + } + return { + directory: 'IDIR', + firstName: field === 'firstName' ? value : '', + lastName: field === 'lastName' ? value : '', + email: field === 'email' ? value : '' + }; + } + + function _searchResponseCallback(result) { + return { recordsTotal: result.length, recordsFiltered: result.length, data: result }; + } let setupCreateTenantModal = function () { let _$filterTable = $('#UserSearchTable'); @@ -99,20 +126,8 @@ searching: false, ajax: abp.libs.datatables.createAjax( _userImportService.search, - function () { - return { - directory: 'IDIR', - firstName: $('#create-tenant-firstName').val(), - lastName: $('#create-tenant-lastName').val() - }; - }, - function (result) { - return { - recordsTotal: result.length, - recordsFiltered: result.length, - data: result - }; - } + function () { return _searchFieldInputAction('create-search-field', 'create-search-value'); }, + _searchResponseCallback ), select: { style: 'single', @@ -134,13 +149,35 @@ name: 'displayName', data: 'displayName', className: 'data-table-header' + }, + { + title: 'Email', + name: 'email', + data: 'email', + className: 'data-table-header' }], }) ); + $('#create-search-field').on('change', function () { + let placeholders = { + firstName: 'At least 2 characters...', + lastName: 'At least 2 characters...', + firstAndLast: 'e.g. John Smith', + email: 'At least 2 characters...' + }; + $('#create-search-value').val('').attr('placeholder', placeholders[$(this).val()] || 'At least 2 characters...'); + }); + $('#TenantAdminSearchButton').click(function (e) { e.preventDefault(); + if ($('#create-search-value').val().trim().length < 2) { + abp.notify.warn(lGm('TenantList:SearchMinChars')); + return; + } _filterDataTable.ajax.reload(); + $('#create-tenant-admin-id').val(''); + $('#create-selected-user-display').hide(); $('#create-tenant-btn').attr('disabled', true); }); @@ -152,12 +189,16 @@ if (type === 'row') { let selectedData = _filterDataTable.row(indexes).data(); $('#create-tenant-admin-id').val(selectedData.userGuid); + let displayName = selectedData.displayName || (selectedData.firstName + ' ' + selectedData.lastName).trim(); + $('#create-selected-user-name').text(displayName); + $('#create-selected-user-display').show(); $('#create-tenant-btn').removeAttr('disabled'); } }); - _filterDataTable.on('deselect', function (e, dt, type, indexes) { - $('#create-tenant-admin-id').val(); + _filterDataTable.on('deselect', function () { + $('#create-tenant-admin-id').val(''); + $('#create-selected-user-display').hide(); $('#create-tenant-btn').attr('disabled', true); }); }; @@ -178,6 +219,94 @@ function _createTenantInitModal(publicApi, args) { setupCreateTenantModal(); + + _createFeaturesLoaded = false; + _createFeatureProviderKey = _generateGuid(); + $('#create-tab-features').on('shown.bs.tab', function () { + if (!_createFeaturesLoaded) { + _createFeaturesLoaded = true; + _loadCreateFeaturesTab(); + } + }); + $('#create-features-content').on('change', '[data-feature-group="Specializations"] input[type="checkbox"]', _specializationCheckboxChange); + $('#create-features-content').on('change', 'input[type="checkbox"]', _captureCreateFeaturesToForm); + + _metabaseNewlyAddedEmails = []; + _metabaseRemovedDefaultEmails = []; + _captureMetabaseUsersToForm(); + $('#metabase-user-list').on('change', '.metabase-user-checkbox', _captureMetabaseUsersToForm); + $('#metabase-save-as-default').on('change', _captureMetabaseUsersToForm); + $('#metabase-add-user-btn').on('click', function (e) { + e.preventDefault(); + _addMetabaseUser($('#metabase-new-user-email').val()); + $('#metabase-new-user-email').val(''); + }); + $('#metabase-new-user-email').on('keypress', function (e) { + if (e.which === 13) { + e.preventDefault(); + $('#metabase-add-user-btn').click(); + } + }); + $('#metabase-user-list').on('click', '.metabase-remove-default-btn', function (e) { + e.preventDefault(); + _metabaseRemovedDefaultEmails.push($(this).data('email')); + $(this).closest('.form-check').remove(); + _captureMetabaseUsersToForm(); + }); + + $('#create-pane-features').closest('form').on('invalid-form.validate', function (e, validator) { + if (validator.errorList.length > 0) { + let $firstErrorPane = $(validator.errorList[0].element).closest('.tab-pane'); + if ($firstErrorPane.length) { + $('[data-bs-target="#' + $firstErrorPane.attr('id') + '"]').tab('show'); + } + } + }); + } + + // ─── Metabase tab: user list ─────────────────────────────────────────────── + + let _metabaseNewlyAddedEmails = []; + let _metabaseRemovedDefaultEmails = []; + + function _captureMetabaseUsersToForm() { + let checked = []; + $('#metabase-user-list .metabase-user-checkbox:checked').each(function () { + checked.push($(this).val()); + }); + $('#metabase-user-emails').val(checked.join(',')); + $('#metabase-removed-default-user-emails').val(_metabaseRemovedDefaultEmails.join(',')); + + if ($('#metabase-save-as-default').prop('checked')) { + let newDefaults = _metabaseNewlyAddedEmails.filter(function (email) { + return checked.includes(email); + }); + $('#metabase-new-default-user-emails').val(newDefaults.join(',')); + } else { + $('#metabase-new-default-user-emails').val(''); + } + } + + function _addMetabaseUser(email) { + email = (email || '').trim(); + if (!email) return; + + let exists = $('#metabase-user-list .metabase-user-checkbox').toArray().some(function (el) { + return $(el).val().toLowerCase() === email.toLowerCase(); + }); + if (exists) { + abp.notify.warn('That user is already in the list.'); + return; + } + + let id = 'metabase-user-' + $('#metabase-user-list .metabase-user-checkbox').length + '-' + Date.now(); + let $checkbox = $('') + .attr('id', id).val(email); + let $label = $('').attr('for', id).text(email); + $('
').append($checkbox).append($label).appendTo('#metabase-user-list'); + + _metabaseNewlyAddedEmails.push(email); + _captureMetabaseUsersToForm(); } abp.modals.createTenant = function () { @@ -189,6 +318,21 @@ let _configTenantId = null; let _featuresLoaded = false; + function _generateGuid() { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); + + // Fallback for environments without crypto.randomUUID - still CSPRNG-backed via + // crypto.getRandomValues, not Math.random(), since this key is used as a cache-busting + // provider key sent to the server, not truly security-sensitive, but there's no reason + // to reach for a weaker PRNG when getRandomValues is universally available. + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, function (b) { return b.toString(16).padStart(2, '0'); }).join(''); + return hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-' + hex.slice(12, 16) + '-' + hex.slice(16, 20) + '-' + hex.slice(20); + } + function _renderFeatureItem(feature) { let id = 'ft-' + feature.name.replaceAll('.', '-'); let checked = (feature.value || '').toLowerCase() === 'true' ? ' checked' : ''; @@ -283,27 +427,38 @@ } function _configSearchInputAction() { - let field = $('#config-search-field').val(); - let value = $('#config-search-value').val(); - if (field === 'firstAndLast') { - let parts = value.trim().replaceAll(/\s+/g, ' ').split(' '); - return { - directory: 'IDIR', - firstName: parts[0] || '', - lastName: parts[1] || '', - email: '' - }; - } - return { - directory: 'IDIR', - firstName: field === 'firstName' ? value : '', - lastName: field === 'lastName' ? value : '', - email: field === 'email' ? value : '' - }; + return _searchFieldInputAction('config-search-field', 'config-search-value'); } function _configSearchResponseCallback(result) { - return { recordsTotal: result.length, recordsFiltered: result.length, data: result }; + return _searchResponseCallback(result); + } + + function _loadCreateFeaturesTab() { + $('#create-features-loading').show(); + $('#create-features-content').html(''); + + abp.ajax({ + url: abp.appPath + 'api/feature-management/features', + type: 'GET', + data: { providerName: 'T', providerKey: _createFeatureProviderKey } + }).done(function (result) { + $('#create-features-loading').hide(); + $('#create-features-content').html(_renderFeatureGroups(result.groups)); + _captureCreateFeaturesToForm(); + }).fail(function () { + $('#create-features-loading').hide(); + $('#create-features-content').html('
Failed to load features. Please try again.
'); + }); + } + + function _captureCreateFeaturesToForm() { + if (!_createFeaturesLoaded) return; + let featureKeys = []; + $('#create-features-content input[type="checkbox"]:checked').each(function () { + featureKeys.push($(this).data('feature-name')); + }); + $('#create-features-json').val(featureKeys.join(',')); } function _configurationModalInitModal(publicApi, args) { diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs index 07827e6ab9..c09d7676fa 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs @@ -29,6 +29,7 @@ public override void Map(TenantDto source, EditModalModel.TenantInfoModel destin { destination.Id = source.Id; destination.Name = source.Name; + destination.DisplayName = source.DisplayName; destination.Division = source.Division; destination.Branch = source.Branch; destination.Description = source.Description; @@ -50,11 +51,14 @@ public override TenantCreateDto Map(CreateModalModel.TenantInfoModel source) public override void Map(CreateModalModel.TenantInfoModel source, TenantCreateDto destination) { destination.Name = source.Name; + destination.DisplayName = source.DisplayName; destination.Division = source.Division; destination.Branch = source.Branch; destination.Description = source.Description; - destination.CasClientCode = source.CasClientCode; + destination.CasClientCode = source.CasClientCode ?? string.Empty; destination.UserIdentifier = source.UserIdentifier; + destination.FeatureKeys = source.FeatureKeys; + destination.MetabaseUserEmails = source.MetabaseUserEmails; TenantExtraPropertiesCopier.Copy(source, destination); } } @@ -71,6 +75,7 @@ public override TenantUpdateDto Map(EditModalModel.TenantInfoModel source) public override void Map(EditModalModel.TenantInfoModel source, TenantUpdateDto destination) { destination.Name = source.Name; + destination.DisplayName = source.DisplayName; destination.Division = source.Division; destination.Branch = source.Branch; destination.Description = source.Description; @@ -93,6 +98,7 @@ public override void Map(TenantDto source, TenantInfoModel destination) { destination.Id = source.Id; destination.Name = source.Name; + destination.DisplayName = source.DisplayName; destination.Division = source.Division; destination.Branch = source.Branch; destination.Description = source.Description; @@ -114,6 +120,7 @@ public override TenantUpdateDto Map(TenantInfoModel source) public override void Map(TenantInfoModel source, TenantUpdateDto destination) { destination.Name = source.Name; + destination.DisplayName = source.DisplayName; destination.Division = source.Division; destination.Branch = source.Branch; destination.Description = source.Description; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs index 7667f51dfa..cce0b7c74c 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs @@ -8,6 +8,7 @@ using Unity.Flex.Worksheets; using Unity.Flex.Worksheets.Values; using Unity.Flex.WorksheetInstances; +using Unity.TenantManagement.Metabase; using Unity.TenantManagement.Onboarding; using Volo.Abp; using Volo.Abp.Application.Dtos; @@ -469,4 +470,87 @@ await _tenantAppService.Received(1).AssignManagerAsync(Arg.Is>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(id, ("tn", "Metabase Co"), ("su", "first@example.com")) + }); + _userLookup.FindUserGuidByEmailAsync("first@example.com").Returns("guid-1"); + _tenantAppService.CreateAsync(Arg.Any()) + .Returns(new TenantDto { Id = newTenantId, Name = "Metabase Co" }); + + await _appService.CreateTenantAsync(id, new CreateTenantInputDto + { + TenantNameFieldKey = "tn", + SuperUsersFieldKey = "su", + MetabaseUserEmails = "a@gov.bc.ca,b@gov.bc.ca" + }); + + await _tenantAppService.Received(1).CreateAsync(Arg.Is(d => + d.MetabaseUserEmails == "a@gov.bc.ca,b@gov.bc.ca")); + } + + [Fact] + public async Task CreateTenantAsync_MetabaseNewDefaultUserEmailsProvided_MergesIntoGlobalSetting() + { + var id = Guid.NewGuid(); + var newTenantId = Guid.NewGuid(); + + _applicationProvider.GetByIdAsync(id).Returns(new OnboardingApplicationRecord { Id = id, Category = "Onboarding" }); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(id, ("tn", "Metabase Defaults Co"), ("su", "first@example.com")) + }); + _userLookup.FindUserGuidByEmailAsync("first@example.com").Returns("guid-1"); + _tenantAppService.CreateAsync(Arg.Any()) + .Returns(new TenantDto { Id = newTenantId, Name = "Metabase Defaults Co" }); + _settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails) + .Returns("existing@gov.bc.ca"); + + await _appService.CreateTenantAsync(id, new CreateTenantInputDto + { + TenantNameFieldKey = "tn", + SuperUsersFieldKey = "su", + MetabaseUserEmails = "existing@gov.bc.ca,new@gov.bc.ca", + MetabaseNewDefaultUserEmails = "new@gov.bc.ca" + }); + + await _settingManager.Received(1).SetGlobalAsync( + MetabaseSettings.UserEmails, "existing@gov.bc.ca,new@gov.bc.ca"); + } + + [Fact] + public async Task CreateTenantAsync_MetabaseRemovedDefaultUserEmailsProvided_RemovesFromGlobalSetting() + { + var id = Guid.NewGuid(); + var newTenantId = Guid.NewGuid(); + + _applicationProvider.GetByIdAsync(id).Returns(new OnboardingApplicationRecord { Id = id, Category = "Onboarding" }); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(id, ("tn", "Metabase Removal Co"), ("su", "first@example.com")) + }); + _userLookup.FindUserGuidByEmailAsync("first@example.com").Returns("guid-1"); + _tenantAppService.CreateAsync(Arg.Any()) + .Returns(new TenantDto { Id = newTenantId, Name = "Metabase Removal Co" }); + _settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails) + .Returns("keep@gov.bc.ca,stale@gov.bc.ca"); + + await _appService.CreateTenantAsync(id, new CreateTenantInputDto + { + TenantNameFieldKey = "tn", + SuperUsersFieldKey = "su", + MetabaseUserEmails = "keep@gov.bc.ca", + MetabaseRemovedDefaultUserEmails = "stale@gov.bc.ca" + }); + + await _settingManager.Received(1).SetGlobalAsync(MetabaseSettings.UserEmails, "keep@gov.bc.ca"); + } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs index 96f8c73743..ebcd99601a 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs @@ -75,6 +75,33 @@ public async Task GetListAsync_Sorted_Descending_By_Name() tenants.FindIndex(t => t.Name == "acme").ShouldBeGreaterThan(tenants.FindIndex(t => t.Name == "volosoft")); } + [Fact] + public async Task GetListAsync_Sorted_By_DisplayName() + { + var acme = UsingDbContext(dbContext => dbContext.Tenants.Single(t => t.Name == "acme")); + var volo = UsingDbContext(dbContext => dbContext.Tenants.Single(t => t.Name == "volosoft")); + + await _tenantAppService.UpdateAsync(acme.Id, new TenantUpdateDto { Name = "acme", DisplayName = "Zeta Corp" }); + await _tenantAppService.UpdateAsync(volo.Id, new TenantUpdateDto { Name = "volosoft", DisplayName = "Alpha Corp" }); + + var result = await _tenantAppService.GetListAsync(new GetTenantsInput { Sorting = "DisplayName ASC" }); + var tenants = result.Items.ToList(); + + tenants.FindIndex(t => t.Name == "volosoft").ShouldBeLessThan(tenants.FindIndex(t => t.Name == "acme")); + } + + [Fact] + public async Task GetListAsync_Filtered_By_DisplayName() + { + var acme = UsingDbContext(dbContext => dbContext.Tenants.Single(t => t.Name == "acme")); + await _tenantAppService.UpdateAsync(acme.Id, new TenantUpdateDto { Name = "acme", DisplayName = "UniqueDisplayNameXyz" }); + + var result = await _tenantAppService.GetListAsync(new GetTenantsInput { Filter = "UniqueDisplayNameXyz" }); + + result.Items.ShouldContain(t => t.Name == "acme"); + result.Items.ShouldNotContain(t => t.Name == "volosoft"); + } + [Fact] public async Task CreateAsync() { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs index 88beaf64fc..21da7aa288 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs @@ -9,6 +9,7 @@ using Unity.GrantManager.Attachments; using Unity.GrantManager.Events; using Unity.GrantManager.Integrations.Css; +using Unity.GrantManager.Integrations.Metabase; using Unity.TenantManagement; using Volo.Abp.Mapperly; using Volo.Abp.BlobStoring; @@ -171,6 +172,8 @@ public override void ConfigureServices(ServiceConfigurationContext context) context.Services.Configure(configuration.GetSection("Payments")); context.Services.Configure(configuration.GetSection("CssApi")); context.Services.Configure(configuration.GetSection("Notifications")); + context.Services.Configure(configuration.GetSection("TenantCreation:Steps:Metabase")); + context.Services.AddTransient(); ConfigureBackgroundServices(configuration); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs index ffc1badf55..670bfe8302 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs @@ -4,10 +4,15 @@ using System.Threading.Tasks; using Unity.GrantManager.Data; using Unity.GrantManager.Identity; +using Unity.GrantManager.Tenants.PostCreation; +using Unity.TenantManagement.Metabase; +using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; using Volo.Abp.EventBus; using Volo.Abp.FeatureManagement; using Volo.Abp.MultiTenancy; +using Volo.Abp.Settings; +using Volo.Abp.SettingManagement; using Volo.Abp.TenantManagement; namespace Unity.GrantManager.Handlers @@ -20,18 +25,24 @@ public class TenantCreatedEventHandler private readonly IUserImportAppService _userImportAppService; private readonly IFeatureAppService _featureAppService; private readonly GrantManagerDbMigrationService _grantManagerDbMigrationService; + private readonly IBackgroundJobManager _backgroundJobManager; + private readonly ISettingManager _settingManager; public TenantCreatedEventHandler(ITenantRepository tenantRepository, ICurrentTenant currentTenant, IUserImportAppService userImportAppService, IFeatureAppService featureAppService, - GrantManagerDbMigrationService grantManagerDbMigrationService) + GrantManagerDbMigrationService grantManagerDbMigrationService, + IBackgroundJobManager backgroundJobManager, + ISettingManager settingManager) { _tenantRepository = tenantRepository; _grantManagerDbMigrationService = grantManagerDbMigrationService; _currentTenant = currentTenant; _userImportAppService = userImportAppService; _featureAppService = featureAppService; + _backgroundJobManager = backgroundJobManager; + _settingManager = settingManager; } public async Task HandleEventAsync(TenantCreatedEto tenantCreatedEto) @@ -49,6 +60,31 @@ await _userImportAppService.ImportUserAsync(new ImportUserDto() } await EnableRequestedFeaturesAsync(tenantCreatedEto, tenant.Id); + await SaveMetabaseUserEmailsAsync(tenantCreatedEto, tenant.Id); + + // Kick off the post-tenant-creation step sequence (e.g. Metabase registration). + // The job re-enqueues itself for each subsequent step, so this only starts step 0. + await _backgroundJobManager.EnqueueAsync(new PostTenantCreationStepArgs + { + TenantId = tenant.Id, + StepIndex = 0 + }); + } + + // Captures the Metabase user list chosen at creation time as a per-tenant setting + // snapshot, so the (async, later-running) Metabase step reads a stable list even if the + // Global default changes in the meantime. + private async Task SaveMetabaseUserEmailsAsync(TenantCreatedEto eto, Guid tenantId) + { + // An empty string is a deliberate "no Metabase users for this tenant" choice - it must + // still be persisted (not skipped), otherwise MetabaseTenantRegistrationStep's + // GetUserEmailsAsync falls back to the Global default list and grants users the + // tenant creator explicitly unchecked. + if (!eto.Properties.TryGetValue("MetabaseUserEmails", out var emailsRaw)) + return; + + await _settingManager.SetAsync( + MetabaseSettings.UserEmails, emailsRaw ?? string.Empty, TenantSettingValueProvider.ProviderName, tenantId.ToString()); } private async Task EnableRequestedFeaturesAsync(TenantCreatedEto eto, Guid tenantId) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/IMetabaseApiClient.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/IMetabaseApiClient.cs new file mode 100644 index 0000000000..aa1d760237 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/IMetabaseApiClient.cs @@ -0,0 +1,23 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Unity.GrantManager.Integrations.Metabase; + +/// +/// Thin wrapper over the Metabase admin REST API, covering the same steps as the +/// manual_deploy_new_metabase_tenant.ps1 runbook: create a database connection for the tenant's +/// readonly Postgres role, create a permissions group and add members to it, grant the group +/// access to the database, and create/grant a collection. +/// +public interface IMetabaseApiClient +{ + Task CreateDatabaseAsync(string name, string host, int port, string dbName, string username, string password, bool ssl, CancellationToken cancellationToken = default); + Task SyncDatabaseSchemaAsync(int databaseId, CancellationToken cancellationToken = default); + Task RescanDatabaseValuesAsync(int databaseId, CancellationToken cancellationToken = default); + Task CreateGroupAsync(string name, CancellationToken cancellationToken = default); + Task FindUserIdByEmailAsync(string email, CancellationToken cancellationToken = default); + Task AddGroupMemberAsync(int groupId, int userId, CancellationToken cancellationToken = default); + Task GrantGroupDatabaseAccessAsync(int groupId, int databaseId, CancellationToken cancellationToken = default); + Task CreateCollectionAsync(string name, CancellationToken cancellationToken = default); + Task GrantGroupCollectionAccessAsync(int groupId, int collectionId, CancellationToken cancellationToken = default); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseApiClient.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseApiClient.cs new file mode 100644 index 0000000000..d315a138ab --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseApiClient.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Newtonsoft.Json.Linq; +using Unity.GrantManager.Integrations.Exceptions; +using Unity.Modules.Shared.Http; + +namespace Unity.GrantManager.Integrations.Metabase; + +public class MetabaseApiClient( + IResilientHttpRequest resilientHttpRequest, + IEndpointManagementAppService endpointManagementAppService, + IOptions options) : IMetabaseApiClient +{ + private const string ApiKeyHeader = "x-api-key"; + + // Metabase's permissions/collection graph endpoints use an optimistic-concurrency "revision" + // number - a PUT with a stale revision (because another tenant registration updated the graph + // first) is rejected. Retry the whole read-mutate-write cycle against a freshly-fetched graph + // rather than surfacing a transient conflict as a permanent failure. + private const int MaxGraphUpdateAttempts = 3; + + public async Task CreateDatabaseAsync(string name, string host, int port, string dbName, string username, string password, bool ssl, CancellationToken cancellationToken = default) + { + var body = new + { + engine = "postgres", + name, + is_full_sync = true, + details = new { host, port, dbname = dbName, user = username, password, ssl } + }; + var result = await PostAsync("/api/database", body, cancellationToken); + return result.Value("id"); + } + + public Task SyncDatabaseSchemaAsync(int databaseId, CancellationToken cancellationToken = default) => + PostAsync($"/api/database/{databaseId}/sync_schema", new { }, cancellationToken); + + public Task RescanDatabaseValuesAsync(int databaseId, CancellationToken cancellationToken = default) => + PostAsync($"/api/database/{databaseId}/rescan_values", new { }, cancellationToken); + + public async Task CreateGroupAsync(string name, CancellationToken cancellationToken = default) + { + var result = await PostAsync("/api/permissions/group", new { name }, cancellationToken); + return result.Value("id"); + } + + public async Task FindUserIdByEmailAsync(string email, CancellationToken cancellationToken = default) + { + var result = await GetAsync($"/api/user?query={Uri.EscapeDataString(email)}", cancellationToken); + var match = result["data"]? + .FirstOrDefault(u => string.Equals(u.Value("email"), email, StringComparison.OrdinalIgnoreCase)); + return match?.Value("id"); + } + + public Task AddGroupMemberAsync(int groupId, int userId, CancellationToken cancellationToken = default) => + PostAsync("/api/permissions/membership", new { group_id = groupId, user_id = userId }, cancellationToken); + + public Task GrantGroupDatabaseAccessAsync(int groupId, int databaseId, CancellationToken cancellationToken = default) => + UpdateGraphWithRetryAsync("/api/permissions/graph", groups => + { + var groupKey = groupId.ToString(); + var groupNode = (JObject?)groups[groupKey] ?? new JObject(); + + groupNode[databaseId.ToString()] = new JObject + { + ["view-data"] = "unrestricted", + ["create-queries"] = "query-builder-and-native" + }; + groups[groupKey] = groupNode; + }, cancellationToken); + + public async Task CreateCollectionAsync(string name, CancellationToken cancellationToken = default) + { + var result = await PostAsync("/api/collection", new { name, color = "#509EE3" }, cancellationToken); + return result.Value("id"); + } + + public Task GrantGroupCollectionAccessAsync(int groupId, int collectionId, CancellationToken cancellationToken = default) => + UpdateGraphWithRetryAsync("/api/collection/graph", groups => + { + var groupKey = groupId.ToString(); + var groupNode = (JObject?)groups[groupKey] ?? new JObject(); + + groupNode[collectionId.ToString()] = "write"; + groups[groupKey] = groupNode; + }, cancellationToken); + + private async Task UpdateGraphWithRetryAsync(string graphPath, Action applyMutation, CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + var graph = await GetAsync(graphPath, cancellationToken); + var groups = (JObject?)graph["groups"] ?? new JObject(); + applyMutation(groups); + + var response = await PutRawAsync(graphPath, + new { groups, revision = graph.Value("revision") }, cancellationToken); + + if (response.IsSuccessStatusCode) + return; + + var isRevisionConflict = response.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.BadRequest; + if (!isRevisionConflict || attempt >= MaxGraphUpdateAttempts) + { + var content = response.Content == null ? string.Empty : await response.Content.ReadAsStringAsync(cancellationToken); + throw new IntegrationServiceException( + $"Metabase API call to '{graphPath}' failed with status {response.StatusCode}: {content}"); + } + + // Stale revision - another concurrent tenant registration updated the graph first. + // Loop around to re-fetch the latest graph and reapply this mutation on top of it. + } + } + + private async Task GetBaseUrlAsync() => + await endpointManagementAppService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.METABASE_API_BASE); + + private IReadOnlyDictionary BuildHeaders() => + new Dictionary { [ApiKeyHeader] = options.Value.ApiKey }; + + private async Task GetAsync(string path, CancellationToken cancellationToken) + { + var baseUrl = await GetBaseUrlAsync(); + var response = await resilientHttpRequest.HttpAsync( + HttpMethod.Get, $"{baseUrl}{path}", extraHeaders: BuildHeaders(), cancellationToken: cancellationToken); + return await ReadJsonAsync(response, path); + } + + private async Task PostAsync(string path, object body, CancellationToken cancellationToken) + { + var baseUrl = await GetBaseUrlAsync(); + var response = await resilientHttpRequest.HttpAsync( + HttpMethod.Post, $"{baseUrl}{path}", body, extraHeaders: BuildHeaders(), cancellationToken: cancellationToken); + return await ReadJsonAsync(response, path); + } + + private async Task PutRawAsync(string path, object body, CancellationToken cancellationToken) + { + var baseUrl = await GetBaseUrlAsync(); + return await resilientHttpRequest.HttpAsync( + HttpMethod.Put, $"{baseUrl}{path}", body, extraHeaders: BuildHeaders(), cancellationToken: cancellationToken); + } + + private static async Task ReadJsonAsync(HttpResponseMessage response, string path) + { + var content = response.Content == null ? string.Empty : await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new IntegrationServiceException( + $"Metabase API call to '{path}' failed with status {response.StatusCode}: {content}"); + } + + return string.IsNullOrWhiteSpace(content) ? new JObject() : JObject.Parse(content); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseOptions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseOptions.cs new file mode 100644 index 0000000000..487528d539 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseOptions.cs @@ -0,0 +1,31 @@ +namespace Unity.GrantManager.Integrations.Metabase; + +public class MetabaseOptions +{ + /// Admin API key - same key the Metabase admin UI/PowerShell runbook uses (x-api-key header). + public string ApiKey { get; set; } = string.Empty; + + /// + /// Local-dev-only override for the Postgres host passed to Metabase when registering a + /// tenant's database connection. Tenant readonly connection strings store "localhost" as the + /// host (correct for the .NET app, which runs on the host machine) - but a dockerized local + /// Metabase container can't reach "localhost" that way, since that resolves to the container + /// itself. Set this to whatever hostname your local Metabase container can actually reach + /// Postgres by - e.g. the Postgres container's name/service (like "unitydb") if both containers + /// share a Docker network, or "host.docker.internal" if Metabase needs to reach out to the host + /// machine instead (which may also require a Windows Firewall inbound allow rule for 5432, and + /// doesn't resolve for every local Docker setup - verify with a direct call to Metabase's + /// POST /api/database before assuming it's this setting). Leave unset in deployed environments - + /// there both the app and Metabase reach Postgres via its OpenShift service name, so the stored + /// host is already correct. + /// + public string DbHostOverride { get; set; } = string.Empty; + + /// + /// Local-dev-only override for whether Metabase connects to the tenant's Postgres database + /// over SSL. Deployed Postgres (Crunchy on OpenShift) requires SSL, so the default (null, no + /// override) sends ssl: true. A plain local `postgres` Docker image has SSL disabled + /// out of the box, so set this to false locally or Metabase's connection attempt fails. + /// + public bool? DbSslOverride { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationSequenceJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationSequenceJob.cs new file mode 100644 index 0000000000..11ef370f63 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationSequenceJob.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.Modules.Shared.PostTenantCreation; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.Tenants.PostCreation; + +/// +/// Runs the registered steps, one per job execution, in +/// ascending order. Each execution re-enqueues itself +/// for the next step, so the sequence is driven entirely by ABP's background job queue. +/// +/// A step's exception is always caught and logged here rather than rethrown, so ABP's own +/// background-job retry mechanism (which only engages when ExecuteAsync throws) never +/// applies to an individual step - failures are best-effort, not retried automatically. A step +/// whose is true is logged and the sequence +/// moves on to the next step regardless; one whose ContinueOnError is false stops the sequence +/// entirely on failure (later steps do not run). Either way, the failed step itself does not get +/// another automatic attempt - recovering it currently requires manually re-enqueuing a +/// for that step index. +/// +public class PostTenantCreationSequenceJob( + IEnumerable steps, + IBackgroundJobManager backgroundJobManager, + ICurrentTenant currentTenant, + ILogger logger) + : AsyncBackgroundJob, ITransientDependency +{ + private const string LogPrefix = "[PostTenantCreation]"; + + public override async Task ExecuteAsync(PostTenantCreationStepArgs args) + { + var orderedSteps = steps.OrderBy(s => s.Order).ToList(); + + if (args.StepIndex >= orderedSteps.Count) + { + return; + } + + var step = orderedSteps[args.StepIndex]; + + try + { + using (currentTenant.Change(args.TenantId)) + { + if (!await step.CanExecuteAsync(args.TenantId)) + { + logger.LogInformation( + "{Prefix} Skipping step {StepIndex} '{StepName}' for tenant {TenantId} - CanExecuteAsync returned false", + LogPrefix, args.StepIndex, step.StepName, args.TenantId); + } + else + { + logger.LogInformation( + "{Prefix} Running step {StepIndex} '{StepName}' for tenant {TenantId}", + LogPrefix, args.StepIndex, step.StepName, args.TenantId); + + await step.ExecuteAsync(args.TenantId); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, + "{Prefix} Step {StepIndex} '{StepName}' failed for tenant {TenantId}", + LogPrefix, args.StepIndex, step.StepName, args.TenantId); + + if (!step.ContinueOnError) + { + logger.LogWarning( + "{Prefix} Stopping post-tenant-creation sequence after step '{StepName}' for tenant {TenantId} (ContinueOnError = false)", + LogPrefix, step.StepName, args.TenantId); + return; + } + } + + await backgroundJobManager.EnqueueAsync(new PostTenantCreationStepArgs + { + TenantId = args.TenantId, + StepIndex = args.StepIndex + 1 + }); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationStepArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationStepArgs.cs new file mode 100644 index 0000000000..394003f3a8 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationStepArgs.cs @@ -0,0 +1,12 @@ +using System; + +namespace Unity.GrantManager.Tenants.PostCreation; + +public class PostTenantCreationStepArgs +{ + public Guid TenantId { get; set; } + + /// Index into the ordered + /// list of the step to run next. + public int StepIndex { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs new file mode 100644 index 0000000000..418b20af9a --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Unity.GrantManager.Integrations.Metabase; +using Unity.Modules.Shared.PostTenantCreation; +using Unity.TenantManagement.Metabase; +using Volo.Abp; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Security.Encryption; +using Volo.Abp.Settings; +using Volo.Abp.SettingManagement; +using Volo.Abp.TenantManagement; + +namespace Unity.GrantManager.Tenants.PostCreation.Steps; + +/// +/// Runs as post-tenant-creation step Order = 1 and does everything +/// manual_deploy_new_metabase_tenant.ps1 used to do by hand, minus the OpenShift/psql steps (the +/// tenant's readonly Postgres role + credentials already exist by this point, since +/// EntityFrameworkCoreGrantManagerDbSchemaMigrator provisions them automatically at tenant creation): +/// +/// 1. Connects the tenant's data as a read-only source - decrypts the tenant's stored +/// Tenant_Readonly connection string and calls the Metabase API to create a database +/// connection named after the tenant, then triggers a schema sync + value rescan. +/// 2. Creates a Metabase permissions group named after the tenant and adds the configured member +/// emails to it (see ). A user who isn't already a Metabase +/// user (via LDAP login or Admin > People) is skipped with a warning, not a hard failure. +/// 3. Grants that group unrestricted view/query access to the new database connection, scoped to +/// just this tenant's data. +/// 4. Creates a Metabase collection for the tenant and grants the group write access to it. +/// +/// The member email list comes from ABP Settings: a Global "default" list (editable via the New +/// Tenant modal's Metabase tab) plus any ad-hoc emails added just for this tenant. The resolved +/// list is snapshotted into a tenant-scoped setting at tenant-creation time (by +/// TenantCreatedEventHandler), so this step reads a stable list even if the Global default +/// changes before this (async, queued) step actually runs. +/// +/// is true - a Metabase outage is logged but doesn't block tenant +/// creation or later post-creation steps. +/// +[RemoteService(false)] +[ExposeServices(typeof(IPostTenantCreationStep))] +public class MetabaseTenantRegistrationStep( + IMetabaseApiClient metabaseApiClient, + ITenantRepository tenantRepository, + IStringEncryptionService stringEncryptionService, + ISettingManager settingManager, + IOptions metabaseOptions, + ILogger logger) + : IPostTenantCreationStep, ITransientDependency +{ + private const string LogPrefix = "[PostTenantCreation][Metabase]"; + private const string TenantReadOnlyConnectionStringName = "Tenant_Readonly"; + + public int Order => 1; + + public string StepName => "Metabase Tenant Registration"; + + // A Metabase outage shouldn't block other post-creation steps from running. + public bool ContinueOnError => true; + + public virtual Task CanExecuteAsync(Guid tenantId) + { + if (string.IsNullOrWhiteSpace(metabaseOptions.Value.ApiKey)) + { + logger.LogInformation( + "{Prefix} No Metabase API key configured - skipping registration for tenant {TenantId}.", + LogPrefix, tenantId); + return Task.FromResult(false); + } + + return Task.FromResult(true); + } + + public virtual async Task ExecuteAsync(Guid tenantId) + { + var tenant = await tenantRepository.GetAsync(tenantId, includeDetails: true); + + var encryptedReadOnlyConnectionString = tenant.FindConnectionString(TenantReadOnlyConnectionStringName); + if (string.IsNullOrWhiteSpace(encryptedReadOnlyConnectionString)) + { + logger.LogWarning( + "{Prefix} No readonly connection string found for tenant {TenantId} ('{TenantName}') - skipping.", + LogPrefix, tenantId, tenant.Name); + return; + } + + var (host, port, dbName, username, password) = + ParseConnectionString(stringEncryptionService.Decrypt(encryptedReadOnlyConnectionString)); + + if (!string.IsNullOrWhiteSpace(metabaseOptions.Value.DbHostOverride)) + { + logger.LogInformation( + "{Prefix} Overriding Postgres host '{OriginalHost}' with '{OverrideHost}' for tenant {TenantId} (Metabase:DbHostOverride is set).", + LogPrefix, host, metabaseOptions.Value.DbHostOverride, tenantId); + host = metabaseOptions.Value.DbHostOverride; + } + + var ssl = metabaseOptions.Value.DbSslOverride ?? true; + + var databaseId = await metabaseApiClient.CreateDatabaseAsync(tenant.Name, host, port, dbName, username, password, ssl); + await metabaseApiClient.SyncDatabaseSchemaAsync(databaseId); + await metabaseApiClient.RescanDatabaseValuesAsync(databaseId); + + var groupId = await metabaseApiClient.CreateGroupAsync(tenant.Name); + + foreach (var email in await GetUserEmailsAsync(tenantId)) + { + var userId = await metabaseApiClient.FindUserIdByEmailAsync(email); + if (userId == null) + { + logger.LogWarning( + "{Prefix} User (hash {EmailHash}) not found in Metabase for tenant {TenantId} - they must log in via LDAP or be created under Admin > People before they can be added to a group.", + LogPrefix, HashEmail(email), tenantId); + continue; + } + await metabaseApiClient.AddGroupMemberAsync(groupId, userId.Value); + } + + await metabaseApiClient.GrantGroupDatabaseAccessAsync(groupId, databaseId); + + var collectionId = await metabaseApiClient.CreateCollectionAsync(tenant.Name); + await metabaseApiClient.GrantGroupCollectionAccessAsync(groupId, collectionId); + + logger.LogInformation( + "{Prefix} Registration complete for tenant {TenantId} ('{TenantName}'): database={DatabaseId}, group={GroupId}, collection={CollectionId}", + LogPrefix, tenantId, tenant.Name, databaseId, groupId, collectionId); + } + + private async Task> GetUserEmailsAsync(Guid tenantId) + { + var raw = await settingManager.GetOrNullAsync(MetabaseSettings.UserEmails, TenantSettingValueProvider.ProviderName, tenantId.ToString()) + ?? await settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails); + + return (raw ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + // Avoids writing a user's email address to logs (CodeQL: exposure of private information). + // A substring/mask still contains real characters from the source string, so CodeQL's + // dataflow analysis still treats it as the same private data - a one-way hash is what's + // actually recognized as breaking that taint, while still letting an admin correlate repeated + // "not found" warnings against a known list of emails (by hashing candidates themselves). + private static string HashEmail(string email) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(email.Trim().ToLowerInvariant())))[..8]; + + private static (string Host, int Port, string DbName, string Username, string Password) ParseConnectionString(string connectionString) + { + string? Get(string key) + { + foreach (var part in connectionString.Split(';')) + { + var eq = part.IndexOf('='); + if (eq > 0 && string.Equals(part[..eq].Trim(), key, StringComparison.OrdinalIgnoreCase)) + { + return part[(eq + 1)..].Trim(); + } + } + return null; + } + + var host = Get("Host") ?? throw new InvalidOperationException("Tenant readonly connection string is missing Host."); + var dbName = Get("Database") ?? throw new InvalidOperationException("Tenant readonly connection string is missing Database."); + var username = Get("Username") ?? throw new InvalidOperationException("Tenant readonly connection string is missing Username."); + var password = Get("Password") ?? throw new InvalidOperationException("Tenant readonly connection string is missing Password."); + var port = int.Parse(Get("Port") ?? "5432", CultureInfo.InvariantCulture); + + return (host, port, dbName, username, password); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs index a0d3efd332..879c2d8890 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs @@ -17,4 +17,5 @@ public static class DynamicUrlKeyNames public const string GITHUB_GRAPHQL = "GITHUB_GRAPHQL"; public const string GEOCODER_LOCATION_API_BASE = "GEOCODER_LOCATION_API_BASE"; public const string ANALYTICS_MATOMO_BASE = "ANALYTICS_MATOMO_BASE"; + public const string METABASE_API_BASE = "METABASE_API_BASE"; } \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json index 4469751493..edd337fc6e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json @@ -614,13 +614,15 @@ "OnboardingModal:CreateFailed": "Failed to create tenant. Please try again.", "CreateTenantModal:Title": "Create Tenant", "CreateTenantModal:Ministry": "Ministry", + "CreateTenantModal:Division": "Division", "CreateTenantModal:Branch": "Branch", "CreateTenantModal:ProgramArea": "Program Area", "CreateTenantModal:Category": "Category", "CreateTenantModal:Features": "Features", "CreateTenantModal:FieldMappingLabel": "Field mapping", - "CreateTenantModal:TenantNameFieldLabel": "Tenant name field", - "CreateTenantModal:SuperUsersFieldLabel": "Super users field", + "CreateTenantModal:TenantNameFieldLabel": "Name", + "CreateTenantModal:DisplayNameFieldLabel": "Display Name", + "CreateTenantModal:SuperUsersFieldLabel": "Program Managers", "CreateTenantModal:MappingPlaceholder": "-- Select a field --", "CreateTenantModal:RevalidateButton": "Re-validate", "CreateTenantModal:Validating": "Validating request…", @@ -629,9 +631,18 @@ "CreateTenantModal:CreatingWarningBody": "Tenant provisioning is in progress — please do not close this window or navigate away until the process completes.", "CreateTenantModal:ConfirmButton": "Create Tenant", "CreateTenantModal:NoFieldsWarning": "No worksheet data is available for this request. Tenant creation requires at least one submitted worksheet with field data.", + "CreateTenantModal:DetailsTab": "Details", + "CreateTenantModal:MetabaseTab": "Metabase", + "CreateTenantModal:MetabaseDescription": "Users checked here will be granted access to this tenant's data in Metabase.", + "CreateTenantModal:MetabaseAccountNote": "Note: a user must already have a Metabase account (via SSO login, or created under Admin > People) before they can be added to a group. Emails without an existing account are skipped during registration.", + "CreateTenantModal:MetabaseAddButton": "Add", + "CreateTenantModal:MetabaseSaveAsDefaultLabel": "Save newly added users as default for future tenants", + "CreateTenantModal:MetabaseRemoveDefaultTitle": "Remove from default list", + "CreateTenantModal:MetabaseAlreadyInList": "That user is already in the list.", "TenantList:ActionsButton": "Actions", "TenantList:ConfigurationAction": "Configuration", "TenantList:LicencePlate": "Licence Plate", + "TenantList:DisplayName": "Display Name", "TenantList:CasClientCode": "CAS Client Code", "TenantList:SearchMinChars": "Please enter at least 2 characters to search.", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs index d383550eb0..5b68b508a4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs @@ -37,6 +37,8 @@ public static class DynamicUrls public const string MATOMO_PROD_URL = $"{PROTOCOL}//prod-analytics-matomo.apps.silver.devops.gov.bc.ca"; public const string GITHUB_REPO = $"{PROTOCOL}//github.com/bcgov/Unity"; public const string GITHUB_GRAPHQL = $"{PROTOCOL}//api.github.com/graphql"; + // No separate dev2 hostname - dev2 shares the dev Metabase route. + public const string METABASE_DEV_URL = $"{PROTOCOL}//dev-unity-reporting.apps.gold.devops.gov.bc.ca"; } private static string GetMatomoUrl() @@ -50,6 +52,18 @@ private static string GetMatomoUrl() return DynamicUrls.MATOMO_PROD_URL; } + // Unlike Matomo, only dev has a known route baked in here. Test/UAT/prod are deliberately + // left blank - ops sets the real URL once via the Endpoint Management admin page, and + // (unlike Matomo) it's never overwritten afterward: this only ever inserts a row when one + // doesn't already exist, so whatever's in the database always takes precedence. + private static string GetMetabaseUrl() + { + var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? string.Empty; + return string.IsNullOrEmpty(env) || env.StartsWith("dev", StringComparison.OrdinalIgnoreCase) + ? DynamicUrls.METABASE_DEV_URL + : string.Empty; + } + private async Task SeedDynamicUrlAsync() { if (currentTenant == null || currentTenant.Id == null) @@ -71,6 +85,7 @@ private async Task SeedDynamicUrlAsync() new() { KeyName = DynamicUrlKeyNames.ANALYTICS_MATOMO_BASE, Url = GetMatomoUrl(), Description = "Matomo Analytics" }, new() { KeyName = DynamicUrlKeyNames.GITHUB_REPO, Url = DynamicUrls.GITHUB_REPO, Description = "GitHub Repository" }, new() { KeyName = DynamicUrlKeyNames.GITHUB_GRAPHQL, Url = DynamicUrls.GITHUB_GRAPHQL, Description = "GitHub GraphQL Endpoint" }, + new() { KeyName = DynamicUrlKeyNames.METABASE_API_BASE, Url = GetMetabaseUrl(), Description = "Metabase Reporting API" }, new() { KeyName = $"{DynamicUrlKeyNames.DIRECT_MESSAGE_KEY_PREFIX}{messageIndex++}", Url = "", Description = $"Direct message webhook {messageIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.DIRECT_MESSAGE_KEY_PREFIX}{messageIndex++}", Url = "", Description = $"Direct message webhook {messageIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.DIRECT_MESSAGE_KEY_PREFIX}{messageIndex++}", Url = "", Description = $"Direct message webhook {messageIndex}" }, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json index f317444a18..c80ec41d0d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json @@ -135,11 +135,19 @@ "ReportingAI": { "JWTSecret": "" }, - "Azure": { "OpenAI": { "ApiKey": "", "Endpoint": "" } + }, + "TenantCreation": { + "Steps": { + "Metabase": { + "ApiKey": "", + "DbHostOverride": "", + "DbSslOverride": false + } + } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json index 4a3b8af562..91383e7d3e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json @@ -159,8 +159,16 @@ }, "OpenAI": { "ApiKey": "", - "Endpoint": "" + "Endpoint": "" }, "UNITY_GITHUB_PAT": "" + }, + "TenantCreation": { + "Steps": { + "Metabase": { + "ApiKey": "", + "DbHostOverride": "" + } + } } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Integrations/Metabase/MetabaseApiClientTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Integrations/Metabase/MetabaseApiClientTests.cs new file mode 100644 index 0000000000..ad2589ef05 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Integrations/Metabase/MetabaseApiClientTests.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using NSubstitute; +using Shouldly; +using Unity.GrantManager.Integrations.Exceptions; +using Unity.Modules.Shared.Http; +using Xunit; + +namespace Unity.GrantManager.Integrations.Metabase; + +public class MetabaseApiClientTests +{ + private const string BaseUrl = "https://metabase.example"; + + private static (MetabaseApiClient Client, IResilientHttpRequest Http) CreateClient() + { + var http = Substitute.For(); + var endpointService = Substitute.For(); + endpointService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.METABASE_API_BASE).Returns(BaseUrl); + var options = Options.Create(new MetabaseOptions { ApiKey = "test-api-key" }); + + return (new MetabaseApiClient(http, endpointService, options), http); + } + + private static HttpResponseMessage JsonResponse(HttpStatusCode status, string json) => + new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + + private static void SetupHttpSequence(IResilientHttpRequest http, params HttpResponseMessage[] responses) => + http.HttpAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any<(string username, string password)?>(), Arg.Any(), + Arg.Any?>(), Arg.Any()) + .Returns(responses[0], responses[1..]); + + [Fact] + public async Task GrantGroupDatabaseAccessAsync_NoConflict_SucceedsOnFirstAttempt() + { + var (client, http) = CreateClient(); + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":1}"), + JsonResponse(HttpStatusCode.OK, "{}")); + + await client.GrantGroupDatabaseAccessAsync(groupId: 5, databaseId: 11); + + await http.Received(2).HttpAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any<(string username, string password)?>(), Arg.Any(), + Arg.Any?>(), Arg.Any()); + } + + [Fact] + public async Task GrantGroupDatabaseAccessAsync_StaleRevisionOnFirstPut_RefetchesGraphAndRetries() + { + var (client, http) = CreateClient(); + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":1}"), // GET #1 + JsonResponse(HttpStatusCode.Conflict, "{\"message\":\"stale\"}"), // PUT #1 - stale revision + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":2}"), // GET #2 (retry re-fetch) + JsonResponse(HttpStatusCode.OK, "{}")); // PUT #2 - succeeds + + await Should.NotThrowAsync(() => client.GrantGroupDatabaseAccessAsync(groupId: 5, databaseId: 11)); + + await http.Received(4).HttpAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any<(string username, string password)?>(), Arg.Any(), + Arg.Any?>(), Arg.Any()); + } + + [Fact] + public async Task GrantGroupDatabaseAccessAsync_ConflictOnEveryAttempt_ThrowsAfterMaxAttempts() + { + var (client, http) = CreateClient(); + // 3 attempts allowed: GET/PUT-conflict x3 (6 calls total), all conflicting. + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":1}"), + JsonResponse(HttpStatusCode.Conflict, "{}"), + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":2}"), + JsonResponse(HttpStatusCode.Conflict, "{}"), + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":3}"), + JsonResponse(HttpStatusCode.Conflict, "{}")); + + await Should.ThrowAsync( + () => client.GrantGroupDatabaseAccessAsync(groupId: 5, databaseId: 11)); + + await http.Received(6).HttpAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any<(string username, string password)?>(), Arg.Any(), + Arg.Any?>(), Arg.Any()); + } + + [Fact] + public async Task GrantGroupDatabaseAccessAsync_NonConflictFailure_ThrowsWithoutRetrying() + { + var (client, http) = CreateClient(); + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":1}"), + JsonResponse(HttpStatusCode.InternalServerError, "{}")); + + await Should.ThrowAsync( + () => client.GrantGroupDatabaseAccessAsync(groupId: 5, databaseId: 11)); + + // Only the initial GET + PUT - a non-conflict failure isn't retried at this layer. + await http.Received(2).HttpAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any<(string username, string password)?>(), Arg.Any(), + Arg.Any?>(), Arg.Any()); + } + + [Fact] + public async Task GrantGroupCollectionAccessAsync_StaleRevisionOnFirstPut_RefetchesGraphAndRetries() + { + var (client, http) = CreateClient(); + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":1}"), + JsonResponse(HttpStatusCode.BadRequest, "{}"), + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":2}"), + JsonResponse(HttpStatusCode.OK, "{}")); + + await Should.NotThrowAsync(() => client.GrantGroupCollectionAccessAsync(groupId: 5, collectionId: 22)); + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/PostTenantCreationSequenceJobTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/PostTenantCreationSequenceJobTests.cs new file mode 100644 index 0000000000..a13e112e85 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/PostTenantCreationSequenceJobTests.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Unity.Modules.Shared.PostTenantCreation; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Unity.GrantManager.Tenants.PostCreation; + +public class PostTenantCreationSequenceJobTests +{ + private sealed class FakeStep(int order, string name, bool continueOnError, Func? onExecute = null, bool canExecute = true) + : IPostTenantCreationStep + { + public int Order { get; } = order; + public string StepName { get; } = name; + public bool ContinueOnError { get; } = continueOnError; + public bool Executed { get; private set; } + + public Task CanExecuteAsync(Guid tenantId) => Task.FromResult(canExecute); + + public async Task ExecuteAsync(Guid tenantId) + { + Executed = true; + if (onExecute != null) + { + await onExecute(tenantId); + } + } + } + + private static (PostTenantCreationSequenceJob Job, List Enqueued) CreateJob( + IEnumerable steps) + { + var enqueued = new List(); + var backgroundJobManager = Substitute.For(); + backgroundJobManager.EnqueueAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(callInfo => + { + var args = callInfo.Arg(); + ArgumentNullException.ThrowIfNull(args); + enqueued.Add(args); + return Task.FromResult(string.Empty); + }); + + var currentTenant = Substitute.For(); + currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + + var job = new PostTenantCreationSequenceJob( + steps, backgroundJobManager, currentTenant, Substitute.For>()); + + return (job, enqueued); + } + + [Fact] + public async Task ExecuteAsync_RunsStepAtIndex_AndEnqueuesNextStepIndex() + { + var tenantId = Guid.NewGuid(); + var step0 = new FakeStep(0, "Step0", continueOnError: false); + var step1 = new FakeStep(1, "Step1", continueOnError: false); + var (job, enqueued) = CreateJob([step0, step1]); + + await job.ExecuteAsync(new PostTenantCreationStepArgs { TenantId = tenantId, StepIndex = 0 }); + + step0.Executed.ShouldBeTrue(); + step1.Executed.ShouldBeFalse(); + var next = enqueued.ShouldHaveSingleItem(); + next.TenantId.ShouldBe(tenantId); + next.StepIndex.ShouldBe(1); + } + + [Fact] + public async Task ExecuteAsync_StepIndexPastEnd_DoesNothing() + { + var (job, enqueued) = CreateJob([new FakeStep(0, "Step0", continueOnError: false)]); + + await job.ExecuteAsync(new PostTenantCreationStepArgs { TenantId = Guid.NewGuid(), StepIndex = 1 }); + + enqueued.ShouldBeEmpty(); + } + + [Fact] + public async Task ExecuteAsync_StepThrows_ContinueOnErrorTrue_StillEnqueuesNextStep() + { + var step = new FakeStep(0, "Flaky", continueOnError: true, onExecute: _ => throw new InvalidOperationException("boom")); + var (job, enqueued) = CreateJob([step]); + + await job.ExecuteAsync(new PostTenantCreationStepArgs { TenantId = Guid.NewGuid(), StepIndex = 0 }); + + enqueued.ShouldHaveSingleItem(); + } + + [Fact] + public async Task ExecuteAsync_StepThrows_ContinueOnErrorFalse_StopsSequence() + { + var step = new FakeStep(0, "Fatal", continueOnError: false, onExecute: _ => throw new InvalidOperationException("boom")); + var (job, enqueued) = CreateJob([step]); + + await job.ExecuteAsync(new PostTenantCreationStepArgs { TenantId = Guid.NewGuid(), StepIndex = 0 }); + + enqueued.ShouldBeEmpty(); + } + + [Fact] + public async Task ExecuteAsync_CanExecuteAsyncReturnsFalse_SkipsExecuteButStillEnqueuesNextStep() + { + var step0 = new FakeStep(0, "Step0", continueOnError: false, canExecute: false); + var step1 = new FakeStep(1, "Step1", continueOnError: false); + var (job, enqueued) = CreateJob([step0, step1]); + + await job.ExecuteAsync(new PostTenantCreationStepArgs { TenantId = Guid.NewGuid(), StepIndex = 0 }); + + step0.Executed.ShouldBeFalse(); + var next = enqueued.ShouldHaveSingleItem(); + next.StepIndex.ShouldBe(1); + } + + [Fact] + public async Task ExecuteAsync_RunsSteps_InAscendingOrder_RegardlessOfRegistrationOrder() + { + var executed = new List(); + var stepHigh = new FakeStep(2, "High", continueOnError: false, + onExecute: _ => { executed.Add("High"); return Task.CompletedTask; }); + var stepLow = new FakeStep(1, "Low", continueOnError: false, + onExecute: _ => { executed.Add("Low"); return Task.CompletedTask; }); + var (job, _) = CreateJob([stepHigh, stepLow]); + + await job.ExecuteAsync(new PostTenantCreationStepArgs { TenantId = Guid.NewGuid(), StepIndex = 0 }); + + executed.ShouldBe(["Low"]); + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStepTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStepTests.cs new file mode 100644 index 0000000000..f5efbee7f2 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStepTests.cs @@ -0,0 +1,175 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using Shouldly; +using Unity.GrantManager.Integrations.Metabase; +using Unity.TenantManagement.Metabase; +using Volo.Abp.Security.Encryption; +using Volo.Abp.Settings; +using Volo.Abp.SettingManagement; +using Volo.Abp.TenantManagement; +using Xunit; + +namespace Unity.GrantManager.Tenants.PostCreation.Steps; + +public class MetabaseTenantRegistrationStepTests +{ + private const string ReadOnlyConnectionStringName = "Tenant_Readonly"; + private const string DecryptedConnectionString = + "Host=dev-crunchy-postgres-primary.ce395f-dev.svc;Port=5432;Database=T_ABC123;Username=t_abc123_readonly;Password=s3cr3t;"; + + // Tenant's constructors are all non-public (ABP requires going through ITenantManager to + // create one) - reflection is the standard workaround for exercising its instance state + // (SetConnectionString/FindConnectionString) in a plain, DB-less unit test. + private static Tenant CreateTenant(string name) + { + var ctor = typeof(Tenant).GetConstructor( + BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(Guid), typeof(string), typeof(string)], null)!; + return (Tenant)ctor.Invoke([Guid.NewGuid(), name, name.ToUpperInvariant()]); + } + + private static (MetabaseTenantRegistrationStep Step, IMetabaseApiClient MetabaseApiClient, ISettingManager SettingManager, Tenant Tenant) + CreateStep(string? encryptedReadOnlyConnectionString = "encrypted-blob", string? apiKey = "test-api-key", string? dbHostOverride = null, bool? dbSslOverride = null) + { + var tenant = CreateTenant("AG-MARB"); + if (encryptedReadOnlyConnectionString != null) + { + tenant.SetConnectionString(ReadOnlyConnectionStringName, encryptedReadOnlyConnectionString); + } + + var tenantRepository = Substitute.For(); + tenantRepository.GetAsync(tenant.Id, Arg.Any(), Arg.Any()).Returns(tenant); + + var encryptionService = Substitute.For(); + encryptionService.Decrypt(Arg.Any()).Returns(DecryptedConnectionString); + + var settingManager = Substitute.For(); + var metabaseApiClient = Substitute.For(); + var metabaseOptions = Options.Create(new MetabaseOptions + { + ApiKey = apiKey ?? string.Empty, + DbHostOverride = dbHostOverride ?? string.Empty, + DbSslOverride = dbSslOverride + }); + + var step = new MetabaseTenantRegistrationStep( + metabaseApiClient, tenantRepository, encryptionService, settingManager, metabaseOptions, + Substitute.For>()); + + return (step, metabaseApiClient, settingManager, tenant); + } + + [Fact] + public void ContinueOnError_IsTrue_SoAMetabaseOutageDoesNotBlockLaterSteps() + { + var (step, _, _, _) = CreateStep(); + + step.ContinueOnError.ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CanExecuteAsync_NoApiKeyConfigured_ReturnsFalse(string? apiKey) + { + var (step, _, _, tenant) = CreateStep(apiKey: apiKey); + + (await step.CanExecuteAsync(tenant.Id)).ShouldBeFalse(); + } + + [Fact] + public async Task CanExecuteAsync_ApiKeyConfigured_ReturnsTrue() + { + var (step, _, _, tenant) = CreateStep(); + + (await step.CanExecuteAsync(tenant.Id)).ShouldBeTrue(); + } + + [Fact] + public async Task ExecuteAsync_NoReadonlyConnectionString_SkipsWithoutCallingMetabase() + { + var (step, metabaseApiClient, _, tenant) = CreateStep(encryptedReadOnlyConnectionString: null); + + await step.ExecuteAsync(tenant.Id); + + await metabaseApiClient.DidNotReceiveWithAnyArgs().CreateDatabaseAsync(default!, default!, default, default!, default!, default!, default); + } + + [Fact] + public async Task ExecuteAsync_CreatesDatabaseGroupAndCollection_UsingParsedConnectionDetailsAndConfiguredEmails() + { + var (step, metabaseApiClient, settingManager, tenant) = CreateStep(); + settingManager.GetOrNullAsync(MetabaseSettings.UserEmails, TenantSettingValueProvider.ProviderName, tenant.Id.ToString()) + .Returns((string?)null); + settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails).Returns("user1@gov.bc.ca,user2@gov.bc.ca"); + + metabaseApiClient.CreateDatabaseAsync( + tenant.Name, "dev-crunchy-postgres-primary.ce395f-dev.svc", 5432, "T_ABC123", "t_abc123_readonly", "s3cr3t", true) + .Returns(11); + metabaseApiClient.CreateGroupAsync(tenant.Name).Returns(22); + metabaseApiClient.FindUserIdByEmailAsync("user1@gov.bc.ca").Returns(101); + metabaseApiClient.FindUserIdByEmailAsync("user2@gov.bc.ca").Returns((int?)null); + metabaseApiClient.CreateCollectionAsync(tenant.Name).Returns(33); + + await step.ExecuteAsync(tenant.Id); + + await metabaseApiClient.Received(1).SyncDatabaseSchemaAsync(11); + await metabaseApiClient.Received(1).RescanDatabaseValuesAsync(11); + await metabaseApiClient.Received(1).AddGroupMemberAsync(22, 101); + await metabaseApiClient.DidNotReceive().AddGroupMemberAsync(22, Arg.Is(id => id != 101)); + await metabaseApiClient.Received(1).GrantGroupDatabaseAccessAsync(22, 11); + await metabaseApiClient.Received(1).GrantGroupCollectionAccessAsync(22, 33); + } + + [Fact] + public async Task ExecuteAsync_DbHostOverrideConfigured_UsesOverrideHostInsteadOfConnectionStringHost() + { + var (step, metabaseApiClient, settingManager, tenant) = CreateStep(dbHostOverride: "host.docker.internal"); + settingManager.GetOrNullAsync(MetabaseSettings.UserEmails, TenantSettingValueProvider.ProviderName, tenant.Id.ToString()) + .Returns((string?)null); + settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails).Returns((string?)null); + metabaseApiClient.CreateGroupAsync(tenant.Name).Returns(22); + metabaseApiClient.CreateCollectionAsync(tenant.Name).Returns(33); + + await step.ExecuteAsync(tenant.Id); + + await metabaseApiClient.Received(1).CreateDatabaseAsync( + tenant.Name, "host.docker.internal", 5432, "T_ABC123", "t_abc123_readonly", "s3cr3t", true); + } + + [Fact] + public async Task ExecuteAsync_DbSslOverrideFalse_DisablesSslForDatabaseConnection() + { + var (step, metabaseApiClient, settingManager, tenant) = CreateStep(dbSslOverride: false); + settingManager.GetOrNullAsync(MetabaseSettings.UserEmails, TenantSettingValueProvider.ProviderName, tenant.Id.ToString()) + .Returns((string?)null); + settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails).Returns((string?)null); + metabaseApiClient.CreateGroupAsync(tenant.Name).Returns(22); + metabaseApiClient.CreateCollectionAsync(tenant.Name).Returns(33); + + await step.ExecuteAsync(tenant.Id); + + await metabaseApiClient.Received(1).CreateDatabaseAsync( + tenant.Name, "dev-crunchy-postgres-primary.ce395f-dev.svc", 5432, "T_ABC123", "t_abc123_readonly", "s3cr3t", false); + } + + [Fact] + public async Task ExecuteAsync_TenantScopedUserEmailsSetting_TakesPrecedenceOverGlobalDefault() + { + var (step, metabaseApiClient, settingManager, tenant) = CreateStep(); + settingManager.GetOrNullAsync(MetabaseSettings.UserEmails, TenantSettingValueProvider.ProviderName, tenant.Id.ToString()) + .Returns("tenant-scoped@gov.bc.ca"); + metabaseApiClient.CreateGroupAsync(tenant.Name).Returns(22); + metabaseApiClient.FindUserIdByEmailAsync(Arg.Any()).Returns((int?)null); + + await step.ExecuteAsync(tenant.Id); + + await metabaseApiClient.Received(1).FindUserIdByEmailAsync("tenant-scoped@gov.bc.ca"); + await settingManager.DidNotReceive().GetOrNullGlobalAsync(MetabaseSettings.UserEmails); + } +}