diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/ITenantViewRoleAppService.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/ITenantViewRoleAppService.cs index 443b6731cd..a0ac9914e4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/ITenantViewRoleAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/ITenantViewRoleAppService.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Threading.Tasks; namespace Unity.Reporting.Configuration @@ -12,14 +11,16 @@ namespace Unity.Reporting.Configuration public interface ITenantViewRoleAppService { /// - /// Retrieves the view role configuration for all tenants in the system. - /// Returns both explicitly configured roles and default inferred roles based on tenant naming patterns. + /// Retrieves the view role configuration for a specific tenant. + /// Returns either the explicitly configured role or a default inferred role based on the tenant's name. /// + /// The unique identifier of the tenant to retrieve the view role configuration for. /// - /// A list of objects containing the tenant information and their associated view roles. - /// Default roles follow the pattern {tenantname}_readonly when not explicitly configured. + /// A containing the tenant information and its associated view role. + /// Defaults to {LicencePlate}_readonly when a licence plate exists, falling back to {tenantname}_readonly for legacy tenants. /// - Task> GetAllAsync(); + /// Thrown when the specified tenant is not found. + Task GetAsync(Guid tenantId); /// /// Updates the view role configuration for a specific tenant. diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/TenantViewRoleDto.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/TenantViewRoleDto.cs index 2012ef9dc6..02272962c9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/TenantViewRoleDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/TenantViewRoleDto.cs @@ -20,15 +20,45 @@ public class TenantViewRoleDto /// /// Gets or sets the database role name that will be granted SELECT permissions on reporting views for this tenant. - /// Defaults to {tenantname}_readonly if not explicitly configured. + /// Defaults to the tenant's {LicencePlate}_readonly role when a license plate is on record + /// (the role automatically provisioned for new tenants), falling back to the legacy + /// {tenantname}_readonly pattern for older tenants with no license plate, unless a role has + /// been explicitly saved for this tenant. /// public string ViewRole { get; set; } = string.Empty; /// /// Gets or sets a value indicating whether the current ViewRole value is an inferred default /// that has not been explicitly saved to the database. When true, indicates the role name - /// follows the default pattern (e.g., {tenantname}_readonly) and requires explicit saving - /// to persist as a tenant-specific setting. + /// follows a default pattern (see ) and requires explicit saving to + /// persist as a tenant-specific setting. /// public bool IsDefaultInferred { get; set; } + + /// + /// Gets or sets the tenant's license plate (its database name, e.g. "T_ABC123"), used since + /// tenant provisioning to name the tenant's two automatically-created database roles: the + /// license plate itself (read-write) and {LicencePlate}_readonly. Null for tenants that + /// predate this convention. + /// + public string? LicencePlate { get; set; } + + /// + /// Gets or sets the {LicencePlate}_readonly role name expected to exist for this tenant. Null + /// when is null. + /// + public string? ExpectedReadOnlyRole { get; set; } + + /// + /// Gets or sets whether actually exists as a role in the + /// tenant's database, checked live. Always false when is null. + /// + public bool ReadOnlyRoleExists { get; set; } + + /// + /// Gets or sets whether the tenant's main (read-write) role - named after + /// - actually exists in the tenant's database, checked live. + /// Always false when is null. + /// + public bool MainRoleExists { get; set; } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/TenantViewRoleAppService.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/TenantViewRoleAppService.cs index e434779455..dc1a5a3ef8 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/TenantViewRoleAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/TenantViewRoleAppService.cs @@ -7,6 +7,7 @@ using Unity.Reporting.BackgroundJobs; using Unity.Reporting.Domain.Configuration; using Unity.Reporting.Settings; +using Volo.Abp; using Volo.Abp.Application.Services; using Volo.Abp.BackgroundJobs; using Volo.Abp.MultiTenancy; @@ -31,37 +32,68 @@ public class TenantViewRoleAppService( IReportColumnsMapRepository reportColumnsMapRepository, ICurrentTenant currentTenant) : ApplicationService, ITenantViewRoleAppService { + private const string LicencePlateExtraPropertyKey = "LicencePlate"; + /// - /// Retrieves all tenant view role configurations. - /// Returns a list of all tenants with their current view role assignments, defaulting to {tenantname}_readonly if not configured. + /// Retrieves the view role configuration for a specific tenant, along with the live existence + /// state of the two roles automatically provisioned for the tenant's license plate (its + /// read-write role and {LicencePlate}_readonly). /// - public async Task> GetAllAsync() + public async Task GetAsync(Guid tenantId) { - var tenants = await tenantRepository.GetListAsync(); - var tenantViewRoles = new List(); + var tenant = await tenantRepository.GetAsync(tenantId); + + var licencePlate = tenant.ExtraProperties.TryGetValue(LicencePlateExtraPropertyKey, out var lp) + ? lp?.ToString() + : null; + var expectedReadOnlyRole = string.IsNullOrWhiteSpace(licencePlate) ? null : $"{licencePlate}_readonly"; + + // An explicitly saved role always wins (covers legacy tenants whose role doesn't follow + // either naming convention). Otherwise prefer the license-plate readonly role - the one + // actually provisioned for new tenants - falling back to the legacy {tenantname}_readonly + // pattern only when there's no license plate on record at all. + var savedViewRole = await settingManager.GetOrNullAsync(ReportingSettings.TenantViewRole, "T", tenant.Id.ToString()); - foreach (var tenant in tenants) + string viewRole; + bool isDefaultInferred; + if (!string.IsNullOrEmpty(savedViewRole)) + { + viewRole = savedViewRole; + isDefaultInferred = false; + } + else if (expectedReadOnlyRole != null) + { + viewRole = expectedReadOnlyRole; + isDefaultInferred = true; + } + else { - // Get tenant-specific setting first, fallback to default pattern - var viewRole = await settingManager.GetOrNullAsync(ReportingSettings.TenantViewRole, "T", tenant.Id.ToString()); + viewRole = $"{tenant.Name.ToLowerInvariant()}_readonly"; + isDefaultInferred = true; + } - bool isDefaultInferred = false; - if (string.IsNullOrEmpty(viewRole)) + bool readOnlyRoleExists = false; + bool mainRoleExists = false; + if (!string.IsNullOrWhiteSpace(licencePlate)) + { + using (currentTenant.Change(tenantId)) { - viewRole = $"{tenant.Name.ToLowerInvariant()}_readonly"; - isDefaultInferred = true; + readOnlyRoleExists = await reportColumnsMapRepository.RoleExistsAsync(expectedReadOnlyRole!); + mainRoleExists = await reportColumnsMapRepository.RoleExistsAsync(licencePlate); } - - tenantViewRoles.Add(new TenantViewRoleDto - { - TenantId = tenant.Id, - TenantName = tenant.Name, - ViewRole = viewRole, - IsDefaultInferred = isDefaultInferred - }); } - return tenantViewRoles; + return new TenantViewRoleDto + { + TenantId = tenant.Id, + TenantName = tenant.Name, + ViewRole = viewRole, + IsDefaultInferred = isDefaultInferred, + LicencePlate = licencePlate, + ExpectedReadOnlyRole = expectedReadOnlyRole, + ReadOnlyRoleExists = readOnlyRoleExists, + MainRoleExists = mainRoleExists + }; } /// @@ -72,6 +104,8 @@ public async Task UpdateAsync(Guid tenantId, UpdateTenantView { var tenant = await tenantRepository.GetAsync(tenantId); + await EnsureRoleExistsAsync(tenantId, input.ViewRole); + await settingManager.SetAsync(ReportingSettings.TenantViewRole, input.ViewRole, "T", tenantId.ToString()); return new TenantViewRoleDto @@ -91,6 +125,17 @@ public async Task AssignRoleToViewsAsync(Guid tenantId) { Logger.LogInformation("Starting role assignment for tenant: {TenantId}", tenantId); + var role = await settingManager.GetOrNullAsync(ReportingSettings.TenantViewRole, "T", tenantId.ToString()); + if (string.IsNullOrWhiteSpace(role)) + { + throw new UserFriendlyException("No view role is configured for this tenant yet. Save a role first."); + } + + // Fail fast here rather than relying solely on AssignViewRoleBackgroundJob's own check - + // that check only logs a warning and silently no-ops, so without this the queue call + // always reports success even for a role that doesn't exist in the tenant's database. + await EnsureRoleExistsAsync(tenantId, role); + var jobArgs = new AssignViewRoleBackgroundJobArgs { TenantId = tenantId @@ -100,6 +145,22 @@ public async Task AssignRoleToViewsAsync(Guid tenantId) Logger.LogInformation("Queued role assignment job for tenant: {TenantId}", tenantId); } + /// + /// Throws a if the given role does not exist as a real + /// PostgreSQL role in the tenant's own database. + /// + private async Task EnsureRoleExistsAsync(Guid tenantId, string role) + { + using (currentTenant.Change(tenantId)) + { + if (!await reportColumnsMapRepository.RoleExistsAsync(role)) + { + throw new UserFriendlyException( + $"Role '{role}' does not exist in this tenant's database. Create the role first, or choose an existing one from View DB Info."); + } + } + } + /// /// Retrieves database information for a specific tenant, including available roles and reporting views. /// This method queries the tenant's database to return a comprehensive list of database roles diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs deleted file mode 100644 index 0819c74972..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Threading.Tasks; -using Unity.Modules.Shared.Navigation; -using Unity.Modules.Shared.Permissions; -using Volo.Abp.UI.Navigation; - -namespace Unity.Reporting.Web.Menus; - -/// -/// ABP Framework menu contributor for the Unity.Reporting module navigation system. -/// Responsible for adding reporting-related menu items to the application's main navigation, -/// including administrative pages for reconciliation and reporting configuration management. -/// All menu items require IT Admin permissions for security and proper access control. -/// -public class ReportingMenuContributor : IMenuContributor -{ - /// - /// Configures the application menu by adding Unity.Reporting module menu items. - /// Delegates to the private method to set up reporting-specific navigation items - /// with appropriate permissions and routing configuration. - /// - /// The menu configuration context containing the menu to be configured. - /// A task representing the asynchronous menu configuration operation. - public async Task ConfigureMenuAsync(MenuConfigurationContext context) - { - await ConfigureReportingMenuAsync(context); - } - - /// - /// Configures reporting-specific menu items including reconciliation and reporting configuration pages. - /// Adds navigation items with IT Admin permission requirements and proper routing to ensure - /// administrative functionality is accessible only to authorized users with appropriate permissions. - /// - /// The menu configuration context for adding reporting menu items. - /// A completed task representing the synchronous menu item addition operations. - private static async Task ConfigureReportingMenuAsync(MenuConfigurationContext context) - { - // Add Reporting Configuration menu item for IT Admin users - await context.AddItemAsync( - new ApplicationMenuItem( - ReportingMenus.Prefix, - displayName: "Reporting", - "~/ReportingAdmin/Configuration") - .OnlyWhenInRole(IdentityConsts.ITAdminRoleName) - ); - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenus.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenus.cs deleted file mode 100644 index 5e55024442..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenus.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Unity.Reporting.Web.Menus; - -/// -/// Static class containing menu identifier constants for the Unity.Reporting web navigation system. -/// Defines standardized menu prefixes and identifiers used throughout the reporting module's -/// navigation infrastructure to ensure consistent menu organization and avoid naming conflicts -/// with other modules in the Unity application ecosystem. -/// -public static class ReportingMenus -{ - /// - /// The base prefix for all Unity.Reporting menu items and navigation elements. - /// Used to namespace reporting-related menu items and ensure they don't conflict - /// with menu items from other modules in the application. - /// - public const string Prefix = "Reporting"; -} diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.cshtml deleted file mode 100644 index dd0b637f99..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.cshtml +++ /dev/null @@ -1,97 +0,0 @@ -@page -@model Unity.Reporting.Web.Pages.ReportingAdmin.IndexModel -@{ - ViewData["Title"] = "Reporting Configuration"; -} - -@section styles -{ - -} -@section scripts -{ - -} - -
-
-

Reporting Configuration

-
-
- -
-
Tenant View Role Management
-

Configure tenant-specific database roles for reporting views. Each tenant can have a custom role, or will default to {tenantname}_readonly if not specified.

- -
- - - - - - - - - - @foreach (var tenantRole in Model.TenantViewRoles) - { - - - - - - } - -
Tenant NameView RoleActions
@tenantRole.TenantName -
- - @if (tenantRole.IsDefaultInferred) - { - - } -
-
-
- - - -
-
-
- - @if (!Model.TenantViewRoles.Any()) - { -
-

No tenants found.

-
- } -
-
-
- diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.cshtml.cs deleted file mode 100644 index f8fcf1ee3e..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.cshtml.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Unity.Modules.Shared.Permissions; -using Unity.Reporting.Configuration; - -namespace Unity.Reporting.Web.Pages.ReportingAdmin -{ - /// - /// Razor Page model for the Reporting Administration configuration interface. - /// Provides functionality for IT administrators to manage tenant-specific reporting settings including - /// database role configuration for view access control per tenant. Displays tenant view role configurations - /// in a management table interface for easy administration. - /// Requires IT Admin permissions for all operations to ensure secure configuration management. - /// - [Authorize(IdentityConsts.ITAdminPermissionName)] - public class IndexModel : ReportingPageModel - { - private readonly ITenantViewRoleAppService _tenantViewRoleAppService; - - /// - /// Gets or sets the list of tenant view role configurations for display in the DataTable. - /// Contains all tenants with their current view role assignments for the management interface. - /// - public List TenantViewRoles { get; set; } = new(); - - /// - /// Initializes a new instance of the IndexModel with required dependency injection services. - /// Sets up the tenant view role service for managing per-tenant configurations. - /// - /// The application service for tenant-specific view role management. - public IndexModel(ITenantViewRoleAppService tenantViewRoleAppService) - { - _tenantViewRoleAppService = tenantViewRoleAppService; - } - - /// - /// Handles GET requests to display the reporting configuration page with current settings. - /// Loads all tenant view role configurations for display in the management interface. - /// - /// A task representing the asynchronous page loading operation. - public async Task OnGetAsync() - { - TenantViewRoles = await _tenantViewRoleAppService.GetAllAsync(); - } - - /// - /// AJAX handler to get tenant database information including roles and views. - /// - /// The tenant ID to get database information for. - /// JSON result containing tenant database information. - public async Task OnGetTenantDatabaseInfoAsync(Guid tenantId) - { - try - { - var databaseInfo = await _tenantViewRoleAppService.GetTenantDatabaseInfoAsync(tenantId); - return new JsonResult(new { success = true, data = databaseInfo }); - } - catch (Exception ex) - { - return new JsonResult(new { success = false, error = ex.Message }); - } - } - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.css b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.css deleted file mode 100644 index df3a7d6839..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.css +++ /dev/null @@ -1,219 +0,0 @@ -.run-sync-jobs { - padding: 1rem; -} - -.reporting-configuration-section { - background-color: #f8f9fa; - border: 1px solid #dee2e6; - border-radius: 0.375rem; - padding: 1.5rem; - margin-bottom: 1.5rem; -} - -.reporting-configuration-section h5 { - color: #495057; - border-bottom: 1px solid #dee2e6; - padding-bottom: 0.5rem; - margin-bottom: 1rem; -} - -.unity-page-titlebar { - border-bottom: 1px solid #dee2e6; - margin-bottom: 1rem; - padding-bottom: 1rem; -} - -.d-flex { - display: flex; -} - -.gap-2 { - gap: 0.5rem; -} - -/* Tenant View Role Management Styles */ -.view-role-input { - width: 100%; - padding: 0.375rem 0.75rem; - font-size: 1rem; - line-height: 1.5; - border: 1px solid #ced4da; - border-radius: 0.25rem; - min-width: 200px; - max-width: 300px; - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} - -.view-role-input:focus { - border-color: #80bdff; - outline: 0; - box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); -} - -.action-buttons-cell .btn { - min-width: 80px; - padding: 0.5rem 1rem; - font-weight: 500; - transition: all 0.15s ease-in-out; -} - -.default-role-indicator { - flex-shrink: 0; - cursor: help; - font-size: 1rem; -} - -.default-role-indicator:hover { - color: #ff6b35 !important; -} - -.view-role-cell .d-flex { - align-items: center; -} - -/* Table header styling - match the user config pattern */ -#TenantViewRoleTable thead th { - background-color: #55698A !important; - color: white !important; - font-weight: 500 !important; - font-size: 18px !important; - text-align: left !important; - border: none; -} - -#TenantViewRoleTable thead th:hover { - background-color: #3E4C63 !important; - color: white !important; -} - -/* Sort arrow styling for headers */ -#TenantViewRoleTable table.dataTable thead th.sorting:before, -#TenantViewRoleTable table.dataTable thead th.sorting:after, -#TenantViewRoleTable table.dataTable thead th.sorting_asc:before, -#TenantViewRoleTable table.dataTable thead th.sorting_asc:after, -#TenantViewRoleTable table.dataTable thead th.sorting_desc:before, -#TenantViewRoleTable table.dataTable thead th.sorting_desc:after { - color: white !important; -} - -#TenantViewRoleTable { - margin-top: 1rem; -} - -#TenantViewRoleTable tbody tr:hover { - background-color: #f1f3f5; -} - -/* DataTables wrapper styling - match general pattern */ -.dataTables_wrapper .dataTables_filter { - margin-bottom: 1rem; -} - -.dataTables_wrapper .dataTables_filter input { - width: 100%; - margin-left: 0.5rem; - border-radius: 0.25rem; - border: 1px solid #ced4da; - padding: 0.375rem 0.75rem; - font-size: 1rem; - line-height: 1.5; - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; -} - -.dataTables_wrapper .dataTables_filter input:focus { - border-color: #80bdff; - outline: 0; - box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); -} - -.table-responsive { - border-radius: 0.375rem; - border: 1px solid #dee2e6; -} - -/* Tooltip styling */ -.tooltip { - font-size: 0.875rem; -} - -.tooltip-inner { - max-width: 300px; - padding: 0.5rem 0.75rem; - background-color: #343a40; - border-radius: 0.375rem; -} - -/* Action bar styling - add consistency */ -.action-bar { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - margin-bottom: 1rem; -} - -.filter-search-action-bar_search-wrapper { - flex: 1; - max-width: 400px; -} - -.tbl-search { - width: 100%; -} - -/* Responsive Design */ -@media (max-width: 768px) { - .d-flex.gap-2 { - flex-direction: column; - gap: 0.5rem !important; - } - - .action-buttons-cell .btn { - min-width: auto; - width: 100%; - padding: 0.375rem 0.75rem; - font-size: 0.875rem; - } - - .view-role-input { - min-width: auto; - max-width: none; - width: 100%; - } - - .view-role-cell .d-flex { - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; - } - - .default-role-indicator { - align-self: flex-end; - } - - .action-bar { - flex-direction: column; - gap: 0.5rem; - } - - .action-bar .btn { - width: 100%; - } - - /* Table header responsive adjustments */ - #TenantViewRoleTable thead th { - font-size: 16px !important; - } -} - -@media (max-width: 576px) { - .view-role-input { - font-size: 0.875rem; - padding: 0.25rem 0.5rem; - } - - #TenantViewRoleTable thead th { - font-size: 14px !important; - padding: 0.5rem 0.25rem; - } -} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.js b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.js deleted file mode 100644 index 7abdccfd0c..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.js +++ /dev/null @@ -1,180 +0,0 @@ -$(function () { - let _tenantViewRoleAppService = unity.reporting.configuration.tenantViewRole; - let _databaseInfoModal = new abp.ModalManager({ - viewUrl: abp.appPath + 'ReportingAdmin/Configuration/DatabaseInfoModal' - }); - - // Initialize tooltips for default role indicators - function initializeTooltips() { - $('[data-bs-toggle="tooltip"]').tooltip(); - } - - // Initialize DataTable for tenant view role management - let tenantViewRoleTable = $('#TenantViewRoleTable').DataTable({ - order: [[0, 'asc']], // Sort by tenant name - processing: false, - serverSide: false, - paging: true, - searching: true, - pageLength: 25, - autoWidth: false, - scrollY: 'calc(100vh - 325px)', - scrollCollapse: true, - columnDefs: [ - { - targets: [2], // Actions column - orderable: false - } - ], - dom: 'frtip', // Show filter, table, info, pagination - language: { - emptyTable: "No tenants found", - search: "Search tenants:", - lengthMenu: "Show _MENU_ tenants per page", - info: "Showing _START_ to _END_ of _TOTAL_ tenants" - }, - drawCallback: function() { - // Reinitialize tooltips after table redraws - initializeTooltips(); - } - }); - - // Keep the scroll body sized so the header/pagination stay within the viewport - // instead of the table overflowing past the bottom of the screen (same plugin - // used by initializeDataTable's fixedHeaders option elsewhere in the app). - // Stashed on the settings object, matching table-utils.js's own usage of the plugin. - if ($.fn.dataTable.ScrollResize) { - tenantViewRoleTable.settings()[0]._scrollResize = new $.fn.dataTable.ScrollResize(tenantViewRoleTable); - } - - // Initialize tooltips on page load - initializeTooltips(); - - // Handle save role button click - $(document).on('click', '.save-role-btn', function () { - const button = $(this); - const tenantId = button.data('tenant-id'); - const row = button.closest('tr'); - const viewRoleInput = row.find('.view-role-input'); - const viewRole = viewRoleInput.val().trim(); - - if (!viewRole) { - abp.notify.warn('Please enter a view role name.'); - return; - } - - button.prop('disabled', true).html(' Saving...'); - - _tenantViewRoleAppService.update(tenantId, { viewRole: viewRole }) - .done(function (_) { - // Remove the default indicator since it's now saved - const indicator = row.find('.default-role-indicator'); - if (indicator.length) { - indicator.tooltip('dispose'); - indicator.remove(); - } - - // Update the data attribute - viewRoleInput.attr('data-is-default', 'false'); - - abp.notify.success('View role saved successfully.'); - }) - .fail(function () { - abp.notify.error('Failed to save view role.'); - }) - .always(function () { - button.prop('disabled', false).html(' Save'); - }); - }); - - // Handle assign role to views button click - $(document).on('click', '.assign-role-btn', function () { - const button = $(this); - const tenantId = button.data('tenant-id'); - const tenantName = button.data('tenant-name'); - const row = button.closest('tr'); - const viewRoleInput = row.find('.view-role-input'); - const viewRole = viewRoleInput.val().trim(); - const isDefault = viewRoleInput.attr('data-is-default') === 'true'; - - if (!viewRole) { - abp.notify.warn('Please enter a view role name before assigning it to views.'); - return; - } - - // Check if role needs to be saved first - if (isDefault) { - abp.message.confirm( - `The role "${viewRole}" is using the default pattern and hasn't been saved yet. Would you like to save it first and then assign it to views?`, - 'Save and Assign Role', - function (isConfirmed) { - if (isConfirmed) { - // Save first, then assign - saveAndAssignRole(tenantId, tenantName, viewRole, button, row); - } - } - ); - } else { - // Role is already saved, proceed with assignment - assignRoleToViews(tenantId, tenantName, viewRole, button); - } - }); - - // Handle view database info button click - $(document).on('click', '.view-database-info-btn', function () { - const button = $(this); - const tenantId = button.data('tenant-id'); - const tenantName = button.data('tenant-name'); - - _databaseInfoModal.open({ - tenantId: tenantId, - tenantName: tenantName - }); - }); - - // Function to save role and then assign to views - function saveAndAssignRole(tenantId, tenantName, viewRole, button, row) { - button.prop('disabled', true).html(' Saving & Assigning...'); - - _tenantViewRoleAppService.update(tenantId, { viewRole: viewRole }) - .done(function (result) { - // Remove the default indicator - const indicator = row.find('.default-role-indicator'); - if (indicator.length) { - indicator.tooltip('dispose'); - indicator.remove(); - } - - // Update the data attribute - row.find('.view-role-input').attr('data-is-default', 'false'); - - // Now assign to views - assignRoleToViews(tenantId, tenantName, viewRole, button); - }) - .fail(function () { - abp.notify.error('Failed to save view role.'); - button.prop('disabled', false).html(' Assign to Views'); - }); - } - - // Function to assign role to views - function assignRoleToViews(tenantId, tenantName, viewRole, button) { - button.prop('disabled', true).html(' Assigning...'); - - _tenantViewRoleAppService.assignRoleToViews(tenantId) - .done(function () { - abp.notify.success(`Role assignment jobs have been queued for tenant "${tenantName}". The process will complete in the background.`); - }) - .fail(function () { - abp.notify.error('Failed to queue role assignment jobs.'); - }) - .always(function () { - button.prop('disabled', false).html(' Assign to Views'); - }); - } - - // Add refresh functionality - window.refreshTenantTable = function() { - window.location.reload(); - }; -}); diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/ReportingWebModule.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/ReportingWebModule.cs index 34b15c6436..3d8bcbe338 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/ReportingWebModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/ReportingWebModule.cs @@ -1,12 +1,10 @@ using Microsoft.Extensions.DependencyInjection; using Unity.Reporting.Localization; -using Unity.Reporting.Web.Menus; using Volo.Abp.AspNetCore.Mvc.Localization; using Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared; using Volo.Abp.Mapperly; using Volo.Abp.Modularity; using Volo.Abp.SettingManagement.Web; -using Volo.Abp.UI.Navigation; using Volo.Abp.VirtualFileSystem; namespace Unity.Reporting.Web; @@ -45,18 +43,13 @@ public override void PreConfigureServices(ServiceConfigurationContext context) } /// - /// Configures main services for the Unity.Reporting Web module including navigation, virtual file system, and AutoMapper. - /// Registers the ReportingMenuContributor for navigation menu setup, configures embedded virtual file system resources - /// for CSS/JS assets and views, and sets up AutoMapper object mapping with validation for web-layer data transformations. + /// Configures main services for the Unity.Reporting Web module including virtual file system and AutoMapper. + /// Configures embedded virtual file system resources for CSS/JS assets and views, and sets up AutoMapper + /// object mapping with validation for web-layer data transformations. /// /// The service configuration context for dependency injection and module configuration. public override void ConfigureServices(ServiceConfigurationContext context) { - Configure(options => - { - options.MenuContributors.Add(new ReportingMenuContributor()); - }); - Configure(options => { options.FileSets.AddEmbedded(); diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/wwwroot/client-proxies/tenant-view-role-proxy.js b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/wwwroot/client-proxies/tenant-view-role-proxy.js deleted file mode 100644 index 844a584771..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/wwwroot/client-proxies/tenant-view-role-proxy.js +++ /dev/null @@ -1,38 +0,0 @@ -/* This file is automatically generated by ABP framework to use MVC Controllers from javascript. */ - -// module reporting - -(function(){ - - // controller unity.reporting.tenantViewRole - - (function(){ - - abp.utils.createNamespace(window, 'unity.reporting.tenantViewRole'); - - unity.reporting.tenantViewRole.getAll = function(ajaxParams) { - return abp.ajax($.extend(true, { - url: abp.appPath + 'api/reporting/tenant-view-roles', - type: 'GET' - }, ajaxParams)); - }; - - unity.reporting.tenantViewRole.update = function(tenantId, input, ajaxParams) { - return abp.ajax($.extend(true, { - url: abp.appPath + 'api/reporting/tenant-view-roles/' + tenantId + '', - type: 'PUT', - data: JSON.stringify(input) - }, ajaxParams)); - }; - - unity.reporting.tenantViewRole.assignRoleToViews = function(tenantId, ajaxParams) { - return abp.ajax($.extend(true, { - url: abp.appPath + 'api/reporting/tenant-view-roles/' + tenantId + '/assign-role-to-views', - type: 'POST', - dataType: null - }, ajaxParams)); - }; - - })(); - -})(); \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/Configuration/TenantViewRoleAppServiceTests.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/Configuration/TenantViewRoleAppServiceTests.cs new file mode 100644 index 0000000000..00a0e984de --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/Configuration/TenantViewRoleAppServiceTests.cs @@ -0,0 +1,212 @@ +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using System; +using System.Reflection; +using System.Threading.Tasks; +using Unity.Reporting.Configuration; +using Unity.Reporting.Domain.Configuration; +using Volo.Abp; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; +using Volo.Abp.SettingManagement; +using Volo.Abp.TenantManagement; +using Xunit; +using Xunit.Abstractions; + +namespace Unity.Reporting.Application.Tests.Configuration; + +public class TenantViewRoleAppServiceTests : ReportingApplicationTestBase +{ + private const string SettingName = "GrantManager.Reporting.TenantViewRole"; + + private readonly ITenantRepository _tenantRepository; + private readonly ISettingManager _settingManager; + private readonly IBackgroundJobManager _backgroundJobManager; + private readonly IReportColumnsMapRepository _reportColumnsMapRepository; + private readonly ICurrentTenant _currentTenant; + private readonly TenantViewRoleAppService _service; + + public TenantViewRoleAppServiceTests(ITestOutputHelper outputHelper) : base(outputHelper) + { + _tenantRepository = Substitute.For(); + _settingManager = Substitute.For(); + _backgroundJobManager = Substitute.For(); + _reportColumnsMapRepository = Substitute.For(); + _currentTenant = Substitute.For(); + _currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + + _service = new TenantViewRoleAppService( + _tenantRepository, _settingManager, _backgroundJobManager, _reportColumnsMapRepository, _currentTenant); + + SetupServicePropertiesForTesting(_service, Substitute.For>()); + } + + // Tenant's constructors are all non-public (ABP requires going through ITenantManager to + // create one) - reflection is the standard workaround for exercising it in a plain 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()]); + } + + // Mirrors ReportMappingServiceTests' approach: TenantViewRoleAppService is constructed + // directly (not resolved via DI) so tests don't need a real Postgres-backed EF Core context - + // its Logger property (from ApplicationService's LazyServiceProvider) is wired up manually. + private static void SetupServicePropertiesForTesting(object service, ILogger logger) + { + var mockLazyServiceProvider = Substitute.For(); + mockLazyServiceProvider.LazyGetService(Arg.Any>()) + .Returns(logger); + + var lazyServiceProviderProperty = typeof(TenantViewRoleAppService) + .GetProperty("LazyServiceProvider", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + if (lazyServiceProviderProperty == null) + { + var currentType = typeof(TenantViewRoleAppService).BaseType; + while (currentType != null && lazyServiceProviderProperty == null) + { + lazyServiceProviderProperty = currentType.GetProperty("LazyServiceProvider", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly); + currentType = currentType.BaseType; + } + } + + if (lazyServiceProviderProperty != null && lazyServiceProviderProperty.CanWrite) + { + lazyServiceProviderProperty.SetValue(service, mockLazyServiceProvider); + } + else + { + throw new InvalidOperationException("Could not find or set LazyServiceProvider property"); + } + } + + [Fact] + public async Task GetAsync_TenantHasLicencePlate_NoSavedRole_DefaultsToLicencePlateReadOnlyRole() + { + var tenant = CreateTenant("acme"); + tenant.ExtraProperties["LicencePlate"] = "T_ABC123"; + _tenantRepository.GetAsync(tenant.Id).Returns(tenant); + _settingManager.GetOrNullAsync(SettingName, "T", tenant.Id.ToString()).Returns((string?)null); + _reportColumnsMapRepository.RoleExistsAsync("T_ABC123_readonly").Returns(true); + _reportColumnsMapRepository.RoleExistsAsync("T_ABC123").Returns(false); + + var result = await _service.GetAsync(tenant.Id); + + result.LicencePlate.ShouldBe("T_ABC123"); + result.ExpectedReadOnlyRole.ShouldBe("T_ABC123_readonly"); + result.ViewRole.ShouldBe("T_ABC123_readonly"); + result.IsDefaultInferred.ShouldBeTrue(); + result.ReadOnlyRoleExists.ShouldBeTrue(); + result.MainRoleExists.ShouldBeFalse(); + } + + [Fact] + public async Task GetAsync_TenantHasNoLicencePlate_FallsBackToLegacyTenantNamePattern() + { + var tenant = CreateTenant("acme"); + _tenantRepository.GetAsync(tenant.Id).Returns(tenant); + _settingManager.GetOrNullAsync(SettingName, "T", tenant.Id.ToString()).Returns((string?)null); + + var result = await _service.GetAsync(tenant.Id); + + result.LicencePlate.ShouldBeNull(); + result.ExpectedReadOnlyRole.ShouldBeNull(); + result.ViewRole.ShouldBe("acme_readonly"); + result.IsDefaultInferred.ShouldBeTrue(); + result.ReadOnlyRoleExists.ShouldBeFalse(); + result.MainRoleExists.ShouldBeFalse(); + // No license plate - existence isn't checked against anything, so the repository should + // never be queried for this tenant. + await _reportColumnsMapRepository.DidNotReceiveWithAnyArgs().RoleExistsAsync(default!); + } + + [Fact] + public async Task GetAsync_SavedRoleExists_TakesPrecedenceOverLicencePlateDefault() + { + var tenant = CreateTenant("acme"); + tenant.ExtraProperties["LicencePlate"] = "T_ABC123"; + _tenantRepository.GetAsync(tenant.Id).Returns(tenant); + _settingManager.GetOrNullAsync(SettingName, "T", tenant.Id.ToString()).Returns("legacy_custom_role"); + _reportColumnsMapRepository.RoleExistsAsync(Arg.Any()).Returns(true); + + var result = await _service.GetAsync(tenant.Id); + + result.ViewRole.ShouldBe("legacy_custom_role"); + result.IsDefaultInferred.ShouldBeFalse(); + // The license-plate role info is still surfaced alongside the saved (legacy) role. + result.LicencePlate.ShouldBe("T_ABC123"); + result.ExpectedReadOnlyRole.ShouldBe("T_ABC123_readonly"); + } + + [Fact] + public async Task UpdateAsync_RoleDoesNotExist_ThrowsUserFriendlyException() + { + var tenant = CreateTenant("acme"); + _tenantRepository.GetAsync(tenant.Id).Returns(tenant); + _reportColumnsMapRepository.RoleExistsAsync("nonexistent_role").Returns(false); + + await Should.ThrowAsync( + () => _service.UpdateAsync(tenant.Id, new UpdateTenantViewRoleDto { ViewRole = "nonexistent_role" })); + + await _settingManager.DidNotReceive().SetAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UpdateAsync_RoleExists_PersistsSetting() + { + var tenant = CreateTenant("acme"); + _tenantRepository.GetAsync(tenant.Id).Returns(tenant); + _reportColumnsMapRepository.RoleExistsAsync("acme_readonly").Returns(true); + + var result = await _service.UpdateAsync(tenant.Id, new UpdateTenantViewRoleDto { ViewRole = "acme_readonly" }); + + result.ViewRole.ShouldBe("acme_readonly"); + result.IsDefaultInferred.ShouldBeFalse(); + await _settingManager.Received(1).SetAsync( + SettingName, "acme_readonly", "T", tenant.Id.ToString(), Arg.Any()); + } + + [Fact] + public async Task AssignRoleToViewsAsync_NoRoleConfigured_ThrowsUserFriendlyException() + { + var tenantId = Guid.NewGuid(); + _settingManager.GetOrNullAsync(SettingName, "T", tenantId.ToString()).Returns((string?)null); + + await Should.ThrowAsync(() => _service.AssignRoleToViewsAsync(tenantId)); + + await _backgroundJobManager.DidNotReceiveWithAnyArgs().EnqueueAsync(default!); + } + + [Fact] + public async Task AssignRoleToViewsAsync_RoleDoesNotExist_ThrowsUserFriendlyException() + { + var tenantId = Guid.NewGuid(); + _settingManager.GetOrNullAsync(SettingName, "T", tenantId.ToString()).Returns("ghost_role"); + _reportColumnsMapRepository.RoleExistsAsync("ghost_role").Returns(false); + + await Should.ThrowAsync(() => _service.AssignRoleToViewsAsync(tenantId)); + + await _backgroundJobManager.DidNotReceiveWithAnyArgs().EnqueueAsync(default!); + } + + [Fact] + public async Task AssignRoleToViewsAsync_RoleExists_EnqueuesJob() + { + var tenantId = Guid.NewGuid(); + _settingManager.GetOrNullAsync(SettingName, "T", tenantId.ToString()).Returns("acme_readonly"); + _reportColumnsMapRepository.RoleExistsAsync("acme_readonly").Returns(true); + + await _service.AssignRoleToViewsAsync(tenantId); + + await _backgroundJobManager.Received(1).EnqueueAsync( + Arg.Is(a => a.TenantId == tenantId), + Arg.Any(), Arg.Any()); + } +} 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..9f2163c3c4 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 @@ -5,6 +5,7 @@ using System.Security.Cryptography; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; +using Unity.Modules.Shared.Permissions; using Unity.TenantManagement.Abstractions; using Unity.TenantManagement.Application; using Unity.TenantManagement.Application.Contracts; @@ -34,6 +35,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 +61,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 +92,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 +108,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) || @@ -142,6 +148,16 @@ private static string GetExtraPropertyValue(Tenant tenant, string key) [Authorize(TenantManagementPermissions.Policies.TenantsCreateOrITOps)] public virtual async Task CreateAsync(TenantCreateDto input) { + // TenantsCreateOrITOps also grants access to any caller holding the plain Tenants.Create + // permission, not just IT Admin/Operations. CreateModalModel.OnPostAsync already strips + // FeatureKeys/MetabaseUserEmails for non-admin callers, but that only guards the OOTB + // Razor Page - a caller can still invoke this application service directly (its own + // dynamic API controller) and set them. Re-check the stricter policy here so a forged call + // can't enable arbitrary features or - more importantly - grant arbitrary email addresses + // Metabase access to the new tenant's database via the post-creation registration step. + StripPrivilegedFieldsUnlessAuthorized( + input, await AuthorizationService.IsGrantedAsync(IdentityConsts.ITAdminOrITOperationsPolicyName)); + Tenant? tenant = null; using (var uow = unitOfWorkManager.Begin(true, false)) @@ -169,6 +185,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; @@ -180,22 +197,42 @@ public virtual async Task CreateAsync(TenantCreateDto input) await uow.CompleteAsync(); } - await localEventBus.PublishAsync( - new TenantCreatedEto - { - Id = tenant.Id, - Name = tenant.Name, - Properties = - { - { "UserIdentifier", input.UserIdentifier }, - { "FeatureKeys", input.FeatureKeys ?? string.Empty } - } - } - ); + var tenantCreatedEto = new TenantCreatedEto + { + Id = tenant.Id, + Name = tenant.Name, + Properties = + { + { "UserIdentifier", input.UserIdentifier }, + { "FeatureKeys", input.FeatureKeys ?? string.Empty } + } + }; + + // Distinguish "field omitted" (input.MetabaseUserEmails is null - an older/API caller + // that never set it) from "explicitly cleared" (empty string - a deliberate "no Metabase + // users for this tenant" choice) - only the latter should be persisted as an override. + // TenantCreatedEventHandler falls back to the Global default when the property is absent. + if (input.MetabaseUserEmails != null) + { + tenantCreatedEto.Properties["MetabaseUserEmails"] = input.MetabaseUserEmails; + } + + await localEventBus.PublishAsync(tenantCreatedEto); return ObjectMapper.Map(tenant); } + // Extracted so the stripping decision itself is unit-testable without driving ABP's + // authorization pipeline through an integration test host. + internal static void StripPrivilegedFieldsUnlessAuthorized(TenantCreateDto input, bool callerIsAuthorized) + { + if (!callerIsAuthorized) + { + input.FeatureKeys = null; + input.MetabaseUserEmails = null; + } + } + [Authorize(TenantManagementPermissions.Policies.TenantsUpdateOrITOps)] public virtual async Task UpdateAsync(Guid id, TenantUpdateDto input) { @@ -206,6 +243,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..e5f8a3d03c 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 @@ -59,12 +59,23 @@ } + @if (Model.CanManageReporting) + { + + }
+ @@ -184,6 +195,89 @@
} + @if (Model.CanManageReporting) + { +
+

+ Configure the database role granted SELECT access to this tenant's reporting views. +

+ + @if (!string.IsNullOrEmpty(Model.ViewRole?.LicencePlate)) + { +
+
License Plate: @Model.ViewRole.LicencePlate
+
+ Main role @Model.ViewRole.LicencePlate: + @if (Model.ViewRole.MainRoleExists) + { + Found + } + else + { + Not found + } +
+
+ Readonly role @Model.ViewRole.ExpectedReadOnlyRole: + @if (Model.ViewRole.ReadOnlyRoleExists) + { + Found + } + else + { + Not found + } +
+
+ } + else + { +

+ No license plate on record for this tenant (a legacy tenant, created before + automatic role provisioning) - defaulting to + @($"{Model.Tenant.Name.ToLowerInvariant()}_readonly") unless a role + has been explicitly saved below. +

+ } + +
+ +
+ + @if (Model.ViewRole?.IsDefaultInferred == true) + { + + } +
+
+ +
+ + + +
+
+ } +
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..8ab08b458d 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 @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Mvc; using Unity.GrantManager.Integrations; using Unity.Modules.Shared.Permissions; +using Unity.Reporting.Configuration; using Volo.Abp.Domain.Entities; using Volo.Abp.FeatureManagement; using Volo.Abp.Features; @@ -21,7 +22,8 @@ namespace Unity.TenantManagement.Web.Pages.TenantManagement.Tenants; public class ConfigurationModalModel( ITenantAppService tenantAppService, ICasClientCodeLookupService lookupService, - IFeatureAppService featureAppService) : TenantManagementPageModel + IFeatureAppService featureAppService, + ITenantViewRoleAppService tenantViewRoleAppService) : TenantManagementPageModel { [BindProperty] public TenantInfoModel Tenant { get; set; } = null!; @@ -45,6 +47,10 @@ public class ConfigurationModalModel( public bool CanManageManagers { get; set; } + public bool CanManageReporting { get; set; } + + public TenantViewRoleDto? ViewRole { get; set; } + public virtual async Task OnGetAsync(Guid id) { var tenantDto = await tenantAppService.GetAsync(id); @@ -61,11 +67,22 @@ public virtual async Task OnGetAsync(Guid id) CanManageManagers = CanManageFeatures; + // Stricter than CanManageFeatures - this mirrors the reporting database-role admin page's + // original ITAdministrator-only gating (IdentityConsts.ITAdminPermissionName on + // TenantViewRoleAppService itself), not the broader ITAdminOrITOperations used above. + CanManageReporting = (await AuthorizationService + .AuthorizeAsync(User, IdentityConsts.ITAdminPolicyName)).Succeeded; + if (CanManageConnectionStrings) { ConnectionStrings = await tenantAppService.GetConnectionStringsAsync(id); } + if (CanManageReporting) + { + ViewRole = await tenantViewRoleAppService.GetAsync(id); + } + return Page(); } @@ -121,6 +138,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..55f8851a38 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, and + // TenantAppService.CreateAsync itself is reachable by anyone with plain Tenants.Create + // permission (TenantsCreateOrITOps) - it now re-checks this same policy and strips these + // privileged fields itself, so this is defense in depth (a clean 4xx-free UX for this + // page), not the only guard. Mirrors 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..c35e7a4c14 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 @@ -17,6 +17,10 @@ } ); + let _reportingDatabaseInfoModal = new abp.ModalManager({ + viewUrl: abp.appPath + 'ReportingAdmin/Configuration/DatabaseInfoModal' + }); + let _dataTable = null; // ─── Actions column renderer ────────────────────────────────────────────── @@ -52,15 +56,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 +73,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 +90,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 +130,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 +153,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 +193,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 +223,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 +322,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,33 +431,138 @@ } 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(',')); + } + + // ─── Configuration modal: Reporting tab (view role) ─────────────────────── + + let _tenantViewRoleAppService = unity.reporting.configuration.tenantViewRole; + + function _saveReportingViewRole(tenantId, onSaved) { + let $btn = $('#config-save-role-btn'); + let viewRole = $('#config-view-role-input').val().trim(); + + if (!viewRole) { + abp.notify.warn('Please enter a view role name.'); + return; + } + + $btn.prop('disabled', true).html(' Saving...'); + + _tenantViewRoleAppService.update(tenantId, { viewRole: viewRole }) + .done(function () { + let $indicator = $('#pane-reporting .default-role-indicator'); + if ($indicator.length) { + $indicator.tooltip('dispose'); + $indicator.remove(); + } + $('#config-view-role-input').attr('data-is-default', 'false'); + + abp.notify.success('View role saved successfully.'); + if (onSaved) onSaved(viewRole); + }) + .fail(function () { + abp.notify.error('Failed to save view role.'); + }) + .always(function () { + $btn.prop('disabled', false).html(' Save'); + }); + } + + function _assignReportingRoleToViews(tenantId, tenantName, viewRole) { + let $btn = $('#config-assign-role-btn'); + $btn.prop('disabled', true).html(' Assigning...'); + + _tenantViewRoleAppService.assignRoleToViews(tenantId) + .done(function () { + abp.notify.success('Role assignment jobs have been queued for tenant "' + tenantName + '". The process will complete in the background.'); + }) + .fail(function () { + abp.notify.error('Failed to queue role assignment jobs.'); + }) + .always(function () { + $btn.prop('disabled', false).html(' Assign to Views'); + }); + } + + function _wireReportingTabHandlers(tenantId) { + $('#config-save-role-btn').off('click').on('click', function () { + _saveReportingViewRole(tenantId); + }); + + $('#config-assign-role-btn').off('click').on('click', function () { + let $btn = $(this); + let tenantName = $btn.data('tenant-name'); + let viewRole = $('#config-view-role-input').val().trim(); + let isDefault = $('#config-view-role-input').attr('data-is-default') === 'true'; + + if (!viewRole) { + abp.notify.warn('Please enter a view role name before assigning it to views.'); + return; + } + + if (isDefault) { + abp.message.confirm( + 'The role "' + viewRole + '" is using the default pattern and hasn\'t been saved yet. Would you like to save it first and then assign it to views?', + 'Save and Assign Role', + function (isConfirmed) { + if (isConfirmed) { + _saveReportingViewRole(tenantId, function (savedViewRole) { + _assignReportingRoleToViews(tenantId, tenantName, savedViewRole); + }); + } + } + ); + } else { + _assignReportingRoleToViews(tenantId, tenantName, viewRole); + } + }); + + $('#config-view-database-info-btn').off('click').on('click', function () { + let $btn = $(this); + _reportingDatabaseInfoModal.open({ + tenantId: tenantId, + tenantName: $btn.data('tenant-name') + }); + }); } function _configurationModalInitModal(publicApi, args) { _configTenantId = args.id; _loadManagersTab(_configTenantId); + _wireReportingTabHandlers(_configTenantId); _configFilterDataTable = $('#ConfigUserSearchTable').DataTable( abp.libs.datatables.normalizeConfiguration({ diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Unity.TenantManagement.Web.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Unity.TenantManagement.Web.csproj index 8b033bd1ce..f10327ca73 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Unity.TenantManagement.Web.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Unity.TenantManagement.Web.csproj @@ -30,6 +30,7 @@ + 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/src/Unity.TenantManagement.Web/UnityTenantManagementWebModule.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebModule.cs index f7bffd53c2..72bc7e970f 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebModule.cs @@ -15,6 +15,7 @@ using Volo.Abp.VirtualFileSystem; using Volo.Abp.Threading; using Unity.Modules.Shared.Permissions; +using Unity.Reporting; using Unity.TenantManagement.Web.Navigation; namespace Unity.TenantManagement.Web; @@ -23,6 +24,7 @@ namespace Unity.TenantManagement.Web; [DependsOn(typeof(AbpAspNetCoreMvcUiBootstrapModule))] [DependsOn(typeof(AbpFeatureManagementWebModule))] [DependsOn(typeof(AbpMapperlyModule))] +[DependsOn(typeof(ReportingApplicationContractsModule))] public class UnityTenantManagementWebModule : AbpModule { private static readonly OneTimeRunner OneTimeRunner = new(); 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..cfe7bd2210 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() { @@ -98,6 +125,41 @@ await Assert.ThrowsAsync(async () => }); } + [Fact] + public void StripPrivilegedFieldsUnlessAuthorized_CallerNotAuthorized_ClearsFeatureKeysAndMetabaseUserEmails() + { + // A caller with only the plain Tenants.Create permission (not IT Admin/Operations) must + // not be able to enable arbitrary features or grant arbitrary email addresses Metabase + // access to a tenant's database via the post-creation registration step. + var input = new TenantCreateDto + { + Name = "acme2", + FeatureKeys = "Unity.Payments", + MetabaseUserEmails = "someone@gov.bc.ca" + }; + + TenantAppService.StripPrivilegedFieldsUnlessAuthorized(input, callerIsAuthorized: false); + + input.FeatureKeys.ShouldBeNull(); + input.MetabaseUserEmails.ShouldBeNull(); + } + + [Fact] + public void StripPrivilegedFieldsUnlessAuthorized_CallerAuthorized_PreservesFeatureKeysAndMetabaseUserEmails() + { + var input = new TenantCreateDto + { + Name = "acme2", + FeatureKeys = "Unity.Payments", + MetabaseUserEmails = "someone@gov.bc.ca" + }; + + TenantAppService.StripPrivilegedFieldsUnlessAuthorized(input, callerIsAuthorized: true); + + input.FeatureKeys.ShouldBe("Unity.Payments"); + input.MetabaseUserEmails.ShouldBe("someone@gov.bc.ca"); + } + [Fact] public async Task UpdateAsync() { 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..a8bf788eae 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,8 +60,41 @@ 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) + { + var emails = await ResolveMetabaseUserEmailsAsync( + eto.Properties, () => _settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails)); + + await _settingManager.SetAsync( + MetabaseSettings.UserEmails, emails, TenantSettingValueProvider.ProviderName, tenantId.ToString()); + } + + // TenantAppService.CreateAsync only adds "MetabaseUserEmails" to eto.Properties when the + // caller explicitly set it (even to an empty string - a deliberate "no Metabase users for + // this tenant" choice, which must still be persisted as-is). When the property is absent - + // an older/API caller that never set it - snapshot the *current* Global default here + // rather than leaving the tenant setting unset, so the step reads a stable list even if the + // Global default changes before it (async, queued) actually runs. + internal static async Task ResolveMetabaseUserEmailsAsync( + IReadOnlyDictionary etoProperties, Func> getGlobalDefaultAsync) => + etoProperties.TryGetValue("MetabaseUserEmails", out var emailsRaw) + ? emailsRaw + : await getGlobalDefaultAsync() ?? string.Empty; + private async Task EnableRequestedFeaturesAsync(TenantCreatedEto eto, Guid tenantId) { if (!eto.Properties.TryGetValue("FeatureKeys", out var featureKeysRaw)) 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..7886949ebc --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/IMetabaseApiClient.cs @@ -0,0 +1,28 @@ +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. +/// +/// The FindOrCreate* methods and are idempotent - they +/// look for an existing database/group/collection/membership by its natural key (name, or +/// group+user) before creating one, so the caller can safely retry after a partial failure +/// (e.g. re-enqueuing MetabaseTenantRegistrationStep) without duplicating resources. +/// +public interface IMetabaseApiClient +{ + Task FindOrCreateDatabaseAsync(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 FindOrCreateGroupAsync(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 FindOrCreateCollectionAsync(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..f4ceaa8e19 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseApiClient.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +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 FindOrCreateDatabaseAsync(string name, string host, int port, string dbName, string username, string password, bool ssl, CancellationToken cancellationToken = default) + { + var existingId = await FindIdByNameAsync("/api/database", name, cancellationToken); + if (existingId != null) + { + return existingId.Value; + } + + 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 FindOrCreateGroupAsync(string name, CancellationToken cancellationToken = default) + { + var existingId = await FindIdByNameAsync("/api/permissions/group", name, cancellationToken); + if (existingId != null) + { + return existingId.Value; + } + + 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 async Task AddGroupMemberAsync(int groupId, int userId, CancellationToken cancellationToken = default) + { + // A rerun after a partial failure must not re-POST an existing membership - Metabase + // treats that as a conflict rather than a no-op. + if (await IsGroupMemberAsync(groupId, userId, cancellationToken)) + { + return; + } + + await PostAsync("/api/permissions/membership", new { group_id = groupId, user_id = userId }, cancellationToken); + } + + private async Task IsGroupMemberAsync(int groupId, int userId, CancellationToken cancellationToken) + { + var memberships = await GetAsync("/api/permissions/membership", cancellationToken); + var groupMembers = memberships[groupId.ToString(CultureInfo.InvariantCulture)] as JArray; + return groupMembers?.Any(m => m.Value("user_id") == userId) ?? false; + } + + 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 FindOrCreateCollectionAsync(string name, CancellationToken cancellationToken = default) + { + var existingId = await FindIdByNameAsync("/api/collection", name, cancellationToken); + if (existingId != null) + { + return existingId.Value; + } + + 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 GetRawAsync(string path, CancellationToken cancellationToken) + { + var baseUrl = await GetBaseUrlAsync(); + return await resilientHttpRequest.HttpAsync( + HttpMethod.Get, $"{baseUrl}{path}", extraHeaders: BuildHeaders(), cancellationToken: cancellationToken); + } + + private async Task GetAsync(string path, CancellationToken cancellationToken) => + await ReadJsonAsync(await GetRawAsync(path, cancellationToken), path); + + // Metabase's list endpoints are inconsistent about pagination - /api/database wraps results in + // {"data": [...]}, while /api/permissions/group and /api/collection return a raw JSON array. + // Handling both shapes here keeps the find-or-create callers simple. + private async Task FindIdByNameAsync(string listPath, string name, CancellationToken cancellationToken) + { + var root = await ReadJsonTokenAsync(await GetRawAsync(listPath, cancellationToken), listPath); + var items = root as JArray ?? root["data"] as JArray ?? new JArray(); + var match = items.FirstOrDefault(item => string.Equals(item.Value("name"), name, StringComparison.Ordinal)); + return match?.Value("id"); + } + + 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) => + (JObject)await ReadJsonTokenAsync(response, path); + + private static async Task ReadJsonTokenAsync(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() : JToken.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..eb3ab6839c --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs @@ -0,0 +1,182 @@ +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 find or create a database +/// connection named after the tenant, then triggers a schema sync + value rescan. +/// 2. Finds or 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. Finds or 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. Recovery from a partial failure is a manual re-enqueue +/// of this step (see PostTenantCreationSequenceJob) - every Metabase call this step makes +/// (via ) is idempotent by design (find-or-create by tenant name, +/// membership/permission checks before writing) so a rerun is always safe. +/// +[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.FindOrCreateDatabaseAsync(tenant.Name, host, port, dbName, username, password, ssl); + await metabaseApiClient.SyncDatabaseSchemaAsync(databaseId); + await metabaseApiClient.RescanDatabaseValuesAsync(databaseId); + + var groupId = await metabaseApiClient.FindOrCreateGroupAsync(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.FindOrCreateCollectionAsync(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..39c8371e14 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs @@ -37,19 +37,31 @@ 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"; + public const string METABASE_TEST_URL = $"{PROTOCOL}//test-unity-reporting.apps.gold.devops.gov.bc.ca"; + public const string METABASE_PROD_URL = $"{PROTOCOL}//prod-unity-reporting.apps.gold.devops.gov.bc.ca"; } - private static string GetMatomoUrl() + internal static string GetEnvironmentUrl(string? aspNetCoreEnvironment, string devUrl, string testUrl, string prodUrl) { - var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? string.Empty; + var env = aspNetCoreEnvironment ?? string.Empty; if (string.IsNullOrEmpty(env) || env.StartsWith("dev", StringComparison.OrdinalIgnoreCase)) - return DynamicUrls.MATOMO_DEV_URL; + return devUrl; if (env.StartsWith("test", StringComparison.OrdinalIgnoreCase) || env.Equals("uat", StringComparison.OrdinalIgnoreCase)) - return DynamicUrls.MATOMO_TEST_URL; - return DynamicUrls.MATOMO_PROD_URL; + return testUrl; + return prodUrl; } + private static string GetMatomoUrl() => + GetEnvironmentUrl(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), + DynamicUrls.MATOMO_DEV_URL, DynamicUrls.MATOMO_TEST_URL, DynamicUrls.MATOMO_PROD_URL); + + private static string GetMetabaseUrl() => + GetEnvironmentUrl(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), + DynamicUrls.METABASE_DEV_URL, DynamicUrls.METABASE_TEST_URL, DynamicUrls.METABASE_PROD_URL); + private async Task SeedDynamicUrlAsync() { if (currentTenant == null || currentTenant.Id == null) @@ -71,6 +83,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}" }, @@ -93,6 +106,19 @@ private async Task SeedDynamicUrlAsync() existing.Url = dynamicUrl.Url; await DynamicUrlRepository.UpdateAsync(existing); } + // Unlike Matomo, Metabase is never kept in sync with the environment default + // once a row exists - ops may point it at a different route via the Endpoint + // Management admin page, and that choice must stick. Only fill in the + // env-default value when the existing (host-level) row is still blank, so a + // pre-existing row from before test/prod URLs were known here gets backfilled + // exactly once, and a deliberately-set value is never overwritten. + else if (existing.KeyName == DynamicUrlKeyNames.METABASE_API_BASE && + string.IsNullOrWhiteSpace(existing.Url) && + !string.IsNullOrWhiteSpace(dynamicUrl.Url)) + { + existing.Url = dynamicUrl.Url; + await DynamicUrlRepository.UpdateAsync(existing); + } } } } 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/Handlers/TenantCreatedEventHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Handlers/TenantCreatedEventHandlerTests.cs index c47f6fe57c..4ed5b04830 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Handlers/TenantCreatedEventHandlerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Handlers/TenantCreatedEventHandlerTests.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.Threading.Tasks; using Shouldly; using Xunit; @@ -5,6 +7,60 @@ namespace Unity.GrantManager.Handlers; public class TenantCreatedEventHandlerTests { + [Fact] + public async Task ResolveMetabaseUserEmailsAsync_PropertyOmitted_SnapshotsCurrentGlobalDefault() + { + var etoProperties = new Dictionary(); + + var result = await TenantCreatedEventHandler.ResolveMetabaseUserEmailsAsync( + etoProperties, () => Task.FromResult("global1@gov.bc.ca,global2@gov.bc.ca")); + + result.ShouldBe("global1@gov.bc.ca,global2@gov.bc.ca"); + } + + [Fact] + public async Task ResolveMetabaseUserEmailsAsync_PropertyOmittedAndNoGlobalDefaultSet_ReturnsEmpty() + { + var etoProperties = new Dictionary(); + + var result = await TenantCreatedEventHandler.ResolveMetabaseUserEmailsAsync( + etoProperties, () => Task.FromResult(null)); + + result.ShouldBe(string.Empty); + } + + [Fact] + public async Task ResolveMetabaseUserEmailsAsync_PropertyExplicitlyEmpty_ReturnsEmptyWithoutFallingBackToGlobal() + { + var etoProperties = new Dictionary { ["MetabaseUserEmails"] = string.Empty }; + var globalDefaultLookedUp = false; + + var result = await TenantCreatedEventHandler.ResolveMetabaseUserEmailsAsync(etoProperties, () => + { + globalDefaultLookedUp = true; + return Task.FromResult("should-not-be-used@gov.bc.ca"); + }); + + result.ShouldBe(string.Empty); + globalDefaultLookedUp.ShouldBeFalse(); + } + + [Fact] + public async Task ResolveMetabaseUserEmailsAsync_PropertyExplicitlySet_ReturnsThatValueWithoutFallingBackToGlobal() + { + var etoProperties = new Dictionary { ["MetabaseUserEmails"] = "tenant-specific@gov.bc.ca" }; + var globalDefaultLookedUp = false; + + var result = await TenantCreatedEventHandler.ResolveMetabaseUserEmailsAsync(etoProperties, () => + { + globalDefaultLookedUp = true; + return Task.FromResult("should-not-be-used@gov.bc.ca"); + }); + + result.ShouldBe("tenant-specific@gov.bc.ca"); + globalDefaultLookedUp.ShouldBeFalse(); + } + [Fact] public void BuildFeatureUpdates_NullInput_ReturnsEmpty() { 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..27b8cba6b6 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Integrations/Metabase/MetabaseApiClientTests.cs @@ -0,0 +1,229 @@ +using System; +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)); + } + + // /api/database wraps its list in {"data": [...]}. + [Fact] + public async Task FindOrCreateDatabaseAsync_DatabaseWithSameNameAlreadyExists_ReturnsExistingIdWithoutCreating() + { + var (client, http) = CreateClient(); + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "{\"data\":[{\"id\":11,\"name\":\"AG-MARB\"}]}")); + + var databaseId = await client.FindOrCreateDatabaseAsync( + "AG-MARB", "host", 5432, "db", "user", "pass", ssl: true); + + databaseId.ShouldBe(11); + await http.Received(1).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 FindOrCreateDatabaseAsync_NoDatabaseWithThatName_CreatesNewDatabase() + { + var (client, http) = CreateClient(); + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "{\"data\":[]}"), + JsonResponse(HttpStatusCode.OK, "{\"id\":12}")); + + var databaseId = await client.FindOrCreateDatabaseAsync( + "AG-MARB", "host", 5432, "db", "user", "pass", ssl: true); + + databaseId.ShouldBe(12); + await http.Received(1).HttpAsync( + HttpMethod.Post, Arg.Is(url => url != null && url.EndsWith("/api/database", StringComparison.Ordinal)), + Arg.Any(), Arg.Any(), Arg.Any<(string username, string password)?>(), + Arg.Any(), Arg.Any?>(), + Arg.Any()); + } + + // /api/permissions/group returns a raw JSON array, unlike /api/database. + [Fact] + public async Task FindOrCreateGroupAsync_GroupWithSameNameAlreadyExists_ReturnsExistingIdWithoutCreating() + { + var (client, http) = CreateClient(); + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "[{\"id\":7,\"name\":\"AG-MARB\"}]")); + + var groupId = await client.FindOrCreateGroupAsync("AG-MARB"); + + groupId.ShouldBe(7); + await http.Received(1).HttpAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any<(string username, string password)?>(), Arg.Any(), + Arg.Any?>(), Arg.Any()); + } + + // /api/collection also returns a raw JSON array. + [Fact] + public async Task FindOrCreateCollectionAsync_CollectionWithSameNameAlreadyExists_ReturnsExistingIdWithoutCreating() + { + var (client, http) = CreateClient(); + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "[{\"id\":33,\"name\":\"AG-MARB\"}]")); + + var collectionId = await client.FindOrCreateCollectionAsync("AG-MARB"); + + collectionId.ShouldBe(33); + await http.Received(1).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 AddGroupMemberAsync_UserAlreadyAMember_DoesNotPostMembershipAgain() + { + var (client, http) = CreateClient(); + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "{\"5\":[{\"user_id\":101,\"membership_id\":1}]}")); + + await client.AddGroupMemberAsync(groupId: 5, userId: 101); + + await http.Received(1).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 AddGroupMemberAsync_UserNotYetAMember_PostsMembership() + { + var (client, http) = CreateClient(); + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "{\"5\":[{\"user_id\":101,\"membership_id\":1}]}"), + JsonResponse(HttpStatusCode.OK, "{}")); + + await client.AddGroupMemberAsync(groupId: 5, userId: 202); + + await http.Received(1).HttpAsync( + HttpMethod.Post, Arg.Is(url => url != null && url.EndsWith("/api/permissions/membership", StringComparison.Ordinal)), + Arg.Any(), Arg.Any(), Arg.Any<(string username, string password)?>(), + Arg.Any(), Arg.Any?>(), + Arg.Any()); + } +} 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..1799aeaa20 --- /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().FindOrCreateDatabaseAsync(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.FindOrCreateDatabaseAsync( + tenant.Name, "dev-crunchy-postgres-primary.ce395f-dev.svc", 5432, "T_ABC123", "t_abc123_readonly", "s3cr3t", true) + .Returns(11); + metabaseApiClient.FindOrCreateGroupAsync(tenant.Name).Returns(22); + metabaseApiClient.FindUserIdByEmailAsync("user1@gov.bc.ca").Returns(101); + metabaseApiClient.FindUserIdByEmailAsync("user2@gov.bc.ca").Returns((int?)null); + metabaseApiClient.FindOrCreateCollectionAsync(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.FindOrCreateGroupAsync(tenant.Name).Returns(22); + metabaseApiClient.FindOrCreateCollectionAsync(tenant.Name).Returns(33); + + await step.ExecuteAsync(tenant.Id); + + await metabaseApiClient.Received(1).FindOrCreateDatabaseAsync( + 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.FindOrCreateGroupAsync(tenant.Name).Returns(22); + metabaseApiClient.FindOrCreateCollectionAsync(tenant.Name).Returns(33); + + await step.ExecuteAsync(tenant.Id); + + await metabaseApiClient.Received(1).FindOrCreateDatabaseAsync( + 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.FindOrCreateGroupAsync(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); + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Domain.Tests/Integrations/DynamicUrlDataSeederTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Domain.Tests/Integrations/DynamicUrlDataSeederTests.cs new file mode 100644 index 0000000000..272b46040f --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Domain.Tests/Integrations/DynamicUrlDataSeederTests.cs @@ -0,0 +1,48 @@ +using Shouldly; +using Unity.GrantManager.Integrations; +using Xunit; + +namespace Unity.GrantManager.Domain.Tests.Integrations; + +public class DynamicUrlDataSeederTests +{ + private const string DevUrl = "https://dev-example.gov.bc.ca"; + private const string TestUrl = "https://test-example.gov.bc.ca"; + private const string ProdUrl = "https://prod-example.gov.bc.ca"; + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Development")] + [InlineData("dev2")] + public void GetEnvironmentUrl_DevOrUnset_ReturnsDevUrl(string? aspNetCoreEnvironment) + { + var result = DynamicUrlDataSeeder.GetEnvironmentUrl( + aspNetCoreEnvironment, DevUrl, TestUrl, ProdUrl); + + result.ShouldBe(DevUrl); + } + + [Theory] + [InlineData("Test")] + [InlineData("test2")] + [InlineData("UAT")] + public void GetEnvironmentUrl_TestOrUat_ReturnsTestUrl(string aspNetCoreEnvironment) + { + var result = DynamicUrlDataSeeder.GetEnvironmentUrl( + aspNetCoreEnvironment, DevUrl, TestUrl, ProdUrl); + + result.ShouldBe(TestUrl); + } + + [Theory] + [InlineData("Production")] + [InlineData("Staging")] + public void GetEnvironmentUrl_AnythingElse_ReturnsProdUrl(string aspNetCoreEnvironment) + { + var result = DynamicUrlDataSeeder.GetEnvironmentUrl( + aspNetCoreEnvironment, DevUrl, TestUrl, ProdUrl); + + result.ShouldBe(ProdUrl); + } +}