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;