diff --git a/CHANGELOG.md b/CHANGELOG.md index eb3036107..5f22e6dd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to * [activity] check ACL on activity list in person context * [list for accompanying course in person] filter list using ACL * [validation] toasts are displayed for errors when modifying accompanying course (generalization required). +* [period] only the user can enable confidentiality * add an endpoint for checking permissions. See https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/232 * [activity] for a new activity: suggest and create on-the-fly locations based on the accompanying course location + location of the suggested parties * [calendar] for a new rdv: suggest and create on-the-fly locations based on the accompanying course location + location of the suggested parties diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.js b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.js index 5b49ba546..96a95ad93 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.js +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.js @@ -10,7 +10,6 @@ body: (body !== null) ? JSON.stringify(body) : null }) .then(response => { - if (response.ok) { return response.json(); } diff --git a/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseApiController.php b/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseApiController.php index 7c19864bd..35fb8d64b 100644 --- a/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseApiController.php +++ b/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseApiController.php @@ -249,6 +249,22 @@ final class AccompanyingCourseApiController extends ApiController ); } + /** + * @Route("/api/1.0/person/accompanying-course/{id}/confidential.json", name="chill_api_person_accompanying_period_confidential") + * @ParamConverter("accompanyingCourse", options={"id": "id"}) + */ + public function toggleConfidentialApi(AccompanyingPeriod $accompanyingCourse, Request $request) + { + if ($request->getMethod() === 'POST') { + $this->denyAccessUnlessGranted(AccompanyingPeriodVoter::CONFIDENTIAL, $accompanyingCourse); + + $accompanyingCourse->setConfidential(!$accompanyingCourse->isConfidential()); + $this->getDoctrine()->getManager()->flush(); + } + + return $this->json($accompanyingCourse->isConfidential(), Response::HTTP_OK, [], ['groups' => ['read']]); + } + public function workApi($id, Request $request, string $_format): Response { return $this->addRemoveSomething( diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php index 84d1c1a71..0ad6c1fd6 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php @@ -378,7 +378,6 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac Request::METHOD_DELETE => 'ALWAYS_FAILS', ], ], - 'confirm' => [ 'methods' => [ Request::METHOD_POST => true, @@ -389,6 +388,16 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE, ], ], + 'confidential' => [ + 'methods' => [ + Request::METHOD_POST => true, + Request::METHOD_GET => true, + ], + 'controller_action' => 'toggleConfidentialApi', + 'roles' => [ + Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::TOGGLE_CONFIDENTIAL, + ], + ], 'findAccompanyingPeriodsByPerson' => [ 'path' => '/by-person/{person_id}.{_format}', 'controller_action' => 'getAccompanyingPeriodsByPerson', diff --git a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php index 6b0c5d768..f3eb6538a 100644 --- a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php +++ b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php @@ -49,6 +49,10 @@ use UnexpectedValueException; * "accompanying_period": AccompanyingPeriod::class * }) * @Assert\GroupSequenceProvider + * @Assert\Expression( + * "this.isConfidential and this.getUser === NULL", + * message="If the accompanying course is confirmed and confidential, a referrer must remain assigned." + * ) */ class AccompanyingPeriod implements TrackCreationInterface, diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/api.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/api.js index 4eb6f2a33..35349164f 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/api.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/api.js @@ -48,6 +48,7 @@ const whoami = () => { }); }; + export { whoami, getSocialIssues, diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/ToggleFlags.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/ToggleFlags.vue index 459e5e758..edb363cc2 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/ToggleFlags.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/ToggleFlags.vue @@ -21,6 +21,7 @@ diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/store/index.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/store/index.js index e91de0be0..a1858c6b3 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/store/index.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/store/index.js @@ -37,6 +37,7 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise]) referrersSuggested: [], // all the users available users: [], + permissions: {} }, getters: { isParticipationValid(state) { @@ -70,7 +71,14 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise]) return true; } return false; - } + }, + canTogglePermission(state) { + if (state.permissions.roles) { + return state.permissions.roles['CHILL_PERSON_ACCOMPANYING_PERIOD_TOGGLE_CONFIDENTIAL']; + } + + return false; + }, }, mutations: { catchError(state, error) { @@ -201,6 +209,10 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise]) return u; }); }, + setPermissions(state, permissions) { + state.permissions = permissions; + // console.log('permissions', state.permissions); + }, updateLocation(state, r) { //console.log('### mutation: set location attributes', r); state.accompanyingCourse.locationStatus = r.locationStatus; @@ -625,6 +637,33 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise]) let users = await getUsers(); commit('setUsers', users); }, + /** + * By adding more roles to body['roles'], more permissions can be checked. + */ + fetchPermissions({commit}) { + const url = '/api/1.0/main/permissions/info.json'; + const body = { + "object": { + "type": "accompanying_period", + "id": id + }, + "class": "Chill\\PersonBundle\\Entity\\AccompanyingPeriod", + "roles": [ + "CHILL_PERSON_ACCOMPANYING_PERIOD_TOGGLE_CONFIDENTIAL" + ] + } + + return makeFetch('POST', url, body) + .then((response) => { + commit('setPermissions', response); + + return Promise.resolve(); + }) + .catch((error) => { + commit('catchError', error); + throw error; + }) + }, updateLocation({ commit, dispatch }, payload) { //console.log('## action: updateLocation', payload.locationStatusTo); const url = `/api/1.0/person/accompanying-course/${payload.targetId}.json`; @@ -642,12 +681,12 @@ let initPromise = Promise.all([scopesPromise, accompanyingCoursePromise]) Object.assign(body, location); makeFetch('PATCH', url, body) .then((response) => { - commit('updateLocation', { - location: response.location, - locationStatus: response.locationStatus, - personLocation: response.personLocation - }); - dispatch('fetchReferrersSuggested'); + commit('updateLocation', { + location: response.location, + locationStatus: response.locationStatus, + personLocation: response.personLocation + }); + dispatch('fetchReferrersSuggested'); }) .catch((error) => { commit('catchError', error); diff --git a/src/Bundle/ChillPersonBundle/Security/Authorization/AccompanyingPeriodVoter.php b/src/Bundle/ChillPersonBundle/Security/Authorization/AccompanyingPeriodVoter.php index ae226e848..ca212108a 100644 --- a/src/Bundle/ChillPersonBundle/Security/Authorization/AccompanyingPeriodVoter.php +++ b/src/Bundle/ChillPersonBundle/Security/Authorization/AccompanyingPeriodVoter.php @@ -31,6 +31,7 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH self::EDIT, self::DELETE, self::FULL, + self::TOGGLE_CONFIDENTIAL_ALL, ]; public const CREATE = 'CHILL_PERSON_ACCOMPANYING_PERIOD_CREATE'; @@ -53,6 +54,13 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH */ public const SEE_DETAILS = 'CHILL_PERSON_ACCOMPANYING_PERIOD_SEE_DETAILS'; + public const TOGGLE_CONFIDENTIAL = 'CHILL_PERSON_ACCOMPANYING_PERIOD_TOGGLE_CONFIDENTIAL'; + + /** + * Right to toggle confidentiality. + */ + public const TOGGLE_CONFIDENTIAL_ALL = 'CHILL_PERSON_ACCOMPANYING_PERIOD_TOGGLE_CONFIDENTIAL_ALL'; + private Security $security; private VoterHelperInterface $voterHelper; @@ -65,7 +73,7 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH $this->voterHelper = $voterHelperFactory ->generate(self::class) ->addCheckFor(null, [self::CREATE]) - ->addCheckFor(AccompanyingPeriod::class, self::ALL) + ->addCheckFor(AccompanyingPeriod::class, [self::TOGGLE_CONFIDENTIAL, ...self::ALL]) ->addCheckFor(Person::class, [self::SEE]) ->build(); } @@ -113,6 +121,14 @@ class AccompanyingPeriodVoter extends AbstractChillVoter implements ProvideRoleH return false; } + if (self::TOGGLE_CONFIDENTIAL === $attribute) { + if ($subject->getUser() === $token->getUser()) { + return true; + } + + return $this->voterHelper->voteOnAttribute(self::TOGGLE_CONFIDENTIAL_ALL, $subject, $token); + } + // if confidential, only the referent can see it if ($subject->isConfidential()) { return $token->getUser() === $subject->getUser(); diff --git a/src/Bundle/ChillPersonBundle/Tests/AccompanyingPeriod/AccompanyingPeriodConfidentialTest.php b/src/Bundle/ChillPersonBundle/Tests/AccompanyingPeriod/AccompanyingPeriodConfidentialTest.php new file mode 100644 index 000000000..f6cd76b79 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/AccompanyingPeriod/AccompanyingPeriodConfidentialTest.php @@ -0,0 +1,133 @@ +client = static::createClient([], [ + 'PHP_AUTH_USER' => 'fred', + 'PHP_AUTH_PW' => 'password', + ]); + } + + public function dataGenerateRandomAccompanyingCourse() + { + $maxGenerated = 3; + $maxResults = $maxGenerated * 8; + + static::bootKernel(); + $em = static::$kernel->getContainer()->get('doctrine.orm.entity_manager'); + $center = $em->getRepository(Center::class) + ->findOneBy(['name' => 'Center A']); + + $qb = $em->createQueryBuilder(); + $personIds = $qb + ->select('p.id') + ->distinct(true) + ->from(Person::class, 'p') + ->join('p.accompanyingPeriodParticipations', 'participation') + ->join('participation.accompanyingPeriod', 'ap') + ->andWhere( + $qb->expr()->eq('ap.step', ':step') + ) + ->andWhere( + $qb->expr()->eq('ap.confidential', ':confidential') + ) + ->setParameter('step', AccompanyingPeriod::STEP_CONFIRMED) + ->setParameter('confidential', true) + ->setMaxResults($maxResults) + ->getQuery() + ->getScalarResult(); + + // create a random order + shuffle($personIds); + + $nbGenerated = 0; + + while ($nbGenerated < $maxGenerated) { + $id = array_pop($personIds)['id']; + + $person = $em->getRepository(Person::class) + ->find($id); + $periods = $person->getAccompanyingPeriods(); + + yield [array_pop($personIds)['id'], $periods[array_rand($periods)]->getId()]; + + ++$nbGenerated; + } + } + + /** + * @dataProvider dataGenerateRandomAccompanyingCourse + */ + public function testRemoveUserWhenConfidential(int $periodId) + { + $period = self::$container->get(AccompanyingPeriodRepository::class) + ->find($periodId); + $em = static::$kernel->getContainer()->get('doctrine.orm.entity_manager'); + + $isConfidential = $period->isConfidential(); + $step = $period->getStep(); + + $initialUser = $period->getUser(); + + $user = new stdClass(); + $user->id = 0; + $user->type = 'user'; + dump($user); + + $this->client->request( + Request::METHOD_PATCH, + sprintf('/api/1.0/person/accompanying-course/%d.json', $periodId), + [], // parameters + [], // files + [], // server parameters + json_encode(['type' => 'accompanying_period', 'user' => $user]) + ); + $response = $this->client->getResponse(); + + // if ($isConfidential === true && $step === 'CONFIRMED') { + $this->assertEquals(422, $response->getStatusCode()); + // } + + $this->assertEquals(200, $response->getStatusCode()); + $period = $em->getRepository(AccompanyingPeriod::class) + ->find($periodId); + $this->assertEquals($user, $period->getUser()); + + // assign initial user again + $period->setUser($initialUser); + $em->flush(); + } +} diff --git a/src/Bundle/ChillPersonBundle/chill.api.specs.yaml b/src/Bundle/ChillPersonBundle/chill.api.specs.yaml index 5e1f0d937..c67884a19 100644 --- a/src/Bundle/ChillPersonBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillPersonBundle/chill.api.specs.yaml @@ -1113,6 +1113,44 @@ paths: description: "OK" 400: description: "transition cannot be applyed" + + /1.0/person/accompanying-course/{id}/confidential.json: + post: + tags: + - person + summary: "Toggle confidentiality of accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "Confidentiality toggle" + required: true + content: + application/json: + schema: + type: object + properties: + type: + type: string + enum: + - "accompanying_period" + confidential: + type: boolean + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" /1.0/person/accompanying-course/by-person/{person_id}.json: get: