+
+
+
+
+
+
+
+
+
+ CAS Client Code
+
+ Select a Client Code
+ @foreach (var option in Model.CasClientOptions)
+ {
+ @option.DisplayName
+ }
+
+
+
+ @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
+
+
+ First Name
+ Last Name
+ First & Last Name
+ Email
+
+
+
+
+
+
+
+
+
+ Selected:
+
+
+
+ @if (Model.CanManageFeatures)
{
-
+
+
+ Loading features...
+
+
+
+
+
+
}
- }
-
-
-
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