From c36e0a604392c55879ae196cd504bb7543ad00b2 Mon Sep 17 00:00:00 2001 From: Norm Brandinger Date: Sat, 17 Jan 2026 13:17:08 -0500 Subject: [PATCH] security/acme-client: Fix certificate grid button handlers for new UIBootgrid Fixed two issues with the certificate grid action buttons not working correctly with the new UIBootgrid/Tabulator system in OPNsense 25.7.x: 1. Buttons not firing (no popup appearing): - Added e.stopPropagation() and e.preventDefault() to all command handlers to prevent Tabulator's row selection from intercepting button clicks - Changed custom command handlers (toggle, sign, revoke, removekey, automation, import) to use document-level event delegation with namespaced events 2. Multiple clicks required to close dialogs: - Moved custom command handlers OUTSIDE the loaded.rs.jquery.bootgrid callback to prevent duplicate handlers being attached on each grid reload - Used .off() before .on() with namespaced events (e.g., "click.acme.import") to ensure only one handler exists - Added .off('click').on() pattern for handlers that must remain inside the callback (edit, copy, delete, add, deleteSelected) The loaded.rs.jquery.bootgrid event fires on every grid reload, so handlers attached inside it would stack up, causing multiple dialogs to open on a single click. Fixes opnsense/plugins#5123 Co-Authored-By: Claude Opus 4.5 --- .../OPNsense/AcmeClient/certificates.volt | 273 +++++++++--------- 1 file changed, 138 insertions(+), 135 deletions(-) diff --git a/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/certificates.volt b/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/certificates.volt index e824edd850..7ec502e5ad 100644 --- a/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/certificates.volt +++ b/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/certificates.volt @@ -17,7 +17,7 @@ are permitted provided that the following conditions are met: this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. -THIS SOFTWARE IS PROVIDED “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, +THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, @@ -141,7 +141,131 @@ POSSIBILITY OF SUCH DAMAGE. */ const grid_certificates = $("#grid-certificates").UIBootgrid($.extend(gridParams, { options: gridopt })); - $("#grid_certificates").on("loaded.rs.jquery.bootgrid", function (e) + // edit dialog id to use + var editDlg = $("#grid-certificates").attr('data-editDialog'); + var gridId = "grid-certificates"; + + /** + * Bind custom command handlers ONCE using namespaced events. + * These are bound to document to use event delegation, with namespaces + * to allow clean unbinding and prevent duplicate handlers. + * stopPropagation() prevents Tabulator row selection from intercepting clicks. + */ + + // toggle item + $(document).off("click.acme.toggle").on("click.acme.toggle", "#grid-certificates .command-toggle", function(e) { + e.stopPropagation(); + e.preventDefault(); + if (gridParams['toggle'] != undefined) { + var uuid = $(this).data("row-id"); + $(this).addClass("fa-spinner fa-pulse"); + ajaxCall(url=gridParams['toggle'] + uuid, + sendData={}, callback=function(data, status) { + std_bootgrid_reload(gridId); + }); + } else { + console.log("[grid] action toggle missing"); + } + }); + + // sign cert + $(document).off("click.acme.sign").on("click.acme.sign", "#grid-certificates .command-sign", function(e) { + e.stopPropagation(); + e.preventDefault(); + if (gridParams['sign'] != undefined) { + var uuid = $(this).data("row-id"); + stdDialogConfirm('{{ lang._('Confirmation Required') }}', + '{{ lang._('Forcefully issue or renew the selected certificate?') }}', + '{{ lang._('Yes') }}', '{{ lang._('Cancel') }}', function() { + // Handle HAProxy integration (no-op if not applicable) + ajaxCall(url="/api/acmeclient/settings/fetch_ha_proxy_integration", sendData={}, callback=function(data, status) { + ajaxCall(url=gridParams['sign'] + uuid, sendData={}, callback=function(data, status) { + $("#"+gridId).bootgrid("reload"); + }); + }); + }); + } else { + console.log("[grid] action sign missing"); + } + }); + + // revoke cert + $(document).off("click.acme.revoke").on("click.acme.revoke", "#grid-certificates .command-revoke", function(e) { + e.stopPropagation(); + e.preventDefault(); + if (gridParams['revoke'] != undefined) { + var uuid = $(this).data("row-id"); + stdDialogConfirm('{{ lang._('Confirmation Required') }}', + '{{ lang._('Revoke selected certificate?') }}', + '{{ lang._('Yes') }}', '{{ lang._('Cancel') }}', function() { + ajaxCall(url=gridParams['revoke'] + uuid, + sendData={}, callback=function(data, status) { + $("#"+gridId).bootgrid("reload"); + }); + }, 'danger'); + } else { + console.log("[grid] action revoke missing"); + } + }); + + // remove private key + $(document).off("click.acme.removekey").on("click.acme.removekey", "#grid-certificates .command-removekey", function(e) { + e.stopPropagation(); + e.preventDefault(); + if (gridParams['removekey'] != undefined) { + var uuid = $(this).data("row-id"); + stdDialogConfirm('{{ lang._('Confirmation Required') }}', + '{{ lang._('Really remove the private key?%s%sThe certificate will be completely reset. This is useful when the private key has been compromised or when you have changed the key options and want to regenerate the private key.%sNote that you have to revalidate the certificate afterwards in order to create a new private key and a matching certificate.') | format('
', '
', '
') }}', + '{{ lang._('Yes') }}', '{{ lang._('Cancel') }}', function() { + ajaxCall(url=gridParams['removekey'] + uuid, + sendData={}, callback=function(data, status) { + $("#"+gridId).bootgrid("reload"); + }); + }, 'danger'); + } else { + console.log("[grid] action removekey missing"); + } + }); + + // run automation + $(document).off("click.acme.automation").on("click.acme.automation", "#grid-certificates .command-automation", function(e) { + e.stopPropagation(); + e.preventDefault(); + if (gridParams['automation'] != undefined) { + var uuid = $(this).data("row-id"); + stdDialogConfirm('{{ lang._('Confirmation Required') }}', + '{{ lang._('Rerun all automations for the selected certificate?') }}', + '{{ lang._('Yes') }}', '{{ lang._('Cancel') }}', function() { + ajaxCall(url=gridParams['automation'] + uuid, + sendData={}, callback=function(data, status) { + $("#"+gridId).bootgrid("reload"); + }); + }); + } else { + console.log("[grid] action automation missing"); + } + }); + + // import certificate into trust storage + $(document).off("click.acme.import").on("click.acme.import", "#grid-certificates .command-import", function(e) { + e.stopPropagation(); + e.preventDefault(); + if (gridParams['import'] != undefined) { + var uuid = $(this).data("row-id"); + stdDialogConfirm('{{ lang._('Confirmation Required') }}', + '{{ lang._('(Re-) import the selected certificate and associated CA certificates into the trust storage?') }}', + '{{ lang._('Yes') }}', '{{ lang._('Cancel') }}', function() { + ajaxCall(url=gridParams['import'] + uuid, + sendData={}, callback=function(data, status) { + $("#"+gridId).bootgrid("reload"); + }); + }); + } else { + console.log("[grid] action import missing"); + } + }); + + grid_certificates.on("loaded.rs.jquery.bootgrid", function (e) { // toggle all rendered tooltips (once for all) $('.bootgrid-tooltip').tooltip(); @@ -154,12 +278,8 @@ POSSIBILITY OF SUCH DAMAGE. } }); - // edit dialog id to use - var editDlg = $(this).attr('data-editDialog'); - var gridId = $(this).attr('id'); - // link Add new to child button with data-action = add - $(this).find("*[data-action=add]").click(function(){ + $(this).find("*[data-action=add]").off('click').on('click', function(){ if ( gridParams['get'] != undefined && gridParams['add'] != undefined) { var urlMap = {}; urlMap['frm_' + editDlg] = gridParams['get']; @@ -187,7 +307,7 @@ POSSIBILITY OF SUCH DAMAGE. }); // link delete selected items action - $(this).find("*[data-action=deleteSelected]").click(function(){ + $(this).find("*[data-action=deleteSelected]").off('click').on('click', function(){ if ( gridParams['del'] != undefined) { stdDialogConfirm('{{ lang._('Confirm removal') }}', '{{ lang._('Do you want to remove the selected item?') }}', @@ -209,19 +329,11 @@ POSSIBILITY OF SUCH DAMAGE. } }); - }); - - /** - * copy actions for items from opnsense_bootgrid_plugin.js - */ - grid_certificates.on("loaded.rs.jquery.bootgrid", function(){ - // edit dialog id to use - var editDlg = $(this).attr('data-editDialog'); - var gridId = $(this).attr('id'); - - // edit item - grid_certificates.find(".command-edit").on("click", function(e) + // edit item - UIBootgrid handles this, but we override for custom dialog handling + grid_certificates.find(".command-edit").off('click').on("click", function(e) { + e.stopPropagation(); + e.preventDefault(); if (editDlg != undefined && gridParams['get'] != undefined) { var uuid = $(this).data("row-id"); var urlMap = {}; @@ -254,8 +366,10 @@ POSSIBILITY OF SUCH DAMAGE. }); // copy item, save as new - grid_certificates.find(".command-copy").on("click", function(e) + grid_certificates.find(".command-copy").off('click').on("click", function(e) { + e.stopPropagation(); + e.preventDefault(); if (editDlg != undefined && gridParams['get'] != undefined) { var uuid = $(this).data("row-id"); var urlMap = {}; @@ -288,8 +402,10 @@ POSSIBILITY OF SUCH DAMAGE. }); // delete item - grid_certificates.find(".command-delete").on("click", function(e) + grid_certificates.find(".command-delete").off('click').on("click", function(e) { + e.stopPropagation(); + e.preventDefault(); if (gridParams['del'] != undefined) { var uuid=$(this).data("row-id"); stdDialogConfirm('{{ lang._('Confirm removal') }}', @@ -306,119 +422,6 @@ POSSIBILITY OF SUCH DAMAGE. } }); - // toggle item - grid_certificates.find(".command-toggle").on("click", function(e) - { - if (gridParams['toggle'] != undefined) { - var uuid=$(this).data("row-id"); - $(this).addClass("fa-spinner fa-pulse"); - ajaxCall(url=gridParams['toggle'] + uuid, - sendData={},callback=function(data,status){ - // reload grid after toggle - std_bootgrid_reload(gridId); - }); - } else { - console.log("[grid] action toggle missing") - } - }); - - // sign cert - grid_certificates.find(".command-sign").on("click", function(e) - { - if (gridParams['sign'] != undefined) { - var uuid=$(this).data("row-id"); - stdDialogConfirm('{{ lang._('Confirmation Required') }}', - '{{ lang._('Forcefully issue or renew the selected certificate?') }}', - '{{ lang._('Yes') }}', '{{ lang._('Cancel') }}', function() { - // Handle HAProxy integration (no-op if not applicable) - ajaxCall(url="/api/acmeclient/settings/fetch_ha_proxy_integration", sendData={}, callback=function(data,status) { - ajaxCall(url=gridParams['sign'] + uuid,sendData={},callback=function(data,status){ - // reload grid after sign - $("#"+gridId).bootgrid("reload"); - }); - }); - }); - } else { - console.log("[grid] action sign missing") - } - }); - - // revoke cert - grid_certificates.find(".command-revoke").on("click", function(e) - { - if (gridParams['revoke'] != undefined) { - var uuid=$(this).data("row-id"); - stdDialogConfirm('{{ lang._('Confirmation Required') }}', - '{{ lang._('Revoke selected certificate?') }}', - '{{ lang._('Yes') }}', '{{ lang._('Cancel') }}', function() { - ajaxCall(url=gridParams['revoke'] + uuid, - sendData={},callback=function(data,status){ - // reload grid after sign - $("#"+gridId).bootgrid("reload"); - }); - }, 'danger'); - } else { - console.log("[grid] action revoke missing") - } - }); - - // remove private key - grid_certificates.find(".command-removekey").on("click", function(e) - { - if (gridParams['removekey'] != undefined) { - var uuid=$(this).data("row-id"); - stdDialogConfirm('{{ lang._('Confirmation Required') }}', - '{{ lang._('Really remove the private key?%s%sThe certificate will be completely reset. This is useful when the private key has been compromised or when you have changed the key options and want to regenerate the private key.%sNote that you have to revalidate the certificate afterwards in order to create a new private key and a matching certificate.') | format('
', '
', '
') }}', - '{{ lang._('Yes') }}', '{{ lang._('Cancel') }}', function() { - ajaxCall(url=gridParams['removekey'] + uuid, - sendData={},callback=function(data,status){ - // reload grid after sign - $("#"+gridId).bootgrid("reload"); - }); - }, 'danger'); - } else { - console.log("[grid] action removekey missing") - } - }); - - // run automation - grid_certificates.find(".command-automation").on("click", function(e) - { - if (gridParams['automation'] != undefined) { - var uuid=$(this).data("row-id"); - stdDialogConfirm('{{ lang._('Confirmation Required') }}', - '{{ lang._('Rerun all automations for the selected certificate?') }}', - '{{ lang._('Yes') }}', '{{ lang._('Cancel') }}', function() { - ajaxCall(url=gridParams['automation'] + uuid, - sendData={},callback=function(data,status){ - // reload grid after sign - $("#"+gridId).bootgrid("reload"); - }); - }); - } else { - console.log("[grid] action automation missing") - } - }); - - // import certificate into trust storage - grid_certificates.find(".command-import").on("click", function(e) - { - if (gridParams['import'] != undefined) { - var uuid=$(this).data("row-id"); - stdDialogConfirm('{{ lang._('Confirmation Required') }}', - '{{ lang._('(Re-) import the selected certificate and associated CA certificates into the trust storage?') }}', - '{{ lang._('Yes') }}', '{{ lang._('Cancel') }}', function() { - ajaxCall(url=gridParams['import'] + uuid, - sendData={},callback=function(data,status){ - // reload grid after sign - $("#"+gridId).bootgrid("reload"); - }); - }); - } else { - console.log("[grid] action import missing") - } - }); - }); // Hide options that are irrelevant in this context.