diff --git a/back/app/models/permission.rb b/back/app/models/permission.rb index 2a1d6082d4c1..bc536e01f5c0 100644 --- a/back/app/models/permission.rb +++ b/back/app/models/permission.rb @@ -30,7 +30,7 @@ class Permission < ApplicationRecord 'survey' => %w[taking_survey attending_event], 'poll' => %w[taking_poll attending_event], 'voting' => %w[voting commenting_idea attending_event], - 'volunteering' => %w[attending_event], + 'volunteering' => %w[volunteering attending_event], 'document_annotation' => %w[annotating_document attending_event] } SCOPE_TYPES = [nil, 'Phase'].freeze diff --git a/back/app/services/permissions/base_permissions_service.rb b/back/app/services/permissions/base_permissions_service.rb index 1311c422108b..a8ce59b09b3f 100644 --- a/back/app/services/permissions/base_permissions_service.rb +++ b/back/app/services/permissions/base_permissions_service.rb @@ -12,6 +12,7 @@ class BasePermissionsService taking_survey taking_poll attending_event + volunteering ].freeze USER_DENIED_REASONS = { diff --git a/back/app/services/permissions/phase_permissions_service.rb b/back/app/services/permissions/phase_permissions_service.rb index a6de9727c738..f0422e3cdd3a 100644 --- a/back/app/services/permissions/phase_permissions_service.rb +++ b/back/app/services/permissions/phase_permissions_service.rb @@ -40,6 +40,10 @@ class PhasePermissionsService < BasePermissionsService already_responded: 'already_responded' }.freeze + VOLUNTEERING_DENIED_REASONS = { + not_volunteering: 'not_volunteering' + }.freeze + def initialize(phase, user, user_requirements_service: nil) super(user, user_requirements_service: user_requirements_service) @phase ||= phase @@ -63,6 +67,8 @@ def denied_reason_for_action(action, reaction_mode: nil) taking_survey_denied_reason_for_action when 'taking_poll' taking_poll_denied_reason_for_action + when 'volunteering' + volunteering_denied_reason_for_phase else raise "Unsupported action: #{action}" unless SUPPORTED_ACTIONS.include?(action) end @@ -136,6 +142,12 @@ def voting_denied_reason_for_action end end + def volunteering_denied_reason_for_phase + unless phase.volunteering? + VOLUNTEERING_DENIED_REASONS[:not_volunteering] + end + end + # Helper methods def posting_limit_reached? diff --git a/back/app/services/permissions/project_permissions_service.rb b/back/app/services/permissions/project_permissions_service.rb index 4dfc416e787a..f993890acce4 100644 --- a/back/app/services/permissions/project_permissions_service.rb +++ b/back/app/services/permissions/project_permissions_service.rb @@ -35,6 +35,8 @@ def action_descriptors taking_poll_disabled_reason = denied_reason_for_action 'taking_poll' voting_disabled_reason = denied_reason_for_action 'voting' attending_event_disabled_reason = denied_reason_for_action 'attending_event' + volunteering_disabled_reason = denied_reason_for_action 'volunteering' + { posting_idea: { enabled: !posting_disabled_reason, @@ -81,6 +83,10 @@ def action_descriptors attending_event: { enabled: !attending_event_disabled_reason, disabled_reason: attending_event_disabled_reason + }, + volunteering: { + enabled: !volunteering_disabled_reason, + disabled_reason: volunteering_disabled_reason } } end diff --git a/back/engines/commercial/report_builder/spec/services/report_builder/queries/projects_spec.rb b/back/engines/commercial/report_builder/spec/services/report_builder/queries/projects_spec.rb index 0a9af3ab7dc4..d50b6d8df144 100644 --- a/back/engines/commercial/report_builder/spec/services/report_builder/queries/projects_spec.rb +++ b/back/engines/commercial/report_builder/spec/services/report_builder/queries/projects_spec.rb @@ -9,7 +9,13 @@ before_all do # 2020 past_project = create(:project) - create(:phase, project: past_project, start_at: Date.new(2020, 2, 1), end_at: Date.new(2020, 3, 1)) + create( + :phase, + project: past_project, + start_at: Date.new(2020, 2, 1), + end_at: Date.new(2020, 3, 1), + with_permissions: true + ) # 2021 @project1 = create(:project) diff --git a/back/engines/free/volunteering/app/policies/volunteering/volunteer_policy.rb b/back/engines/free/volunteering/app/policies/volunteering/volunteer_policy.rb index e904ec20350f..16a86d13782e 100644 --- a/back/engines/free/volunteering/app/policies/volunteering/volunteer_policy.rb +++ b/back/engines/free/volunteering/app/policies/volunteering/volunteer_policy.rb @@ -25,9 +25,15 @@ def index_xlsx? end def create? - user&.active? && - (record.user_id == user.id) && - ProjectPolicy.new(user, record.cause.phase.project).show? + return false unless user&.active? + return false unless record.user_id == user.id + + project = record.cause.phase.project + service = Permissions::ProjectPermissionsService.new(project, user) + reason = service.denied_reason_for_action('volunteering') + return false if reason + + ProjectPolicy.new(user, record.cause.phase.project).show? end def destroy? diff --git a/back/engines/free/volunteering/spec/acceptance/volunteers_spec.rb b/back/engines/free/volunteering/spec/acceptance/volunteers_spec.rb index f94f3060a929..805c5e110a9a 100644 --- a/back/engines/free/volunteering/spec/acceptance/volunteers_spec.rb +++ b/back/engines/free/volunteering/spec/acceptance/volunteers_spec.rb @@ -16,7 +16,17 @@ post 'web_api/v1/causes/:cause_id/volunteers' do ValidationErrorHelper.new.error_fields(self, Volunteering::Volunteer) - let(:cause) { create(:cause) } + let(:cause) do + create( + :cause, + phase: create( + :volunteering_phase, + start_at: 6.months.ago, + end_at: nil + ) + ) + end + let(:cause_id) { cause.id } example_request 'Create a volunteer with the current user' do @@ -31,10 +41,52 @@ do_request assert_status 422 end + + context 'when the phase has granular permissions' do + let(:group) { create(:group) } + + let(:project) do + create( + :single_phase_volunteering_project, + phase_attrs: { with_permissions: true } + ) + end + + let(:cause) do + cause = create(:cause, phase: project.phases.first) + permission = cause.phase.permissions.find_by(action: 'volunteering') + permission.update!(permitted_by: 'groups', groups: [group]) + + cause + end + + let(:cause_id) { cause.id } + + example 'Try to volunteer for a cause, not as a group member', document: false do + do_request + assert_status 401 + end + + example 'Try to volunteer for a cause, as a group member', document: false do + group.add_member(@user).save! + do_request + assert_status 201 + end + end end delete 'web_api/v1/causes/:cause_id/volunteers' do - let(:cause) { create(:cause) } + let(:cause) do + create( + :cause, + phase: create( + :volunteering_phase, + start_at: 6.months.ago, + end_at: nil + ) + ) + end + let(:cause_id) { cause.id } let!(:volunteer) { create(:volunteer, user: @user, cause: cause) } diff --git a/back/spec/acceptance/projects_spec.rb b/back/spec/acceptance/projects_spec.rb index 3ce414b66637..1a52e7bea10a 100644 --- a/back/spec/acceptance/projects_spec.rb +++ b/back/spec/acceptance/projects_spec.rb @@ -174,7 +174,8 @@ taking_survey: { enabled: false, disabled_reason: 'project_inactive' }, taking_poll: { enabled: false, disabled_reason: 'project_inactive' }, voting: { enabled: false, disabled_reason: 'project_inactive' }, - attending_event: { enabled: false, disabled_reason: 'project_inactive' } + attending_event: { enabled: false, disabled_reason: 'project_inactive' }, + volunteering: { enabled: false, disabled_reason: 'project_inactive' } } ) expect(json_response.dig(:data, :relationships)).to include( diff --git a/front/app/api/permissions/types.ts b/front/app/api/permissions/types.ts index 2bc2f9aab675..564e8f60d03f 100644 --- a/front/app/api/permissions/types.ts +++ b/front/app/api/permissions/types.ts @@ -40,7 +40,8 @@ export type IPhasePermissionAction = | 'taking_poll' | 'voting' | 'annotating_document' - | 'attending_event'; + | 'attending_event' + | 'volunteering'; export interface IPhasePermissionData { id: string; diff --git a/front/app/api/projects/__mocks__/_mockServer.ts b/front/app/api/projects/__mocks__/_mockServer.ts index d893321e30cb..b1fcd3f3d068 100644 --- a/front/app/api/projects/__mocks__/_mockServer.ts +++ b/front/app/api/projects/__mocks__/_mockServer.ts @@ -80,6 +80,10 @@ export const project1: IProjectData = { enabled: true, disabled_reason: null, }, + volunteering: { + enabled: false, + disabled_reason: 'not_volunteering', + }, }, avatars_count: 8, participants_count: 8, @@ -211,6 +215,10 @@ export const project2: IProjectData = { enabled: true, disabled_reason: null, }, + volunteering: { + enabled: false, + disabled_reason: 'not_volunteering', + }, }, avatars_count: 6, participants_count: 6, @@ -345,6 +353,10 @@ const votingProject: IProject = { enabled: false, disabled_reason: 'not_poll', }, + volunteering: { + enabled: false, + disabled_reason: 'not_volunteering', + }, }, avatars_count: 2, participants_count: 2, diff --git a/front/app/api/projects/types.ts b/front/app/api/projects/types.ts index 0fd20b20496f..ed1f95913855 100644 --- a/front/app/api/projects/types.ts +++ b/front/app/api/projects/types.ts @@ -11,6 +11,7 @@ import { ProjectReactingDisabledReason, ProjectSurveyDisabledReason, ProjectVotingDisabledReason, + ProjectVolunteeringDisabledReason, } from 'utils/actionDescriptors/types'; import { Keys } from 'utils/cl-react-query/types'; @@ -99,6 +100,7 @@ export interface IProjectAttributes { annotating_document: ActionDescriptor; voting: ActionDescriptor; attending_event: ActionDescriptor; + volunteering: ActionDescriptor; }; uses_content_builder: boolean; } diff --git a/front/app/components/FormBuilder/utils.tsx b/front/app/components/FormBuilder/utils.tsx index 6ff8c7125313..32c41d6da369 100644 --- a/front/app/components/FormBuilder/utils.tsx +++ b/front/app/components/FormBuilder/utils.tsx @@ -80,8 +80,6 @@ export const getIsPostingEnabled = ( return false; }; - - export function generateTempId() { return `TEMP-ID-${uuid4()}`; } diff --git a/front/app/containers/Admin/pagesAndMenu/containers/PagesMenu/index.tsx b/front/app/containers/Admin/pagesAndMenu/containers/PagesMenu/index.tsx index 99b4caa07c8b..86a18f4a10bb 100644 --- a/front/app/containers/Admin/pagesAndMenu/containers/PagesMenu/index.tsx +++ b/front/app/containers/Admin/pagesAndMenu/containers/PagesMenu/index.tsx @@ -25,7 +25,8 @@ const PagesMenu = () => { return null; } - const disabledAddProjectToNavbarButton = navbarItems.data.length >= MAX_NAVBAR_ITEMS; + const disabledAddProjectToNavbarButton = + navbarItems.data.length >= MAX_NAVBAR_ITEMS; return ( Attending event: {participants}', }, + volunteering: { + id: 'app.components.app.containers.AdminPage.ProjectEdit.phaseHeader.volunteering', + defaultMessage: 'Volunteering: {participants}', + }, and: { id: 'app.components.app.containers.AdminPage.ProjectEdit.phaseHeader.and', defaultMessage: 'and', diff --git a/front/app/containers/Admin/projects/project/phase/utils.ts b/front/app/containers/Admin/projects/project/phase/utils.ts index ece7387c1d7f..e82ec0c2a84c 100644 --- a/front/app/containers/Admin/projects/project/phase/utils.ts +++ b/front/app/containers/Admin/projects/project/phase/utils.ts @@ -77,6 +77,8 @@ export const getParticipationActionLabel = (action: IPhasePermissionAction) => { return messages.annotatingDocument; case 'attending_event': return messages.attendingEvent; + case 'volunteering': + return messages.volunteering; } }; diff --git a/front/app/containers/ProjectsShowPage/shared/volunteering/CauseCard.tsx b/front/app/containers/ProjectsShowPage/shared/volunteering/CauseCard.tsx index fb3a37f3d21e..c91845001761 100644 --- a/front/app/containers/ProjectsShowPage/shared/volunteering/CauseCard.tsx +++ b/front/app/containers/ProjectsShowPage/shared/volunteering/CauseCard.tsx @@ -13,11 +13,11 @@ import { } from '@citizenlab/cl2-component-library'; import styled, { useTheme } from 'styled-components'; -import { GLOBAL_CONTEXT } from 'api/authentication/authentication_requirements/constants'; -import getAuthenticationRequirements from 'api/authentication/authentication_requirements/getAuthenticationRequirements'; +import { AuthenticationContext } from 'api/authentication/authentication_requirements/types'; import { ICauseData } from 'api/causes/types'; import useAddVolunteer from 'api/causes/useAddVolunteer'; import useDeleteVolunteer from 'api/causes/useDeleteVolunteer'; +import { IProject } from 'api/projects/types'; import { triggerAuthenticationFlow } from 'containers/Authentication/events'; @@ -27,6 +27,10 @@ import Image from 'components/UI/Image'; import QuillEditedContent from 'components/UI/QuillEditedContent'; import { ScreenReaderOnly } from 'utils/a11y'; +import { + isFixableByAuthentication, + getPermissionsDisabledMessage, +} from 'utils/actionDescriptors'; import { FormattedMessage, useIntl } from 'utils/cl-intl'; import { isEmptyMultiloc } from 'utils/helperUtils'; @@ -165,10 +169,10 @@ const ActionWrapper = styled.div` interface Props { cause: ICauseData; className?: string; - disabled?: boolean; + project: IProject; } -const CauseCard = ({ cause, className, disabled }: Props) => { +const CauseCard = ({ cause, className, project }: Props) => { const { mutate: addVolunteer } = useAddVolunteer(); const { mutate: deleteVolunteer } = useDeleteVolunteer(); const theme = useTheme(); @@ -191,14 +195,29 @@ const CauseCard = ({ cause, className, disabled }: Props) => { params: { cause }, } as const; + const { disabled_reason } = + project.data.attributes.action_descriptors.volunteering; + + const blocked = !!disabled_reason; + const blockedAndUnfixable = + blocked && !isFixableByAuthentication(disabled_reason); + const handleOnVolunteerButtonClick = async () => { - const response = await getAuthenticationRequirements(GLOBAL_CONTEXT); - const { requirements } = response.data.attributes; + const phaseId = cause.relationships.phase.data.id; - if (requirements.permitted) { + const context: AuthenticationContext = { + type: 'phase', + action: 'volunteering', + id: phaseId, + }; + + if (!blocked) { volunteer(); - } else { - triggerAuthenticationFlow({ successAction }); + } else if (!blockedAndUnfixable) { + triggerAuthenticationFlow({ + successAction, + context, + }); } }; @@ -259,9 +278,12 @@ const CauseCard = ({ cause, className, disabled }: Props) => {