From ab1f32cd7ba65401606c1299a266597daf00f99b Mon Sep 17 00:00:00 2001 From: Joshua Wa Date: Thu, 30 Jul 2026 08:53:12 -0400 Subject: [PATCH 1/2] ROCK-8640 Hide the ungated Connect action on Connection Request Board cards The kanban card's action menu ("...") renders its Connect item from core ConnectionRequestService.CanConnect() - placement group assigned, state not Connected/Inactive, ShowConnectButton - which knows nothing about the S&S gate. So the item stayed visible on secured opportunities at any status. The prior commit refuses the postback server-side; this hides the affordance so the menu matches the gated modal button. Adds a status-based overload to SeccConnectGateHelper (the request-based overload now delegates to it) so the board can ask "could this person connect a request at this status?" per status column. GetUserConnectableStatusIds() runs that shared gate against every status on the selected opportunity, also applying the edit-rights check the modal button requires, and the resulting ids ride both board script payloads. The JavaScript checks membership only - no gate logic lives client-side. The helper overload should be carried to hotfix-1.16.12 with the port of this change so the two branches keep a byte-identical helper. Co-Authored-By: Claude Fable 5 (cherry picked from commit fb451dda1cbf2d6a5f5b4318dfefe7aee713ae7b) --- RockWeb/App_Code/SeccConnectGateHelper.cs | 22 +++++- .../Connection/ConnectionRequestBoard.ascx.cs | 67 +++++++++++++++++-- .../connectionRequestBoard.js | 34 +++++++++- 3 files changed, 115 insertions(+), 8 deletions(-) diff --git a/RockWeb/App_Code/SeccConnectGateHelper.cs b/RockWeb/App_Code/SeccConnectGateHelper.cs index 2814380cf0e..ba45bf62f85 100644 --- a/RockWeb/App_Code/SeccConnectGateHelper.cs +++ b/RockWeb/App_Code/SeccConnectGateHelper.cs @@ -94,6 +94,22 @@ public static bool IsPersonAuthorizedToConnect( Person currentPerson, Guid? safe /// A null request is allowed (board add mode) so modal rendering isn't blocked. /// public static bool CanConnect( ConnectionRequest connectionRequest, ConnectionOpportunity opportunity, Person currentPerson, Guid? safetySecurityRoleGuid ) + { + if ( connectionRequest == null ) + { + return CanConnect( ( int? ) null, null, opportunity, currentPerson, safetySecurityRoleGuid ); + } + + return CanConnect( connectionRequest.ConnectionStatusId, connectionRequest.ConnectionState, opportunity, currentPerson, safetySecurityRoleGuid ); + } + + /// + /// Same gate, evaluated for an arbitrary status instead of a request. Lets callers ask + /// "could the person connect a request at this status?" (e.g. the board card action menu, + /// which must decide per status column). A null status means there is no request in context, + /// which is allowed (board add mode) so modal rendering isn't blocked. + /// + public static bool CanConnect( int? connectionStatusId, ConnectionState? connectionState, ConnectionOpportunity opportunity, Person currentPerson, Guid? safetySecurityRoleGuid ) { if ( opportunity == null ) { @@ -123,13 +139,13 @@ public static bool CanConnect( ConnectionRequest connectionRequest, ConnectionOp return true; } - if ( connectionRequest == null ) + if ( !connectionStatusId.HasValue ) { return true; } - return connectableStatuses.Contains( connectionRequest.ConnectionStatusId ) - || connectionRequest.ConnectionState == ConnectionState.Connected; + return connectableStatuses.Contains( connectionStatusId.Value ) + || connectionState == ConnectionState.Connected; } } } diff --git a/RockWeb/Blocks/Connection/ConnectionRequestBoard.ascx.cs b/RockWeb/Blocks/Connection/ConnectionRequestBoard.ascx.cs index 369016889c2..b6d677ded6f 100644 --- a/RockWeb/Blocks/Connection/ConnectionRequestBoard.ascx.cs +++ b/RockWeb/Blocks/Connection/ConnectionRequestBoard.ascx.cs @@ -2873,6 +2873,61 @@ private bool CanUserConnect() GetAttributeValue( AttributeKey.SafetySecurityRole ).AsGuidOrNull() ); } + /// + /// SECC (ROCK-8640): Runs the shared gate against every status on the selected opportunity, so the + /// board card action menu can hide its Connect item using exactly the same rule that hides the + /// Connect button on the request modal. Presentation only - the card menu's postback is enforced + /// separately in ProcessJavaScriptCommand. + /// + private List GetUserConnectableStatusIds() + { + var statusIds = new List(); + + /* + The modal's Connect button also requires edit rights, so apply the same check here. + + Note that no request is in context at bind time, so when the connection type has + EnableRequestSecurity turned on this evaluates opportunity-level Edit rather than the + per-request Edit the modal evaluates, and the card menu can show Connect for a request + the modal would hide. The card menu's postback is still evaluated per-request in + ProcessJavaScriptCommand, so this is a presentation difference only. Matching the modal + exactly here would require evaluating the gate per request instead of per status. + */ + if ( !CanUserEditConnectionRequest() ) + { + return statusIds; + } + + var connectionOpportunity = GetConnectionOpportunity(); + + if ( connectionOpportunity == null + || connectionOpportunity.ConnectionType == null + || connectionOpportunity.ConnectionType.ConnectionStatuses == null ) + { + return statusIds; + } + + var safetySecurityRoleGuid = GetAttributeValue( AttributeKey.SafetySecurityRole ).AsGuidOrNull(); + + // Ordered so the same board state always produces the same list. The client stores these in its + // options object and re-renders the board when that object changes. + foreach ( var connectionStatus in connectionOpportunity.ConnectionType.ConnectionStatuses.OrderBy( cs => cs.Id ) ) + { + /* + Each status is evaluated as Active. State only affects the gate when it is Connected, + and the client applies this list only to cards whose core CanConnect is already true -- + which is false for both Connected and Inactive requests -- so the state passed here + cannot change the outcome. + */ + if ( SeccConnectGateHelper.CanConnect( connectionStatus.Id, ConnectionState.Active, connectionOpportunity, CurrentPerson, safetySecurityRoleGuid ) ) + { + statusIds.Add( connectionStatus.Id ); + } + } + + return statusIds; + } + /// /// Binds the modal activities grid. /// @@ -5744,7 +5799,8 @@ private void RefreshRequestCard() statusIds: {8}, connectionStates: {9}, campusId: {10}, - pastDueOnly: {11} + pastDueOnly: {11}, + userConnectableStatusIds: {12} }});", ToJavaScript( ConnectionRequestId ), // 0 ToJavaScript( whitespaceRemovedTemplate ), // 1 @@ -5757,7 +5813,8 @@ private void RefreshRequestCard() ToJavaScript( cblStatusFilter.SelectedValuesAsInt ), // 8 ToJavaScript( cblStateFilter.SelectedValues ), // 9 ToJavaScript( CampusId ), // 10 - ToJavaScript( rcbPastDueOnly.Checked ) /* 11 */ ); + ToJavaScript( rcbPastDueOnly.Checked ), // 11 + ToJavaScript( GetUserConnectableStatusIds() ) /* 12 */ ); ScriptManager.RegisterStartupScript( upnlJavaScript, @@ -5793,7 +5850,8 @@ private void BindBoard() lastActivityTypeIds: {11}, controlClientId: {12}, pastDueOnly: {13}, - connectionRequestId: {14} + connectionRequestId: {14}, + userConnectableStatusIds: {15} }});", ToJavaScript( ConnectionOpportunityId ), // 0 ToJavaScript( GetMaxCardsPerColumn() ), // 1 @@ -5809,7 +5867,8 @@ private void BindBoard() ToJavaScript( cblLastActivityFilter.SelectedValuesAsInt ), // 11 ToJavaScript( lbJavaScriptCommand.ClientID ), // 12 ToJavaScript( rcbPastDueOnly.Checked ), //13 - ToJavaScript( ConnectionRequestId ) /* 14 */ ); + ToJavaScript( ConnectionRequestId ), // 14 + ToJavaScript( GetUserConnectableStatusIds() ) /* 15 */ ); ScriptManager.RegisterStartupScript( upnlJavaScript, diff --git a/RockWeb/Scripts/Rock/Controls/ConnectionRequestBoard/connectionRequestBoard.js b/RockWeb/Scripts/Rock/Controls/ConnectionRequestBoard/connectionRequestBoard.js index 1123c81e111..90fe17f9f25 100644 --- a/RockWeb/Scripts/Rock/Controls/ConnectionRequestBoard/connectionRequestBoard.js +++ b/RockWeb/Scripts/Rock/Controls/ConnectionRequestBoard/connectionRequestBoard.js @@ -83,8 +83,34 @@ }; let _cardTemplate = ''; + // ROCK-8640: the card action menu's Connect item is driven by the core CanConnect value, which knows + // nothing about the SECC Safety & Security gate. The server evaluates that gate (the same method that + // hides the Connect button on the request modal) for every status on the opportunity and sends the + // allowed status ids here, so this only has to check membership - no gate logic lives in JavaScript. + let _userConnectableStatusIds = null; + + const captureConnectGate = function (options) { + if (options && options.userConnectableStatusIds) { + _userConnectableStatusIds = options.userConnectableStatusIds; + } + }; + + const applyConnectGate = function (viewModel) { + if (!viewModel || viewModel.CanConnect !== true || !_userConnectableStatusIds) { + return viewModel; + } + + if (_userConnectableStatusIds.indexOf(viewModel.StatusId) !== -1) { + return viewModel; + } + + const gated = $.extend({}, viewModel); + gated.CanConnect = false; + return gated; + }; + const getCardHtml = function (requestViewModel) { - return resolveTemplateFields(getCardTemplate(), requestViewModel); + return resolveTemplateFields(getCardTemplate(), applyConnectGate(requestViewModel)); }; const getSentryTemplate = function () { @@ -249,6 +275,8 @@ return } + captureConnectGate(options); + fetchRequestViewModel(options, function (requestViewModel) { refreshCard(options.connectionRequestId, requestViewModel); }); @@ -644,6 +672,10 @@ throw 'A valid options object is required'; } + // ROCK-8640: capture before the early return below, so the gate is applied even when the board + // itself does not need re-rendering. + captureConnectGate(options); + // Check if this options object has already been initialized (no need to do it again) const newOptionsHash = JSON.stringify(options); const areColsRendered = $('.js-column-container > *').length > 0; From 70eee0ea45f05ae4dd244d7d643f82ae6fbd8075 Mon Sep 17 00:00:00 2001 From: Joshua Wa Date: Thu, 30 Jul 2026 13:18:12 -0400 Subject: [PATCH 2/2] ROCK-8640 Evaluate the connect gate against the request's own opportunity The Connection Request Board resolved the connect gate's opportunity from the board's current selection, while the connection request identifier arrives from the client postback, so the two can disagree. A user with edit rights on an opportunity that does not require Safety & Security to connect could send a connect postback carrying the identifier of a request in a gated opportunity: the gate read SecurityToConnect and ConnectableStatuses from the selected opportunity, allowed it, and MarkRequestConnected then connected the client-supplied request. Resolve the opportunity from the request instead, which is what ConnectionRequestDetail already does. The selected opportunity is still used when there is no request in context (modal add mode), since that is the opportunity the new request is created in, and it is reused directly when it already matches so the normal path does not take an extra query. This predates the card gate work - it is present both in the merged 13.7 change and in hotfix-1.16.12 - so this commit stands on its own and can be cherry-picked to v16 independently of the card menu change. Co-Authored-By: Claude Fable 5 (cherry picked from commit d20cad2570f2ea2ee1eac186a61f0c63e4de7ca0) --- .../Connection/ConnectionRequestBoard.ascx.cs | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/RockWeb/Blocks/Connection/ConnectionRequestBoard.ascx.cs b/RockWeb/Blocks/Connection/ConnectionRequestBoard.ascx.cs index b6d677ded6f..41b247a7707 100644 --- a/RockWeb/Blocks/Connection/ConnectionRequestBoard.ascx.cs +++ b/RockWeb/Blocks/Connection/ConnectionRequestBoard.ascx.cs @@ -2866,13 +2866,47 @@ private bool CanUserEditConnectionRequest() /// private bool CanUserConnect() { + var connectionRequest = GetConnectionRequest(); + return SeccConnectGateHelper.CanConnect( - GetConnectionRequest(), - GetConnectionOpportunity(), + connectionRequest, + GetGateConnectionOpportunity( connectionRequest ), CurrentPerson, GetAttributeValue( AttributeKey.SafetySecurityRole ).AsGuidOrNull() ); } + /// + /// SECC (ROCK-8640): Returns the opportunity whose connect rules apply to the given request. + /// The request identifier arrives from the client, so it can belong to an opportunity other than the + /// one currently selected on the board. The gate has to read SecurityToConnect and ConnectableStatuses + /// from the request's own opportunity - which is what ConnectionRequestDetail does - otherwise a user + /// on an opportunity that does not require security could connect a request in one that does. + /// Falls back to the selected opportunity when there is no request in context (modal add mode), which + /// is the opportunity the new request will be created in. + /// + /// The connection request, or null in modal add mode. + private ConnectionOpportunity GetGateConnectionOpportunity( ConnectionRequest connectionRequest ) + { + var selectedConnectionOpportunity = GetConnectionOpportunity(); + + if ( connectionRequest == null ) + { + return selectedConnectionOpportunity; + } + + // Reuse the already loaded instance when it is the right one, which is the normal case. + if ( selectedConnectionOpportunity != null + && selectedConnectionOpportunity.Id == connectionRequest.ConnectionOpportunityId ) + { + return selectedConnectionOpportunity; + } + + return new ConnectionOpportunityService( new RockContext() ) + .Queryable() + .AsNoTracking() + .FirstOrDefault( co => co.Id == connectionRequest.ConnectionOpportunityId ); + } + /// /// SECC (ROCK-8640): Runs the shared gate against every status on the selected opportunity, so the /// board card action menu can hide its Connect item using exactly the same rule that hides the