From 292dde1bec4434222ef79132cf82b5d975f2f16e Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 4 Aug 2026 12:10:42 -0700 Subject: [PATCH 01/38] bugfix/AB#33901-SavedStates --- .../Unity.Payments.Web/Pages/PaymentRequests/Index.js | 9 ++------- .../src/Unity.GrantManager.Web/Pages/Applicants/Index.js | 9 ++------- .../Pages/GrantApplications/Index.js | 9 ++------- 3 files changed, 6 insertions(+), 21 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js index 61a8dbcb4..940ffdbc6 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js @@ -543,14 +543,9 @@ $(function () { // Initialize savedStates button styling $('.grp-savedStates').closest('.btn-group').addClass('cstm-save-view'); - // Update button text based on whether any named saved views exist. - // Driven by the StateRestore extension's own 'stateRestore-change' event (the same - // event it uses internally to update its default label) rather than a localStorage - // key guess or a one-shot draw handler - those raced against the extension's own - // loading of previously-saved states from storage on page refresh. + // Update button text to Save View function updateSavedStatesButtonText() { - const savedStatesExist = dataTable.stateRestore.states().length > 0; - $('.grp-savedStates').text(savedStatesExist ? 'Saved States' : 'Save View'); + $('.grp-savedStates').text('Save View'); } dataTable.on('stateRestore-change', updateSavedStatesButtonText); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js index 46c9c51dd..79bf2852d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js @@ -653,14 +653,9 @@ $(function () { // Initialize button styling $('.grp-savedStates').closest('.btn-group').addClass('cstm-save-view'); - // Update button text based on whether any named saved views exist. - // Driven by the StateRestore extension's own 'stateRestore-change' event (the same - // event it uses internally to update its default label) rather than a localStorage - // key guess or a one-shot draw handler - those raced against the extension's own - // loading of previously-saved states from storage on page refresh. + // Update button text based function updateSavedStatesButtonText() { - const savedStatesExist = dataTable.stateRestore.states().length > 0; - $('.grp-savedStates').text(savedStatesExist ? 'Saved States' : 'Save View'); + $('.grp-savedStates').text('Save View'); } dataTable.on('stateRestore-change', updateSavedStatesButtonText); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js index 70524e66f..1a5614554 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js @@ -523,14 +523,9 @@ $(function () { // Initialize button styling $('.grp-savedStates').closest('.btn-group').addClass('cstm-save-view'); - // Update button text based on whether any named saved views exist. - // Driven by the StateRestore extension's own 'stateRestore-change' event (the same - // event it uses internally to update its default label) rather than a localStorage - // key guess or a one-shot draw handler - those raced against the extension's own - // loading of previously-saved states from storage on page refresh. + // Update button text based to Save Views function updateSavedStatesButtonText() { - const savedStatesExist = dataTable.stateRestore.states().length > 0; - $('.grp-savedStates').text(savedStatesExist ? 'Saved States' : 'Save View'); + $('.grp-savedStates').text('Save View'); } dataTable.on('stateRestore-change', updateSavedStatesButtonText); From 47755afe16e4a22d90deccd6af7edc8300368a2b Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:16:24 -0700 Subject: [PATCH 02/38] [AB#33417] Fix HTML Injection in Email Comment Notifications Bug --- .../EmailNotificaions/EmailNotificationService.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs index 0abcfd1e3..ab8abf4d4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs @@ -259,11 +259,16 @@ private async Task RenderCommentNotificationTemplateAsync(string current // Load template from embedded resources or file system string templateContent = await LoadEmailTemplateAsync("CommentNotification"); + // HTML-encode user-controlled values to prevent HTML/script injection in the rendered email + var encodedCurrentUserText = WebUtility.HtmlEncode(currentUserText); + var encodedCommentBody = WebUtility.HtmlEncode(commentBody); + var encodedCommentLink = WebUtility.HtmlEncode(commentLink); + // Replace placeholders with actual values var renderedTemplate = templateContent - .Replace("@Model.CurrentUserText", currentUserText) - .Replace("@Html.Raw(Model.CommentBody)", commentBody) - .Replace("@Model.CommentLink", commentLink); + .Replace("@Model.CurrentUserText", encodedCurrentUserText) + .Replace("@Html.Raw(Model.CommentBody)", encodedCommentBody) + .Replace("@Model.CommentLink", encodedCommentLink); return renderedTemplate; } From 6496d6756be3308baf7db40ad34f20ab66a0db6b Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:17:56 -0700 Subject: [PATCH 03/38] [AB#33420] Fix XSS User Profile Data in Notification Settings Bug --- .../InternalEmailGroups.js | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/InternalEmailGroups.js b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/InternalEmailGroups.js index 538c9c590..6f7143fb0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/InternalEmailGroups.js +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/InternalEmailGroups.js @@ -63,21 +63,28 @@ const emailGroupsManager = { }; }, + // Escape untrusted values before injecting into HTML strings/attributes + escapeHtml: function(value) { + return $('
').text(value ?? '').html(); + }, + // Generate dropdown item HTML for users generateUserDropdownItem: function(user) { const firstName = user.name ? user.name.split(' ')[0] : ''; const lastName = user.surname || ''; + const displayName = this.escapeHtml(user.userName || user.name || 'Unknown'); + const email = this.escapeHtml(user.email || ''); return `
  • + data-user-id="${this.escapeHtml(user.id)}" + data-user-name="${displayName}" + data-user-email="${email}" + data-first-name="${this.escapeHtml(firstName)}" + data-last-name="${this.escapeHtml(lastName)}">
    - ${user.userName || user.name || 'Unknown'} - ${user.email || ''} + ${displayName} + ${email}
  • @@ -559,6 +566,9 @@ const emailGroupsManager = { let pendingUserAdditions = []; let pendingUserRemovals = []; + const escapedGroupName = emailGroupsManager.utils.escapeHtml(group.name); + const escapedGroupDescription = emailGroupsManager.utils.escapeHtml(group.description || ''); + const modalHtml = `
    @@ -142,13 +142,13 @@
    @if (Model.PaymentGroupings[k].Items.Any(x => !x.IsValid)) { - + } else { - - - + + + }
    diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreateHistoricalPayments.cshtml b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreateHistoricalPayments.cshtml index a37314f67..73cbbb924 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreateHistoricalPayments.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreateHistoricalPayments.cshtml @@ -54,7 +54,7 @@ @if (ViewData["Error"] != null) { } else @@ -108,7 +108,7 @@ size="Small" icon-type="Other" class="m-0 p-0 remove-single-payment" - icon="fa fa-xmark" + icon="fa-solid fa-xmark" data-parameter="@item.CorrelationId" /> diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml index 679d5a1a2..d820d22b1 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml @@ -58,7 +58,7 @@ @if (ViewData["Error"] != null) { } else @@ -157,7 +157,7 @@ size="Small" icon-type="Other" class="m-0 p-0 remove-single-payment" - icon="fa fa-xmark" + icon="fa-solid fa-xmark" data-parameter="@item.CorrelationId" /> diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js index 61a8dbcb4..5156ba636 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js @@ -758,7 +758,7 @@ $(function () { index: columnIndex, render: function (data, _, row) { if (row.errorSummary != null && row.errorSummary !== '') { - return `${data} `; + return `${data} `; } else { return data; } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentTags/PaymentTagsSelectionModal.cshtml b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentTags/PaymentTagsSelectionModal.cshtml index 7d04d4c25..797484f2d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentTags/PaymentTagsSelectionModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentTags/PaymentTagsSelectionModal.cshtml @@ -19,7 +19,7 @@ @if (ViewData["Error"] != null) { } else diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/Default.cshtml index bbd81aeee..03cd2df90 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/Default.cshtml @@ -54,7 +54,7 @@ type="button" icon="xmark" class="clear-btn"> - + } else { diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/DatabaseInfoModal.cshtml b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/DatabaseInfoModal.cshtml index 53a6efdcd..64d4c141a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/DatabaseInfoModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/DatabaseInfoModal.cshtml @@ -22,7 +22,7 @@ @foreach (var role in Model.DatabaseInfo.DatabaseRoles) {
  • - @role + @role
  • } @@ -42,7 +42,7 @@ @foreach (var view in Model.DatabaseInfo.ReportingViews) {
  • - @view + @view
  • } 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 index 7d91aecc4..dd0b637f9 100644 --- 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 @@ -48,7 +48,7 @@ aria-label="@($"View role for {tenantRole.TenantName}")" /> @if (tenantRole.IsDefaultInferred) { - @@ -68,14 +68,14 @@ data-tenant-id="@tenantRole.TenantId" data-tenant-name="@tenantRole.TenantName" title="Assign Role to All Views"> - Assign to Views + Assign to Views
    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 index c83331411..7abdccfd0 100644 --- 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 @@ -63,7 +63,7 @@ $(function () { return; } - button.prop('disabled', true).html(' Saving...'); + button.prop('disabled', true).html(' Saving...'); _tenantViewRoleAppService.update(tenantId, { viewRole: viewRole }) .done(function (_) { @@ -134,7 +134,7 @@ $(function () { // Function to save role and then assign to views function saveAndAssignRole(tenantId, tenantName, viewRole, button, row) { - button.prop('disabled', true).html(' Saving & Assigning...'); + button.prop('disabled', true).html(' Saving & Assigning...'); _tenantViewRoleAppService.update(tenantId, { viewRole: viewRole }) .done(function (result) { @@ -153,13 +153,13 @@ $(function () { }) .fail(function () { abp.notify.error('Failed to save view role.'); - button.prop('disabled', false).html(' Assign to Views'); + 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...'); + button.prop('disabled', true).html(' Assigning...'); _tenantViewRoleAppService.assignRoleToViews(tenantId) .done(function () { @@ -169,7 +169,7 @@ $(function () { abp.notify.error('Failed to queue role assignment jobs.'); }) .always(function () { - button.prop('disabled', false).html(' Assign to Views'); + button.prop('disabled', false).html(' Assign to Views'); }); } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml index 6fb9d89be..2d916282b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml @@ -176,7 +176,7 @@ data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="There are duplicate keys detected and catered for, this may affect the view generation and resulting data"> - + Duplicate Keys @@ -187,7 +187,7 @@ data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="A DataGrid has dynamic columns but no matching field mapping was found in the form submission schema. Map the worksheet DataGrid to a CHEFS form field to resolve the individual columns."> - + Unmapped DataGrid @@ -236,7 +236,7 @@
    - + Tip: Use a descriptive name that clearly identifies the data this view will contain.
    @@ -261,7 +261,7 @@
    -
    - - -
    `; } previewDiv.innerHTML = ` +
    + + +
    @@ -277,7 +266,7 @@ $(function () { } function buildTextAreaFieldPreview(item) { - let req = item.dataset.required ? "required" : null; + let req = item.dataset.required === 'True' ? "required" : null; return `

    ${sanitizeHtml(item.dataset.questiondesc)}

    @@ -296,6 +285,7 @@ $(function () { const truncatedValue = option.value.length > 100 ? option.value.substring(0, 100) + " ..." : option.value; return ``; }).join(''); + let req = item.dataset.required === 'True' ? "required" : null; return `

    ${sanitizeHtml(item.dataset.questiondesc)}

    @@ -304,15 +294,17 @@ $(function () { +
    `; } function buildNumberFieldPreview(item) { - let req = item.dataset.required ? "required" : null; + let req = item.dataset.required === 'True' ? "required" : null; return `

    ${sanitizeHtml(item.dataset.questiondesc)}

    @@ -325,6 +317,8 @@ $(function () { } function buildYesNoFieldPreview(item) { + let req = item.dataset.required === 'True' ? "required" : null; + return `

    ${sanitizeHtml(item.dataset.questiondesc)}

    @@ -332,6 +326,7 @@ $(function () { +
    `; } function buildTextFieldPreview(item) { - let req = item.dataset.required ? "required" : null; + let req = item.dataset.required === 'True' ? "required" : null; return `

    ${sanitizeHtml(item.dataset.questiondesc)}

    @@ -497,24 +493,120 @@ function savePreviewChanges(questionId, inputFieldPrefix, saveButtonPrefix, disc updateSubtotal(); } -function savePreviewSectionChanges(formId, sectionId) { - const secSaveButton = document.getElementById('scoresheet-section-save-' + sectionId); - const secDiscardButton = document.getElementById('scoresheet-section-discard-' + sectionId); +// sectionId (hashCode-derived) -> { isDirty: bool, isValid: bool } +// Kept separate from AssessmentScoresWidget's scoresheetSectionState: this +// preview has no assessment, no backend save, and its own hashCode-based +// section id scheme, so it tracks and acts on its own bulk button pair. +let previewSectionState = {}; + +function resetPreviewSectionState() { + previewSectionState = {}; + refreshPreviewBulkActionButtons(); +} + +function getDirtyPreviewSectionIds() { + return Object.keys(previewSectionState).filter( + (id) => previewSectionState[id].isDirty + ); +} + +function updatePreviewSectionHeaderStyle(sectionId, isDirty) { + const headerButton = document.querySelector( + '#panel-' + sectionId + ' .accordion-button' + ); + if (headerButton) { + headerButton.classList.toggle('section-unsaved', isDirty); + } +} + +function refreshPreviewBulkActionButtons() { + const states = Object.values(previewSectionState); + const anyDirty = states.some((s) => s.isDirty); + const anyInvalidDirty = states.some((s) => s.isDirty && !s.isValid); + + const saveAllBtn = document.getElementById('previewSaveAllBtn'); + const discardAllBtn = document.getElementById('previewDiscardAllBtn'); + if (discardAllBtn) discardAllBtn.disabled = !anyDirty; + if (saveAllBtn) saveAllBtn.disabled = !anyDirty || anyInvalidDirty; +} + +// Registered as an extension hook that AssessmentScoresWidget/Default.js's +// shared handleInputChange() calls after validating a section - this script +// bundles alongside it on the Scoresheet configuration page (see +// ScoresheetViewComponent's script bundle contributor). +globalThis.onScoresheetSectionValidated = function (sectionId, isDirty, isInvalid) { + previewSectionState[sectionId] = { isDirty, isValid: !isInvalid }; + updatePreviewSectionHeaderStyle(sectionId, isDirty); + refreshPreviewBulkActionButtons(); +}; + +// Local-only "save": there is no real assessment to persist to here, this +// just accepts the current values as the new baseline, same as clicking +// Save All would visually communicate on the real page. +function savePreviewAllSections() { + const dirtySectionIds = getDirtyPreviewSectionIds(); + if (dirtySectionIds.length === 0) return; + + dirtySectionIds.forEach((sectionId) => { + const answersArr = []; + const inputFieldArr = []; + const origAnswersArr = []; + $.each( + $(`#section-form-${sectionId}`).serializeArray(), + function (_, inputData) { + buildFormData(answersArr, inputData, inputFieldArr, origAnswersArr); + } + ); - const assessmentAnswersArr = []; - const inputFieldArr = []; - const origAnswersArr = []; - const formData = $(`#${formId}`).serializeArray(); + inputFieldArr.forEach((fieldId) => { + const el = document.getElementById(fieldId); + if (el) { + el.dataset.originalValue = el.value; + } + }); - //Handle form object data - $.each(formData, function (_, inputData) { - buildFormData(assessmentAnswersArr, inputData, inputFieldArr, origAnswersArr); + previewSectionState[sectionId] = { isDirty: false, isValid: true }; + updatePreviewSectionHeaderStyle(sectionId, false); }); - secSaveButton.disabled = true; - secDiscardButton.disabled = true; + updateSubtotal(); + refreshPreviewBulkActionButtons(); +} + +function discardAllPreviewSections() { + const dirtySectionIds = getDirtyPreviewSectionIds(); + if (dirtySectionIds.length === 0) return; + + dirtySectionIds.forEach((sectionId) => { + const answersArr = []; + const inputFieldArr = []; + const origAnswersArr = []; + $.each( + $(`#section-form-${sectionId}`).serializeArray(), + function (_, inputData) { + buildFormData(answersArr, inputData, inputFieldArr, origAnswersArr); + } + ); + + inputFieldArr.forEach((fieldId) => { + const questionId = fieldId.split('-').slice(2).join('-'); + const el = document.getElementById(fieldId); + el.value = el.dataset.originalValue; + + const errorMessage = document.getElementById( + 'error-message-' + questionId + ); + if (errorMessage) { + errorMessage.textContent = ''; + } + }); + + previewSectionState[sectionId] = { isDirty: false, isValid: true }; + updatePreviewSectionHeaderStyle(sectionId, false); + }); updateSubtotal(); + refreshPreviewBulkActionButtons(); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml index c5c30a36c..1e68cfe46 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml @@ -20,6 +20,10 @@
    +
    + + +
    @if (Model.Scoresheet.Sections.Any()) {
    @@ -190,10 +194,6 @@ }
    -
    - - -
    diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.css index 17cc777f3..d7b96fe14 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.css @@ -54,6 +54,28 @@ input { margin-top: 0; } +#assessmentScoresWidgetArea .save-button-container { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + position: sticky; + top: 0; + padding-right: 24px; + z-index: 999; +} + +#assessmentScoresWidgetArea .save-button-container .floating-save-btn { + position: static; +} + +#assessment-scoresheet .accordion-button.section-unsaved { + color: #FF0909; +} + +#assessment-scoresheet .preview-btn.section-unsaved:not(.collapsed) { + color: #FFADAD; +} + /* AI-generated answer styling (blue text) */ .ai-generated-answer { color: #0066cc !important; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js index 6b030038f..5e3de19a2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js @@ -83,76 +83,165 @@ globalThis.saveAssessmentScoresWidgetState = saveAssessmentScoresWidgetState; abp.widgets.AssessmentScoresWidget = function ($wrapper) { return { init: function () { + // The widget is re-rendered (and init() re-run) whenever the + // selected application/review changes, without a full page + // reload. Section ids come from the scoresheet template and can + // be shared across assessments, so stale dirty/invalid state + // from a previously-viewed assessment must not carry over. + resetScoresheetSectionState(); restoreAssessmentScoresWidgetState($wrapper[0]); updateSubtotal(); + refreshBulkScoresheetActionButtons(); globalThis.syncAIRateLimitButtons?.(); }, }; }; -function saveScoresSection(formId, sectionId) { - const assessmentId = $('#AssessmentId').val(); - const secSaveButton = document.getElementById( - 'scoresheet-section-save-' + sectionId +// sectionId -> { isDirty: bool, isValid: bool } +const scoresheetSectionState = {}; + +function resetScoresheetSectionState() { + Object.keys(scoresheetSectionState).forEach( + (id) => delete scoresheetSectionState[id] ); - const secDiscardButton = document.getElementById( - 'scoresheet-section-discard-' + sectionId +} + +function getDirtySectionIds() { + return Object.keys(scoresheetSectionState).filter( + (id) => scoresheetSectionState[id].isDirty ); +} - const assessmentAnswersArr = []; - const inputFieldArr = []; - const origAnswersArr = []; - const formData = $(`#${formId}`).serializeArray(); +function getScoresheetSectionName(sectionId) { + const schemaInput = document.getElementById('AssessmentScoresheetSchemaJson'); + if (!schemaInput) return sectionId; - //Handle form object data - $.each(formData, function (_, inputData) { - buildFormData( - assessmentAnswersArr, - inputData, - inputFieldArr, - origAnswersArr - ); - }); + let schema; + try { + schema = JSON.parse(schemaInput.value || '{}'); + } catch { + return sectionId; + } - const data = { - AssessmentId: assessmentId, - AssessmentAnswers: assessmentAnswersArr.map( - ({ questionId, questionType, answer }) => ({ - questionId, - questionType, - answer, - }) - ), - }; + const sections = schema.sections || schema.Sections || []; + const section = sections.find((s) => String(s.id ?? s.Id) === sectionId); + return section ? (section.name ?? section.Name) : sectionId; +} - //Calls an enpoint and disabled buttons - secSaveButton.disabled = true; - secDiscardButton.disabled = true; - unity.grantManager.assessments.assessment - .saveScoresheetSectionAnswers(data) - .done(function () { - abp.notify.success( - 'The answers have been saved successfully.', - 'Save Answers' - ); +function updateSectionHeaderStyle(sectionId, isDirty) { + const headerButton = document.querySelector( + '#heading-' + sectionId + ' .accordion-button' + ); + if (headerButton) { + headerButton.classList.toggle('section-unsaved', isDirty); + } +} - if (inputFieldArr.length > 0) { - for (let item of inputFieldArr) { - const inputField = document.getElementById(item); - inputField.dataset.originalValue = inputField.value; - } - } +function refreshBulkScoresheetActionButtons() { + const states = Object.values(scoresheetSectionState); + const anyDirty = states.some((s) => s.isDirty); + const anyInvalidDirty = states.some((s) => s.isDirty && !s.isValid); - updateSubtotal(); - PubSub.publish( - 'refresh_review_list_without_sidepanel', - assessmentId + const saveAllBtn = document.getElementById('scoresheetSaveAllBtn'); + const discardAllBtn = document.getElementById('scoresheetDiscardAllBtn'); + if (discardAllBtn) discardAllBtn.disabled = !anyDirty; + if (saveAllBtn) saveAllBtn.disabled = !anyDirty || anyInvalidDirty; +} + +function saveAllScoresheetSections() { + const dirtySectionIds = getDirtySectionIds(); + if (dirtySectionIds.length === 0) return; + + const sectionNames = dirtySectionIds.map(getScoresheetSectionName); + + Swal.fire({ + title: 'Are you sure you want to save the changes made to the following section(s)?', + html: + '
      ' + + sectionNames + .map((n) => `
    • ${$('
      ').text(n).html()}
    • `) + .join('') + + '
    ', + showCancelButton: true, + confirmButtonText: 'Save Changes', + cancelButtonText: 'Cancel', + customClass: { + confirmButton: 'btn btn-primary', + cancelButton: 'btn btn-secondary', + }, + }).then((result) => { + if (!result.isConfirmed) return; + + const assessmentId = $('#AssessmentId').val(); + document.getElementById('scoresheetSaveAllBtn').disabled = true; + document.getElementById('scoresheetDiscardAllBtn').disabled = true; + + const combinedAnswers = []; + const combinedInputFieldArr = []; + dirtySectionIds.forEach((sectionId) => { + const answersArr = []; + const inputFieldArr = []; + const origAnswersArr = []; + $.each( + $(`#section-form-${sectionId}`).serializeArray(), + function (_, inputData) { + buildFormData( + answersArr, + inputData, + inputFieldArr, + origAnswersArr + ); + } ); - }) - .fail(function () { - secSaveButton.disabled = false; - secDiscardButton.disabled = false; + combinedAnswers.push( + ...answersArr.map(({ questionId, questionType, answer }) => ({ + questionId, + questionType, + answer, + })) + ); + combinedInputFieldArr.push(...inputFieldArr); }); + + unity.grantManager.assessments.assessment + .saveScoresheetSectionAnswers({ + AssessmentId: assessmentId, + AssessmentAnswers: combinedAnswers, + }) + .done(function () { + abp.notify.success( + 'The answers have been saved successfully.', + 'Save Answers' + ); + + combinedInputFieldArr.forEach((fieldId) => { + const el = document.getElementById(fieldId); + if (el) { + el.dataset.originalValue = el.value; + el.dataset.originalIsHumanConfirmed = + el.dataset.isHumanConfirmed; + } + }); + + dirtySectionIds.forEach((sectionId) => { + scoresheetSectionState[sectionId] = { + isDirty: false, + isValid: true, + }; + updateSectionHeaderStyle(sectionId, false); + }); + + updateSubtotal(); + PubSub.publish( + 'refresh_review_list_without_sidepanel', + assessmentId + ); + refreshBulkScoresheetActionButtons(); + }) + .fail(function () { + refreshBulkScoresheetActionButtons(); + }); + }); } function markAsHumanConfirmed(inputElement) { @@ -169,23 +258,29 @@ function markAsHumanConfirmed(inputElement) { inputElement.classList.remove('ai-generated-answer'); inputElement.classList.add('human-confirmed-answer'); - // Remove AI indicator if it exists + // Hide (not remove) the AI indicator and citation so they can be + // restored later if the user discards an unsaved edit. const aiIndicator = inputElement.parentElement.querySelector( '.ai-answer-indicator' ); - if (aiIndicator) { - aiIndicator.remove(); + aiIndicator.classList.add('d-none'); + } + const aiCitation = inputElement.parentElement.querySelector( + '.ai-citation' + ); + if (aiCitation) { + aiCitation.classList.add('d-none'); } - // Remove low-confidence-badge from the question header (accordion button) + // Hide the low-confidence badge from the question header (accordion button) const questionAccordion = inputElement.closest('.accordion-item'); if (questionAccordion) { const lowConfidenceBadge = questionAccordion.querySelector( '.low-confidence-badge' ); if (lowConfidenceBadge) { - lowConfidenceBadge.remove(); + lowConfidenceBadge.classList.add('d-none'); } // Also remove the low-confidence-question class from the accordion item @@ -200,6 +295,38 @@ function markAsHumanConfirmed(inputElement) { } } +function restoreAiIndicators(inputElement) { + inputElement.dataset.isHumanConfirmed = 'false'; + inputElement.classList.remove('human-confirmed-answer'); + inputElement.classList.add('ai-generated-answer'); + + const aiIndicator = inputElement.parentElement.querySelector( + '.ai-answer-indicator' + ); + if (aiIndicator) { + aiIndicator.classList.remove('d-none'); + } + const aiCitation = inputElement.parentElement.querySelector( + '.ai-citation' + ); + if (aiCitation) { + aiCitation.classList.remove('d-none'); + } + + const questionAccordion = inputElement.closest('.accordion-item'); + if (questionAccordion) { + const lowConfidenceBadge = questionAccordion.querySelector( + '.low-confidence-badge' + ); + if (lowConfidenceBadge) { + // Its continued presence in the DOM (hidden, not removed) is itself + // the signal that this question was originally low-confidence. + lowConfidenceBadge.classList.remove('d-none'); + questionAccordion.classList.add('low-confidence-question'); + } + } +} + // Utility function to help debug AI answer integration function debugAIAnswers() { const aiAnswers = document.querySelectorAll( @@ -266,50 +393,78 @@ function debugAIAnswers() { })), }; } -function discardChangesScoresSection(formId, sectionId) { - const secSaveButton = document.getElementById( - 'scoresheet-section-save-' + sectionId - ); - const secDiscardButton = document.getElementById( - 'scoresheet-section-discard-' + sectionId - ); - const assessmentAnswersArr = []; - const inputFieldArr = []; - const origAnswersArr = []; - const formData = $(`#${formId}`).serializeArray(); +function discardAllScoresheetSections() { + const dirtySectionIds = getDirtySectionIds(); + if (dirtySectionIds.length === 0) return; + + const sectionNames = dirtySectionIds.map(getScoresheetSectionName); + + Swal.fire({ + title: 'You have unsaved changes in the following section(s):', + html: + '
      ' + + sectionNames + .map((n) => `
    • ${$('
      ').text(n).html()}
    • `) + .join('') + + '

    Discarding changes will permanently remove all unsaved updates. This action cannot be undone.

    ', + showCancelButton: true, + confirmButtonText: 'Discard Changes', + cancelButtonText: 'Cancel', + customClass: { + confirmButton: 'btn btn-danger', + cancelButton: 'btn btn-secondary', + }, + }).then((result) => { + if (!result.isConfirmed) return; + + dirtySectionIds.forEach((sectionId) => { + const answersArr = []; + const inputFieldArr = []; + const origAnswersArr = []; + $.each( + $(`#section-form-${sectionId}`).serializeArray(), + function (_, inputData) { + buildFormData( + answersArr, + inputData, + inputFieldArr, + origAnswersArr + ); + } + ); - $.each(formData, function (_, inputData) { - buildFormData( - assessmentAnswersArr, - inputData, - inputFieldArr, - origAnswersArr - ); - }); + inputFieldArr.forEach((fieldId) => { + const questionId = fieldId.split('-').slice(2).join('-'); + const el = document.getElementById(fieldId); + el.value = el.dataset.originalValue; - //Handle dynamic data to bring back original values - if (inputFieldArr.length > 0) { - for (let item of inputFieldArr) { - let questionId = item.split('-').slice(2).join('-'); - const inputField = document.getElementById(item); - const originalValue = inputField.dataset.originalValue; - inputField.value = originalValue; - - if ( - item.includes('answer-number-') || - item.includes('answer-text-') - ) { const errorMessage = document.getElementById( 'error-message-' + questionId ); - errorMessage.textContent = ''; - } - } - } + if (errorMessage) { + errorMessage.textContent = ''; + } - secSaveButton.disabled = true; - secDiscardButton.disabled = true; + // Only restore AI styling if the checkpoint itself was never + // human-confirmed - i.e. this edit was never saved. + if ( + el.dataset.originalIsHumanConfirmed === 'false' && + el.dataset.isHumanConfirmed === 'true' + ) { + restoreAiIndicators(el); + } + }); + + scoresheetSectionState[sectionId] = { + isDirty: false, + isValid: true, + }; + updateSectionHeaderStyle(sectionId, false); + }); + + refreshBulkScoresheetActionButtons(); + }); } function buildFormData( @@ -453,6 +608,15 @@ function compareObj(objA, objB) { return res; } +function validateRequiredSelectField(selectField, errorMessage) { + if (selectField.validity.valueMissing) { + errorMessage.textContent = 'This field is required.'; + return false; + } + errorMessage.textContent = ''; + return true; +} + function handleInputChange(questionId, inputFieldPrefix) { const sectionFormId = $(`#${inputFieldPrefix + questionId}`) .closest('form') @@ -461,12 +625,10 @@ function handleInputChange(questionId, inputFieldPrefix) { sectionFormId !== null ? sectionFormId?.split('-').slice(2).join('-') : null; - const secSaveButton = document.getElementById( - 'scoresheet-section-save-' + sectionId - ); - const secDiscardButton = document.getElementById( - 'scoresheet-section-discard-' + sectionId - ); + + if (!sectionId) { + return; + } const assessmentAnswersArr = []; const inputFieldArr = []; @@ -484,29 +646,61 @@ function handleInputChange(questionId, inputFieldPrefix) { //Handle values and objects comparison for (let x = 0; x < assessmentAnswersArr.length; x++) { + const qId = assessmentAnswersArr[x].questionId; + const errorMessage = document.getElementById( + 'error-message-' + qId + ); + if (assessmentAnswersArr[x].questionType === 1) { let inputNumberField = document.getElementById( - 'answer-number-' + assessmentAnswersArr[x].questionId - ); - let numberErrorMessage = document.getElementById( - 'error-message-' + assessmentAnswersArr[x].questionId + 'answer-number-' + qId ); assessmentAnswersArr[x].isValid = validateNumericField( inputNumberField, - numberErrorMessage + errorMessage ); } else if (assessmentAnswersArr[x].questionType === 2) { let inputTextField = document.getElementById( - 'answer-text-' + assessmentAnswersArr[x].questionId - ); - let textErrorMessage = document.getElementById( - 'error-message-' + assessmentAnswersArr[x].questionId + 'answer-text-' + qId ); if (inputTextField.required) { assessmentAnswersArr[x].isValid = validateTextField( inputTextField, - textErrorMessage + errorMessage + ); + } + } else if (assessmentAnswersArr[x].questionType === 14) { + let inputTextAreaField = document.getElementById( + 'answer-textarea-' + qId + ); + + if (inputTextAreaField.required) { + assessmentAnswersArr[x].isValid = validateTextField( + inputTextAreaField, + errorMessage + ); + } + } else if (assessmentAnswersArr[x].questionType === 6) { + let inputYesNoField = document.getElementById( + 'answer-yesno-' + qId + ); + + if (inputYesNoField.required) { + assessmentAnswersArr[x].isValid = validateRequiredSelectField( + inputYesNoField, + errorMessage + ); + } + } else if (assessmentAnswersArr[x].questionType === 12) { + let inputSelectListField = document.getElementById( + 'answer-selectlist-' + qId + ); + + if (inputSelectListField.required) { + assessmentAnswersArr[x].isValid = validateRequiredSelectField( + inputSelectListField, + errorMessage ); } } @@ -516,28 +710,31 @@ function handleInputChange(questionId, inputFieldPrefix) { ); } - //Handle button events + //Handle section dirty/valid state let isNotSame = assessmentAnswersArr.some((item) => item.isSame === false); let isInValid = assessmentAnswersArr.some((item) => item.isValid === false); - if (isNotSame && isInValid) { - secSaveButton.disabled = true; - secDiscardButton.disabled = false; - } - - if (isNotSame && !isInValid) { - secSaveButton.disabled = false; - secDiscardButton.disabled = false; - } else { - secSaveButton.disabled = true; - } + scoresheetSectionState[sectionId] = { + isDirty: isNotSame, + isValid: !isInValid, + }; + updateSectionHeaderStyle(sectionId, isNotSame); + refreshBulkScoresheetActionButtons(); + + // Optional extension point: the Scoresheet configuration preview + // (Scoresheet.js) shares this function via the same script bundle and + // registers this hook to drive its own (separately-tracked) bulk + // Save All/Discard All state. No-op on the real AssessmentScoresWidget. + globalThis.onScoresheetSectionValidated?.(sectionId, isNotSame, isInValid); } function validateTextField(textInputField, errorMessage) { if ( - textInputField.validity.tooShort || textInputField.validity.valueMissing ) { + errorMessage.textContent = 'This field is required.'; + return false; + } else if (textInputField.validity.tooShort) { errorMessage.textContent = 'The answer is too short. Minimum length is ' + textInputField.minLength + @@ -556,7 +753,10 @@ function validateTextField(textInputField, errorMessage) { } function validateNumericField(numericInputField, errorMessage) { - if (numericInputField.validity.rangeOverflow) { + if (numericInputField.validity.valueMissing) { + errorMessage.textContent = 'This field is required.'; + return false; + } else if (numericInputField.validity.rangeOverflow) { errorMessage.textContent = `Value must be less than or equal to ${numericInputField.max}.`; return false; } else if (numericInputField.validity.rangeUnderflow) { @@ -726,12 +926,11 @@ $(function () { saveAssessmentScores(); }); - // Dynamically-generated section buttons (event delegation) - $(document).on('click', '[id^="scoresheet-section-save-"]', function () { - saveScoresSection($(this).data('form-id'), $(this).data('section-id')); + // Save All / Discard All (assessment-wide) + $(document).on('click', '#scoresheetSaveAllBtn', function () { + saveAllScoresheetSections(); }); - $(document).on('click', '[id^="scoresheet-section-discard-"]', function () { - discardChangesScoresSection($(this).data('form-id'), $(this).data('section-id')); + $(document).on('click', '#scoresheetDiscardAllBtn', function () { + discardAllScoresheetSections(); }); - }); From 42b3e0191f39f9074d332b080a2c47baa7313a22 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:10:40 -0700 Subject: [PATCH 19/38] [AB#33783] Secure PaymentThresholdAppService critical endpoints --- .../Payments/AccountCodingAppService.cs | 12 ------ .../Payments/PaymentThresholdAppService.cs | 41 +++++++++++++------ 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Payments/AccountCodingAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Payments/AccountCodingAppService.cs index 7d9b38314..238aa90b0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Payments/AccountCodingAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Payments/AccountCodingAppService.cs @@ -28,18 +28,6 @@ public AccountCodingAppService(IRepository repository) : ba /// /// Deletes the account coding with the specified . /// - /// - /// - /// - /// API Restriction - /// Has been removed from API registration - /// - /// - /// Permission Policy - /// Requires - /// - /// - /// /// The unique identifier of the account coding to delete. /// A task that represents the asynchronous delete operation. [RemoteService(false)] diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Payments/PaymentThresholdAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Payments/PaymentThresholdAppService.cs index 1ba3e518f..1a40459f4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Payments/PaymentThresholdAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Payments/PaymentThresholdAppService.cs @@ -1,23 +1,38 @@ +using Microsoft.AspNetCore.Authorization; using System; +using System.Threading.Tasks; +using Unity.GrantManager.Permissions; using Unity.Payments.Domain.PaymentThresholds; using Unity.Payments.PaymentThresholds; +using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; using Volo.Abp.Domain.Repositories; -namespace Unity.GrantManager.Payments -{ - public class PaymentThresholdAppService : - CrudAppService< - PaymentThreshold, - PaymentThresholdDto, - Guid, - PagedAndSortedResultRequestDto, - UpdatePaymentThresholdDto>, IPaymentThresholdAppService +namespace Unity.GrantManager.Payments; + +[Authorize] +public class PaymentThresholdAppService : + CrudAppService< + PaymentThreshold, + PaymentThresholdDto, + Guid, + PagedAndSortedResultRequestDto, + UpdatePaymentThresholdDto>, IPaymentThresholdAppService +{ + public PaymentThresholdAppService(IRepository repository) + : base(repository) { - public PaymentThresholdAppService(IRepository repository) - : base(repository) - { - } + CreatePolicyName = UnitySettingManagementPermissions.ConfigurePayments; + UpdatePolicyName = UnitySettingManagementPermissions.ConfigurePayments; + DeletePolicyName = UnitySettingManagementPermissions.ConfigurePayments; } + + [RemoteService(false)] + public override Task CreateAsync(UpdatePaymentThresholdDto input) + => base.CreateAsync(input); + + [RemoteService(false)] + public override Task DeleteAsync(Guid id) + => base.DeleteAsync(id); } From de46479df406617cc4749a3da6a016ebb91fa5fb Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:13:09 -0700 Subject: [PATCH 20/38] [AB#33783] Secure IntakeAppService critical endpoints --- .../Intakes/IntakeAppService.cs | 39 ++++++++++++------- .../Payments/PaymentThresholdAppService.cs | 1 - 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeAppService.cs index 8ad41da8e..03d9456a8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeAppService.cs @@ -1,25 +1,36 @@ using Microsoft.AspNetCore.Authorization; using System; +using System.Threading.Tasks; using Unity.GrantManager.Permissions; +using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; using Volo.Abp.Domain.Repositories; -namespace Unity.GrantManager.Intakes +namespace Unity.GrantManager.Intakes; + +[Authorize(GrantManagerPermissions.Intakes.Default)] +public class IntakeAppService : + CrudAppService< + Intake, + IntakeDto, + Guid, + PagedAndSortedResultRequestDto, + CreateUpdateIntakeDto>, + IIntakeAppService { - [Authorize(GrantManagerPermissions.Intakes.Default)] - public class IntakeAppService : - CrudAppService< - Intake, - IntakeDto, - Guid, - PagedAndSortedResultRequestDto, - CreateUpdateIntakeDto>, - IIntakeAppService + public IntakeAppService(IRepository repository) + : base(repository) { - public IntakeAppService(IRepository repository) - : base(repository) - { - } + DeletePolicyName = GrantManagerPermissions.Intakes.Default; } + + /// + /// Deletes the intake with the specified . + /// + /// The unique identifier of the intake to delete. + /// A task that represents the asynchronous delete operation. + [RemoteService(false)] + public override Task DeleteAsync(Guid id) + => base.DeleteAsync(id); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Payments/PaymentThresholdAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Payments/PaymentThresholdAppService.cs index 1a40459f4..7b0680249 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Payments/PaymentThresholdAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Payments/PaymentThresholdAppService.cs @@ -24,7 +24,6 @@ public PaymentThresholdAppService(IRepository repository : base(repository) { CreatePolicyName = UnitySettingManagementPermissions.ConfigurePayments; - UpdatePolicyName = UnitySettingManagementPermissions.ConfigurePayments; DeletePolicyName = UnitySettingManagementPermissions.ConfigurePayments; } From 7783656226367627c594d8293dd7fc98aa2a8510 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:17:23 -0700 Subject: [PATCH 21/38] [AB#33783] Secure ApplicationLinksAppService critical endpoints --- .../GrantApplications/ApplicationLinksAppService.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs index 51d12a9b1..2f1b8f1d9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Unity.GrantManager.ApplicationForms; using Unity.GrantManager.Applications; +using Volo.Abp; using Volo.Abp.Application.Services; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; @@ -445,6 +446,16 @@ public async Task GetApplicationDetailsByReferenceAsync } } + /// + /// Updates the intake with the specified using the provided . + /// + /// Use to update the link type instead of deleting. + /// + /// + /// + [RemoteService(false)] + public override Task UpdateAsync(Guid id, ApplicationLinksDto input) => base.UpdateAsync(id, input); + public async Task UpdateLinkTypeAsync(Guid applicationLinkId, ApplicationLinkType newLinkType) { Logger.LogInformation("UpdateLinkTypeAsync called with linkId: {LinkId}, newLinkType: {LinkType}", applicationLinkId, newLinkType); From cf12a2f152b3746e311810ce68f610613ddd54a1 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:20:39 -0700 Subject: [PATCH 22/38] [AB#33783] Secure ApplicationFormAppService critical endpoints --- .../ApplicationForms/ApplicationFormAppService.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs index e4f8289d3..23b6b42b4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs @@ -9,8 +9,8 @@ using Unity.GrantManager.GrantApplications; using Unity.GrantManager.Integrations.Chefs; using Unity.GrantManager.Permissions; -using Unity.Payments.Permissions; using Unity.Payments.Enums; +using Unity.Payments.Permissions; using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; @@ -52,6 +52,8 @@ public ApplicationFormAppService( _applicationService = applicationService; _applicationFormSubmissionRepository = applicationFormSubmissionRepository; _formsApiService = formsApiService; + + DeletePolicyName = GrantManagerPermissions.ApplicationForms.Default; } [Authorize(GrantManagerPermissions.ApplicationForms.Default)] @@ -307,4 +309,8 @@ public async Task GetFormDetailsByApplicationIdAsync( ApplicationFormVersion = formDetails.ApplicationFormVersion }; } + + [RemoteService(false)] + public override Task DeleteAsync(Guid id) + => base.DeleteAsync(id); } From 5fdba9ca83ee65bac0dc1aeb1b8c8d72033dddc6 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:34:05 -0700 Subject: [PATCH 23/38] [AB#33783] Secure ApplicationFormVersionAppService critical endpoints --- .../ApplicationFormVersionAppService.cs | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs index 4bedeb5dd..f59d747bd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs @@ -1,35 +1,33 @@ -using Microsoft.Extensions.Logging; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; using System; +using System.Collections.Generic; using System.Linq; -using System.Text.RegularExpressions; using System.Text.Json; -using System.Collections.Generic; +using System.Text.RegularExpressions; using System.Threading.Tasks; -using Unity.GrantManager.Applications; using Unity.AI.Cooldown; using Unity.AI.Features; -using Unity.AI.Permissions; using Unity.AI.Operations; +using Unity.AI.Permissions; using Unity.AI.Requests; -using Unity.AI.Responses; -using Unity.AI.Runtime; +using Unity.Flex.Domain.Worksheets; +using Unity.GrantManager.ApplicationForms.Mapping; +using Unity.GrantManager.Applications; using Unity.GrantManager.Forms; using Unity.GrantManager.Intakes; +using Unity.GrantManager.Intakes.Mapping; using Unity.GrantManager.Integrations.Chefs; -using Unity.GrantManager.ApplicationForms.Mapping; using Unity.GrantManager.Reporting.FieldGenerators; using Unity.Modules.Shared.Features; +using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; using Volo.Abp.Domain.Entities; using Volo.Abp.Domain.Repositories; using Volo.Abp.Features; -using Volo.Abp; using Volo.Abp.Uow; -using Unity.GrantManager.Intakes.Mapping; -using Unity.Flex.Domain.Worksheets; namespace Unity.GrantManager.ApplicationForms { @@ -62,12 +60,17 @@ public class ApplicationFormVersionAppService( public override async Task CreateAsync(CreateUpdateApplicationFormVersionDto input) => await base.CreateAsync(input); + [RemoteService(false)] public override async Task UpdateAsync(Guid id, CreateUpdateApplicationFormVersionDto input) => await base.UpdateAsync(id, input); public override async Task GetAsync(Guid id) => await base.GetAsync(id); + [RemoteService(false)] + public override Task DeleteAsync(Guid id) + => base.DeleteAsync(id); + public async Task InitializePublishedFormVersion(dynamic chefsForm, Guid applicationFormId, bool initializePublishedOnly) { if (chefsForm == null) return false; @@ -348,9 +351,9 @@ public virtual async Task GenerateMappingAsync(Guid i Data = FormMappingPromptDataBuilder.Build(readModel) }); var submissionHeaderMapping = FormMappingResponseMapper.BuildSubmissionHeaderMapping(response); - var applicationFormVersion = await repository.GetAsync(id); + var applicationFormVersion = await Repository.GetAsync(id); applicationFormVersion.SubmissionHeaderMapping = submissionHeaderMapping; - await repository.UpdateAsync(applicationFormVersion, true); + await Repository.UpdateAsync(applicationFormVersion, true); return new ApplicationFormMappingDto { From 6351322dcc242c4260a1e35ebd0976e90649bc99 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:36:13 -0700 Subject: [PATCH 24/38] [AB#33783] Secure EndpointManagementAppService critical endpoints --- .../ApplicationForms/ApplicationFormAppService.cs | 2 +- .../Endpoints/EndpointManagementAppService.cs | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs index 23b6b42b4..01642a6fc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs @@ -222,7 +222,7 @@ public async Task SavePaymentConfiguration(FormPaymentConfigurationDto dto) throw new BusinessException(GrantManagerDomainErrorCodes.ChildFormCannotReferenceSelf); } - var parentForm = await Repository.FindAsync(dto.ParentFormId.Value) ?? throw new BusinessException(GrantManagerDomainErrorCodes.ChildFormRequiresParentForm); + _ = await Repository.FindAsync(dto.ParentFormId.Value) ?? throw new BusinessException(GrantManagerDomainErrorCodes.ChildFormRequiresParentForm); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs index f581bd38e..0172b072d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs @@ -1,9 +1,9 @@ -using System; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Caching.Distributed; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; -using Microsoft.Extensions.Caching.Distributed; using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; @@ -188,5 +188,9 @@ public async Task ClearCacheAsync(Guid? tenantId = null) // Clear the key set itself await _cache.RemoveAsync(keySetKey); } + + [RemoteService(false)] + public override Task DeleteAsync(Guid id) + => base.DeleteAsync(id); } } \ No newline at end of file From 5da3bfcf661017e559d2a1c174b4c76d815dec40 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:56:43 -0700 Subject: [PATCH 25/38] [AB#33783] Update ApplicationLinks Comments --- .../GrantApplications/ApplicationLinksAppService.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs index 2f1b8f1d9..6784aaf97 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs @@ -447,12 +447,12 @@ public async Task GetApplicationDetailsByReferenceAsync } /// - /// Updates the intake with the specified using the provided . + /// Updates an application link with the specified using the provided . /// - /// Use to update the link type instead of deleting. - /// - /// - /// + /// Remote updates are disabled; use to change the link type. + /// The unique identifier of the application link to update. + /// The updated application link data. + /// The updated application link. [RemoteService(false)] public override Task UpdateAsync(Guid id, ApplicationLinksDto input) => base.UpdateAsync(id, input); From 649a1a32f37ea6b1dd24cf900d79d0100740de10 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 7 Aug 2026 09:23:48 -0700 Subject: [PATCH 26/38] feature/AB#32293-DBMigratorLogging --- .../GrantManagerEntityFrameworkCoreModule.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerEntityFrameworkCoreModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerEntityFrameworkCoreModule.cs index 2a48fe23a..f351815cc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerEntityFrameworkCoreModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerEntityFrameworkCoreModule.cs @@ -60,7 +60,7 @@ public override void ConfigureServices(ServiceConfigurationContext context) options.Configure(dbContextConfiguration => { dbContextConfiguration.UseNpgsql(); - dbContextConfiguration.DbContextOptions.LogTo(Console.WriteLine, LogLevel.Information); + dbContextConfiguration.DbContextOptions.LogTo(Console.WriteLine, LogLevel.Error); }); }); } From d78448f06684d68d36aa66be4ce5e1304be6c7f4 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Fri, 7 Aug 2026 14:27:55 -0700 Subject: [PATCH 27/38] AB#33248: Make the disabled buttons more visible --- .../Components/Scoresheet/Scoresheet.css | 23 ++++++++++++++++++- .../Components/Scoresheet/Scoresheet.js | 10 +++++--- .../AssessmentScoresWidget/Default.cshtml | 6 ++--- .../AssessmentScoresWidget/Default.css | 21 ++++++++++++++++- .../AssessmentScoresWidget/Default.js | 18 +++++++++++++++ 5 files changed, 70 insertions(+), 8 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.css b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.css index aa69e9567..e1e512fd4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.css +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.css @@ -28,14 +28,25 @@ gap: 0.5rem; position: sticky; top: 0; - padding-right: 24px; + padding: 8px 24px 8px 0; z-index: 999; + background-color: #ffffff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06); } #previewScoresheetActions .floating-save-btn { position: static; } +#previewSaveAllBtn:disabled { + --bs-btn-disabled-bg: #255A90; + --bs-btn-disabled-color: #ffffff; +} + +#previewDiscardAllBtn:disabled { + --bs-btn-disabled-color: #6c757d; +} + #preview .accordion-button.section-unsaved, #scoresheet-preview .accordion-button.section-unsaved { color: #FF0909; @@ -46,6 +57,16 @@ color: #FFADAD; } +#preview .accordion-button.question-unsaved, +#scoresheet-preview .accordion-button.question-unsaved { + color: #FF0909; +} + +#preview .accordion-button.question-unsaved:not(.collapsed), +#scoresheet-preview .accordion-button.question-unsaved:not(.collapsed) { + color: #FFADAD; +} + #preview .preview-btn.collapsed::after { -webkit-filter: grayscale(1) invert(0); filter: grayscale(1) invert(0); diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js index 3144e28db..c670b00b1 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js @@ -217,7 +217,7 @@ $(function () { accordionHTML += `

    -

    @@ -240,8 +240,8 @@ $(function () { previewDiv.innerHTML = `
    - - + +
    @@ -563,6 +563,8 @@ function savePreviewAllSections() { if (el) { el.dataset.originalValue = el.value; } + const questionId = fieldId.split('-').slice(2).join('-'); + updateQuestionHeaderStyle(questionId, false); }); previewSectionState[sectionId] = { isDirty: false, isValid: true }; @@ -599,6 +601,8 @@ function discardAllPreviewSections() { if (errorMessage) { errorMessage.textContent = ''; } + + updateQuestionHeaderStyle(questionId, false); }); previewSectionState[sectionId] = { isDirty: false, isValid: true }; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml index 1e68cfe46..d2e91c111 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml @@ -21,8 +21,8 @@
    - - + +
    @if (Model.Scoresheet.Sections.Any()) { @@ -80,7 +80,7 @@

    -