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..41b247a7707 100644
--- a/RockWeb/Blocks/Connection/ConnectionRequestBoard.ascx.cs
+++ b/RockWeb/Blocks/Connection/ConnectionRequestBoard.ascx.cs
@@ -2866,13 +2866,102 @@ 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
+ /// 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 +5833,8 @@ private void RefreshRequestCard()
statusIds: {8},
connectionStates: {9},
campusId: {10},
- pastDueOnly: {11}
+ pastDueOnly: {11},
+ userConnectableStatusIds: {12}
}});",
ToJavaScript( ConnectionRequestId ), // 0
ToJavaScript( whitespaceRemovedTemplate ), // 1
@@ -5757,7 +5847,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 +5884,8 @@ private void BindBoard()
lastActivityTypeIds: {11},
controlClientId: {12},
pastDueOnly: {13},
- connectionRequestId: {14}
+ connectionRequestId: {14},
+ userConnectableStatusIds: {15}
}});",
ToJavaScript( ConnectionOpportunityId ), // 0
ToJavaScript( GetMaxCardsPerColumn() ), // 1
@@ -5809,7 +5901,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;