From db7467e69869c995e3a3cf6e5fff91474c188b80 Mon Sep 17 00:00:00 2001 From: robertfausk Date: Thu, 28 Oct 2021 10:37:31 +0200 Subject: [PATCH] An superadmin can see active users #216 * upgrade vue2-daterange-picker * add WalksTimeRangeFilter * add integration tests for walks.timeRange filter * add acceptance tests for /benutzer * restricted to superadmin by now; will be enabled for admin too if considered useful --- web/assets/js/components/Users.vue | 15 + .../js/components/Users/ActiveUserList.vue | 270 ++++++++++++++++++ web/package.json | 2 +- web/phpcs.xml.dist | 3 + web/phpstan.neon.dist | 2 + .../SystemicQuestionFormController.php | 75 ----- web/src/Controller/TeamFormController.php | 69 ----- web/src/Entity/User.php | 9 + web/src/Filter/WalksTimeRangeFilter.php | 76 +++++ .../Acceptance/features/user_list.feature | 74 +++++ .../features/users_active_with_walks.feature | 74 +++++ web/webpack.config.js | 2 +- web/yarn.lock | 2 +- 13 files changed, 526 insertions(+), 147 deletions(-) create mode 100755 web/assets/js/components/Users/ActiveUserList.vue delete mode 100755 web/src/Controller/SystemicQuestionFormController.php delete mode 100755 web/src/Controller/TeamFormController.php create mode 100644 web/src/Filter/WalksTimeRangeFilter.php create mode 100755 web/tests/Acceptance/features/user_list.feature create mode 100644 web/tests/Integration/features/users_active_with_walks.feature diff --git a/web/assets/js/components/Users.vue b/web/assets/js/components/Users.vue index d04ba84c..32285f81 100755 --- a/web/assets/js/components/Users.vue +++ b/web/assets/js/components/Users.vue @@ -7,6 +7,14 @@ > + + + "use strict"; + import ActiveUserList from './Users/ActiveUserList'; import UserCreate from './Users/UserCreate'; import UserList from './Users/UserList'; import ContentCollapse from './ContentCollapse.vue'; @@ -26,10 +35,16 @@ export default { name: "Users", components: { + ActiveUserList, ContentCollapse, UserCreate, UserList, }, + computed: { + isSuperAdmin() { + return this.$store.getters['security/isSuperAdmin']; + }, + }, async mounted() { await this.$store.dispatch('client/findAll'); }, diff --git a/web/assets/js/components/Users/ActiveUserList.vue b/web/assets/js/components/Users/ActiveUserList.vue new file mode 100755 index 00000000..6e55c9d6 --- /dev/null +++ b/web/assets/js/components/Users/ActiveUserList.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/web/package.json b/web/package.json index faf85405..98dc09ac 100755 --- a/web/package.json +++ b/web/package.json @@ -38,7 +38,7 @@ "vue-router": "^3.3.4", "vue-template-compiler": "^2.6", "vue-web-storage": "^4.0.2", - "vue2-daterange-picker": "^0.6.5", + "vue2-daterange-picker": "^0.6.7", "vuex": "^3.4.0", "webpack-notifier": "^1.8" }, diff --git a/web/phpcs.xml.dist b/web/phpcs.xml.dist index ee01b415..af433bc8 100755 --- a/web/phpcs.xml.dist +++ b/web/phpcs.xml.dist @@ -27,13 +27,16 @@ src/Security/Voter/*Voter.php src/DataTransformer/*.php + src/Filter/*Filter.php src/Notifier/*Notification.php + src/Filter/*Filter.php tests/Context/EmailTrait.php src/DataTransformer/*.php + src/Filter/*Filter.php diff --git a/web/phpstan.neon.dist b/web/phpstan.neon.dist index e818c2ef..8d270993 100755 --- a/web/phpstan.neon.dist +++ b/web/phpstan.neon.dist @@ -10,9 +10,11 @@ parameters: ignoreErrors: - '#Access to an undefined property Symfony\\Component\\Validator\\Constraint::\$message#' - '#Method App\\Serializer\\Normalizer\\Base64DataUriNormalizer::denormalize\(\) has parameter \$context with no value type specified in iterable type array.#' + - '#Method App\\Filter\\WalksTimeRangeFilter::filterProperty\(\) has parameter \$value with no typehint specified.#' # - '#Method [a-zA-Z0-9\\_]+OpenApiFactory::__invoke\(\) has parameter \$context with no value type specified in iterable type array.#' - '#Method [a-zA-Z0-9\\_]+Requirements::getConstraints\(\) has parameter \$options with no value type specified in iterable type array.#' # - '#Method [a-zA-Z0-9\\_]+Extension::applyToCollection\(\) has no return typehint specified.#' # - '#Method [a-zA-Z0-9\\_]+Extension::applyToItem\(\) has no return typehint specified.#' - '#Method [a-zA-Z0-9\\_]+Extension::applyToItem\(\) has parameter \$[a-zA-Z0-9\\_]+ with no value type specified in iterable type array.#' # - '#Method [a-zA-Z0-9\\_]+DataTransformer::[a-zA-Z0-9\\_]+\(\) has parameter \$[a-zA-Z0-9\\_]+ with no value type specified in iterable type array.#' + - '#Method [a-zA-Z0-9\\_]+WalksTimeRangeFilter::getDescription\(\) return type has no value type specified in iterable type array\.#' diff --git a/web/src/Controller/SystemicQuestionFormController.php b/web/src/Controller/SystemicQuestionFormController.php deleted file mode 100755 index 3c31abe7..00000000 --- a/web/src/Controller/SystemicQuestionFormController.php +++ /dev/null @@ -1,75 +0,0 @@ -formFactory = $formFactory; - $this->systemicQuestionRepository = $systemicQuestionRepository; - $this->router = $router; - } - - /** - * @Route("/systemic-question/form-{id}", name="systemic_question_form", requirements={"id"="\d+"}, defaults={"id"=""}) - * - * @Template(template="systemic_question/form.html.twig") - * - * @param Request $request - * @param FlashBagInterface $flash - * @param SystemicQuestion|null $systemicQuestion - * - * @return array|RedirectResponse - */ - public function __invoke(Request $request, FlashBagInterface $flash, ?SystemicQuestion $systemicQuestion = null) - { - $isCreateNew = (bool) !$systemicQuestion; - $form = $this->formFactory->create(SystemicQuestionType::class, $systemicQuestion); - $form->handleRequest($request); - - if (!$form->isSubmitted() || !$form->isValid()) { - return [ - 'form' => $form->createView(), - 'isCreateNew' => $isCreateNew, - ]; - } - - $systemicQuestion = $form->getData(); - \assert($systemicQuestion instanceof SystemicQuestion); - $this->systemicQuestionRepository->save($systemicQuestion); - - if ($isCreateNew) { - $flash->add('notice', 'Systemische Frage erfolgreich erstellt.'); - } else { - $flash->add('notice', 'Systemische Frage erfolgreich bearbeitet.'); - } - - $url = $this->router->generate('systemic_question_list'); - Assert::notNull($url); - - return new RedirectResponse($url); - } -} diff --git a/web/src/Controller/TeamFormController.php b/web/src/Controller/TeamFormController.php deleted file mode 100755 index 2a93e638..00000000 --- a/web/src/Controller/TeamFormController.php +++ /dev/null @@ -1,69 +0,0 @@ -formFactory = $formFactory; - $this->teamRepository = $teamRepository; - $this->router = $router; - } - - /** - * @Route("/team/form-{id}", name="team_form", requirements={"id"="\d+"}, defaults={"id"=""}) - * - * @Template(template="team/form.html.twig") - * - * @param Request $request - * @param FlashBagInterface $flash - * @param Team|null $team - * - * @return array|RedirectResponse - */ - public function __invoke(Request $request, FlashBagInterface $flash, ?Team $team = null) - { - $form = $this->formFactory->create(TeamType::class, $team); - $form->handleRequest($request); - - if (!$form->isSubmitted() || !$form->isValid()) { - return [ - 'form' => $form->createView(), - ]; - } - - $team = $form->getData(); - \assert($team instanceof Team); - $this->teamRepository->save($team); - - $flash->add('notice', 'Team erfolgreich erstellt/bearbeitet.'); - - $url = $this->router->generate('team_list'); - Assert::notNull($url); - - return new RedirectResponse($url); - } -} diff --git a/web/src/Entity/User.php b/web/src/Entity/User.php index 8d293f2d..d25d1616 100755 --- a/web/src/Entity/User.php +++ b/web/src/Entity/User.php @@ -3,7 +3,9 @@ namespace App\Entity; +use ApiPlatform\Core\Annotation\ApiFilter; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\DateFilter; use App\Dto\User\IsConfirmationTokenValidRequest; use App\Dto\User\PasswordChangeRequest; use App\Dto\User\RequestPasswordResetRequest; @@ -12,6 +14,7 @@ use App\Dto\User\UserDisableRequest; use App\Dto\User\UserEmailConfirmRequest; use App\Dto\User\UserEnableRequest; +use App\Filter\WalksTimeRangeFilter; use App\Security\Voter\ClientVoter; use App\Security\Voter\UserVoter; use App\Value\ConfirmationToken; @@ -133,6 +136,12 @@ itemOperations: ["get"], normalizationContext: ["groups" => ["user:read"]] )] +#[ApiFilter( + WalksTimeRangeFilter::class, + properties: [ + 'timeRange' => DateFilter::EXCLUDE_NULL, + ], +)] class User implements UserInterface { use TimestampableEntity; diff --git a/web/src/Filter/WalksTimeRangeFilter.php b/web/src/Filter/WalksTimeRangeFilter.php new file mode 100644 index 00000000..13f5a86a --- /dev/null +++ b/web/src/Filter/WalksTimeRangeFilter.php @@ -0,0 +1,76 @@ +properties) { + return []; + } + + $description = []; + // phpcs:ignore + foreach ($this->properties as $property => $strategy) { + $description["walks.$property"] = [ + 'property' => $property, + 'type' => Type::BUILTIN_TYPE_STRING, + 'required' => false, + 'description' => 'Filter using date range on startDate of walks.', + 'openapi' => [ + 'example' => '01.10.2021..31.12.2022', + ], + ]; + } + + return $description; + } + + protected function filterProperty( + string $property, + $value, + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + string $operationName = null + ): void { + $resourceClass = Walk::class; + + if (!\str_starts_with($property, 'walks.')) { + return; + } + $property = \str_replace('walks.', '', $property); + + if ('timeRange' !== $property) { + return; + } + + $values = \explode('..', $value); + if (2 !== \count($values)) { + return; + } + if (!$this->isPropertyEnabled($property, $resourceClass) || + !$this->isPropertyMapped('startTime', $resourceClass) + ) { + return; + } + $property = 'startTime'; + + $queryBuilder + ->distinct(true) + ->join('o.walks', 'w') + ->andWhere(\sprintf('w.%s > :timeFrom', $property)) + ->setParameter('timeFrom', $values[0]) + ->andWhere(\sprintf('w.%s < :timeTo', $property)) + ->setParameter('timeTo', $values[1]); + } +} diff --git a/web/tests/Acceptance/features/user_list.feature b/web/tests/Acceptance/features/user_list.feature new file mode 100755 index 00000000..8d8e0da4 --- /dev/null +++ b/web/tests/Acceptance/features/user_list.feature @@ -0,0 +1,74 @@ +Feature: An admin can see users of his client + + Background: + Given the following clients exists: + | email | + | client@gmx.de | + | gamer@gmx.de | + Given the following users exists: + | email | roles | client | + | karl@gmx.de | | client@gmx.de | + | steve@gmx.de | | client@gmx.de | + | bob@gmx.de | | client@gmx.de | + | lonely@gmx.de | | client@gmx.de | + | admin@gmx.de | ROLE_ADMIN | client@gmx.de | + | superadmin@gmx.de | ROLE_SUPER_ADMIN | client@gmx.de | + Given the following teams exists: + | name | users | ageRanges | client | + | Westhang | karl@gmx.de,steve@gmx.de,bob@gmx.de,admin@gmx.de | 1-10,3-12, 13 - 90 | client@gmx.de | + Given the following systemic questions exists: + | question | client | + | How old? | client@gmx.de | + Given the following tags exists: + | name | color | client | + | Gewalt | Chocolate | client@gmx.de | + | Drogen | Blue | client@gmx.de | + | RPG | Lime | gamer@gmx.de | + Given the following walks exists: + | name | team | startTime | + | Gassi | Westhang | now | + | Gassi2 | Westhang | last month | + | Spaziergang | Westhang | last month | + | Gamescon | Westhang | now | + + @javascript + @userList + Scenario: I can not see tag list as a non admin user + Given I am authenticated as "lonely@gmx.de" + When I am on "benutzer" + Then I should be on "/dashboard" + + @javascript + @userList + Scenario: I can not see tag list as an admin user + Given I am authenticated as "admin@gmx.de" + When I am on "benutzer" + Then I should be on "/benutzer" + + And I wait for "Liste der Benutzer" to appear + And I wait for "Benutzername" to appear + And I wait for "Erstellt am" to appear + + And I wait for "Neuen Benutzer erstellen" to appear + + And I wait for "Liste der aktiven Benutzer" to disappear + + @javascript + @userList + Scenario: I can see tag list as an super admin user + Given I am authenticated as "superadmin@gmx.de" + When I am on "benutzer" + Then I should be on "/benutzer" + + And I wait for "Liste der Benutzer" to appear + And I wait for "Benutzername" to appear + And I wait for "Klient" to appear + And I wait for "Erstellt am" to appear + And I wait for "Geändert am" to appear + + And I wait for "Neuen Benutzer erstellen" to appear + And I wait for "Zeitraum" to appear + And I wait for "Summe" to appear + And I wait for "4" to appear + + And I wait for "Liste der aktiven Benutzer" to appear diff --git a/web/tests/Integration/features/users_active_with_walks.feature b/web/tests/Integration/features/users_active_with_walks.feature new file mode 100644 index 00000000..22210a23 --- /dev/null +++ b/web/tests/Integration/features/users_active_with_walks.feature @@ -0,0 +1,74 @@ +Feature: Testing user resource with walk filter timeRange + + Background: + Given the following clients exists: + | email | + | client@gmx.de | + | gamer@gmx.de | + | main@gmx.de | + Given the following users exists: + | email | roles | client | + | karl@gmx.de | | client@gmx.de | + | lonely@gmx.de | | client@gmx.de | + | two@pac.de | | client@gmx.de | + | dr@dre.de | | client@gmx.de | + | admin@gmx.de | ROLE_ADMIN | client@gmx.de | + | karl@gamer.de | | gamer@gmx.de | + | superadmin@gmx.de | ROLE_SUPER_ADMIN | main@gmx.de | + Given the following teams exists: + | name | users | ageRanges | client | + | Westhang | karl@gmx.de,two@pac.de | 1-10,3-12, 13 - 90 | client@gmx.de | + | CA | two@pac.de,dr@dre.de | 1-10,3-12, 13 - 90 | client@gmx.de | + | Gamers | karl@gamer.de | | gamer@gmx.de | + Given the following walks exists: + | name | team | startTime | + | Gassi | CA | 4.09.2021 | + | Gassi2 | CA | 4.11.2021 | + | Spaziergang | CA | 5.10.2021 | + | Gamescon | Gamers | 5.10.2021 | + + @api @user + Scenario: I can request /api/users as an user and get all users which have done a walk in a specific time + Given I am authenticated against api as "karl@gamer.de" + When I send a GET request to "/api/users?walks.timeRange=2021-09-30T22:00:00.000Z..2021-10-31T22:59:59.999Z" + Then the response should be in JSON + And the response status code should be 200 +# And print last JSON response + And the JSON nodes should be equal to: + | hydra:totalItems | 1 | + | hydra:member[0].username | karl@gamer.de | + + Given I am authenticated against api as "dr@dre.de" + When I send a GET request to "/api/users?walks.timeRange=2021-09-30T22:00:00.000Z..2021-10-31T22:59:59.999Z" + Then the response should be in JSON + And the response status code should be 200 +# And print last JSON response + And the JSON nodes should be equal to: + | hydra:totalItems | 2 | + | hydra:member[0].username | two@pac.de | + | hydra:member[1].username | dr@dre.de | + + @api @user + Scenario: I can request /api/users as an admin user and get all users which have done a walk in a specific time + Given I am authenticated against api as "admin@gmx.de" + When I send a GET request to "/api/users?walks.timeRange=2021-09-30T22:00:00.000Z..2021-10-31T22:59:59.999Z" + Then the response should be in JSON + And the response status code should be 200 +# And print last JSON response + And the JSON nodes should be equal to: + | hydra:totalItems | 2 | + | hydra:member[0].username | two@pac.de | + | hydra:member[1].username | dr@dre.de | + + @api @user + Scenario: I can request /api/users as a superadmin and get all users of all clients which have done a walk in a specific time + Given I am authenticated against api as "superadmin@gmx.de" + When I send a GET request to "/api/users?walks.timeRange=2021-09-30T22:00:00.000Z..2021-10-31T22:59:59.999Z" + Then the response should be in JSON + And the response status code should be 200 +# And print last JSON response + And the JSON nodes should be equal to: + | hydra:totalItems | 3 | + | hydra:member[0].username | two@pac.de | + | hydra:member[1].username | dr@dre.de | + | hydra:member[2].username | karl@gamer.de | diff --git a/web/webpack.config.js b/web/webpack.config.js index a4af9dac..e97d6fc3 100755 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -8,7 +8,7 @@ Encore .setOutputPath('public/build/') // what's the public path to this directory (relative to your project's document root dir) - .setPublicPath(Encore.isProduction() ? '/build' : 'https://swapp.local/build/') + .setPublicPath(Encore.isProduction() ? '/build' : '/build/') // .setOutputPath() diff --git a/web/yarn.lock b/web/yarn.lock index 4d149d83..ca6e3c0f 100755 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -6183,7 +6183,7 @@ vue-web-storage@^4.0.2: resolved "https://registry.yarnpkg.com/vue-web-storage/-/vue-web-storage-4.0.3.tgz#36936263f5d1619ecc68fc8e6a3c90709b1bad66" integrity sha512-FFF0DKGJqLOPSgAwyR/mo/sTz7FnwjlNuB8TTPTSBalni1tDwrKSo4GRvGSorcLPdFwiY3dbSarUEHPZdn7fxA== -vue2-daterange-picker@^0.6.5: +vue2-daterange-picker@^0.6.7: version "0.6.7" resolved "https://registry.yarnpkg.com/vue2-daterange-picker/-/vue2-daterange-picker-0.6.7.tgz#fd7c353ba55290e66fd10365ac2e8f4da48e0bf0" integrity sha512-p8vMF4xvOCSAqdmQCaXOPNVIJE6Oi2vWsLfuqaRXwxFbqK5jU63pQEtCuW0XobSL5wJtomD0XvuumNeqTT7uHw==