From 943bf933e7a9423295d80e6b4e7962916f936cd3 Mon Sep 17 00:00:00 2001 From: Michel Roca Date: Wed, 4 Nov 2020 17:30:13 +0100 Subject: [PATCH] Finish user templates refacto --- .github/workflows/build.yml | 2 +- .github/workflows/ci.yml | 2 +- Makefile | 6 +- assets/css/login.scss | 10 - behat.yml.dist | 2 +- composer.json | 4 +- composer.lock | 377 +++++++++++++++- config/bootstrap.php | 1 - config/packages/reset_password.yaml | 1 + config/packages/security.yaml | 5 +- config/routes.yaml | 5 + docker-compose.yml | 4 + docker/php-flex/Dockerfile | 6 +- docs/features-fr.md | 35 +- docs/technical.md | 4 + features/organization/home.feature | 3 +- features/organization/planning.feature | 6 +- features/organization/users.feature | 14 +- features/user/password.feature | 13 +- features/user/register.feature | 2 +- features/user/reset_password.feature | 21 +- fixtures/users.yaml | 12 +- src/Command/AdminPromoteCommand.php | 55 +++ .../Admin/OrganizationsListController.php | 23 + .../User/AddToMissionModalController.php | 8 +- .../User/PromoteRevokeController.php | 8 +- .../User/UserDeleteController.php | 12 +- .../Organization/User/UserEditController.php | 14 +- .../User/UserMissionsListController.php | 8 +- .../User/UserShowModalController.php | 8 +- .../User/Account/CreateAccountController.php | 7 +- .../User/Security/ResetPasswordController.php | 42 +- src/DataFixtures/ApplicationFixtures.php | 4 +- src/Entity/Organization.php | 4 +- src/Entity/ResetPasswordRequest.php | 2 +- src/Entity/User.php | 32 +- src/EventListener/OrganizationListener.php | 2 +- src/Form/Type/ChangePasswordFormType.php | 5 +- .../Type/ResetPasswordRequestFormType.php | 11 +- src/Form/Type/UserPasswordType.php | 4 +- src/Form/Type/UserType.php | 7 +- src/Migrations/Version20200519114430.php | 2 +- src/Repository/UserRepository.php | 19 +- src/Security/Voter/OrganizationVoter.php | 6 +- src/Twig/Extension/OrganizationExtension.php | 20 +- templates/_navbar.html.twig | 32 +- templates/admin/organizations.html.twig | 26 ++ templates/base.html.twig | 8 - templates/mailer/_macro.html.twig | 17 + templates/mailer/_template.html.twig | 411 ++++++++++++++++++ templates/misc/flash-messages.html.twig | 4 +- templates/organization/base.html.twig | 42 +- .../organization/mission/_list_full.html.twig | 2 +- .../planning/_availabilities_users.html.twig | 4 +- templates/organization/user/_list.html.twig | 6 +- templates/organization/user/_show.html.twig | 40 +- templates/organization/user/edit.html.twig | 12 +- templates/organization/user/list.html.twig | 40 ++ .../reset_password/check_email.html.twig | 13 +- templates/reset_password/email.html.twig | 18 +- templates/reset_password/request.html.twig | 32 +- templates/reset_password/reset.html.twig | 8 +- templates/user/account-form.html.twig | 4 + templates/user/index.html.twig | 18 +- templates/user/login.html.twig | 42 +- templates/user/password-form.html.twig | 8 +- tests/Behat/MailsContext.php | 51 +++ tests/Behat/MinkContextTrait.php | 39 ++ tests/Behat/SecurityContext.php | 29 +- translations/messages.fr.yaml | 28 +- 70 files changed, 1493 insertions(+), 279 deletions(-) create mode 100644 src/Command/AdminPromoteCommand.php create mode 100644 src/Controller/Admin/OrganizationsListController.php create mode 100644 templates/admin/organizations.html.twig create mode 100644 templates/mailer/_macro.html.twig create mode 100644 templates/mailer/_template.html.twig create mode 100644 tests/Behat/MailsContext.php create mode 100644 tests/Behat/MinkContextTrait.php diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 51364301..1ceee53a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: - name: Get the version id: vars run: | - echo ::set-output name=BUILD_TAG::$(git describe --tags) + echo ::set-output name=BUILD_TAG::$(git describe --tags --always) echo ::set-output name=CI_COMMIT_REF_SLUG::${GITHUB_REF#refs/*/} - name: Pull existing Docker image diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ab85c7d..ed221c5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: if: always() - name: Install composer dependencies - run: composer install --no-progress --no-suggest --prefer-dist --optimize-autoloader + run: composer install --no-progress --prefer-dist --optimize-autoloader - name: Cache node_modules uses: actions/cache@v1 diff --git a/Makefile b/Makefile index 351d8138..67690faf 100644 --- a/Makefile +++ b/Makefile @@ -35,14 +35,13 @@ build-prod: docker build -t resop:latest -f docker/php-flex/Dockerfile . start-db: - $(DOCKER_COMPOSE_UP) traefik postgres adminer + $(DOCKER_COMPOSE_UP) traefik postgres adminer mailcatcher docker-compose run --rm wait -c postgres:5432 start-php: $(DOCKER_COMPOSE_UP_RECREATE) traefik nginx fpm docker-compose run --rm wait -c fpm:9000,nginx:80 - @echo -n "\nStack started with success:\nhttp://resop.vcap.me:7500/login => user1@resop.com : 01/01/1990" - @echo -n "\nhttp://resop.vcap.me:7500/organizations/login => DT75 : covid19\n" + @echo -n "\nStack started with success: http://resop.vcap.me:7500/\nuser102@resop.com : covid19\nadmin101@resop.com : covid19\nsuper_admin1@resop.com : covid19\n" start: init-db start-php @@ -130,6 +129,7 @@ test-coverage: bin/tools sh -c "COVERAGE=true vendor/bin/behat --format=progress" move-test-profiler: + @echo "You must set 'profiler: { collect: true }' in config/packages/test/web_profiler.yaml in order to use this command" bin/tools sh -c "rm -rf var/cache/dev/profiler && mkdir -p var/cache/dev && cp -R var/cache/test/profiler var/cache/dev/profiler" @echo "Done : http://resop.vcap.me:7500/_profiler/search?limit=10" diff --git a/assets/css/login.scss b/assets/css/login.scss index af0b7c8e..5d500a67 100644 --- a/assets/css/login.scss +++ b/assets/css/login.scss @@ -2,16 +2,6 @@ $xs: 380px; -@media (min-width: 360px) and (max-width: map-get($grid-breakpoints, "sm")) { - .form-inline { - .form-control { - display: inline-block; - width: auto; - vertical-align: middle; - } - } -} - body.login { .navbar-and-body { background-image: url(../img/login-background.jpg); diff --git a/behat.yml.dist b/behat.yml.dist index 51d23c3c..0082daac 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -2,7 +2,6 @@ default: suites: default: contexts: - - Alex\MailCatcher\Behat\MailCatcherContext - App\Tests\Behat\CoverageContext - App\Tests\Behat\DatabaseContext - App\Tests\Behat\FixturesContext @@ -11,6 +10,7 @@ default: - App\Tests\Behat\SecurityContext - App\Tests\Behat\TraversingContext - App\Tests\Behat\UserPlanningContext + - App\Tests\Behat\MailsContext - Behat\MinkExtension\Context\MinkContext - PantherExtension\Context\PantherContext - PantherExtension\Context\WaitContext diff --git a/composer.json b/composer.json index 04a9d770..8d11e8e9 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "symfony/form": "5.*", "symfony/framework-bundle": "5.*", "symfony/intl": "5.*", - "symfony/mailer": "5.0.*", + "symfony/mailer": "5.*", "symfony/monolog-bundle": "^3.5", "symfony/security-bundle": "5.*", "symfony/serializer-pack": "^1.0", @@ -41,7 +41,7 @@ "twig/intl-extra": "^3.0" }, "require-dev": { - "alexandresalome/mailcatcher": "dev-master", + "alexandresalome/mailcatcher": "^1.3", "behat/behat": "^3.6", "dama/doctrine-test-bundle": "^6.3", "escapestudios/symfony2-coding-standard": "^3.11", diff --git a/composer.lock b/composer.lock index ca38b36e..f4518e9b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "031141cc5c61ebd3544843de579c300c", + "content-hash": "00871b4f5c207526155666ae1a674511", "packages": [ { "name": "beberlei/assert", @@ -1667,6 +1667,64 @@ ], "time": "2020-07-30T16:57:33+00:00" }, + { + "name": "egulias/email-validator", + "version": "2.1.23", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "5fa792ad1853ae2bc60528dd3e5cbf4542d3c1df" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/5fa792ad1853ae2bc60528dd3e5cbf4542d3c1df", + "reference": "5fa792ad1853ae2bc60528dd3e5cbf4542d3c1df", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^1.0.1", + "php": ">=5.5", + "symfony/polyfill-intl-idn": "^1.10" + }, + "require-dev": { + "dominicsayers/isemail": "^3.0.7", + "phpunit/phpunit": "^4.8.36|^7.5.15", + "satooshi/php-coveralls": "^1.0.1" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "time": "2020-10-31T20:37:35+00:00" + }, { "name": "friendsofsymfony/jsrouting-bundle", "version": "2.6.0", @@ -4504,6 +4562,155 @@ ], "time": "2020-10-24T12:01:57+00:00" }, + { + "name": "symfony/mailer", + "version": "v5.1.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "fa5cc9f894a5d082e7e46bfdd44f5dd83529f0ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/fa5cc9f894a5d082e7e46bfdd44f5dd83529f0ba", + "reference": "fa5cc9f894a5d082e7e46bfdd44f5dd83529f0ba", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10", + "php": ">=7.2.5", + "psr/log": "~1.0", + "symfony/event-dispatcher": "^4.4|^5.0", + "symfony/mime": "^4.4|^5.0", + "symfony/polyfill-php80": "^1.15", + "symfony/service-contracts": "^1.1|^2" + }, + "conflict": { + "symfony/http-kernel": "<4.4" + }, + "require-dev": { + "symfony/amazon-mailer": "^4.4|^5.0", + "symfony/google-mailer": "^4.4|^5.0", + "symfony/http-client-contracts": "^1.1|^2", + "symfony/mailchimp-mailer": "^4.4|^5.0", + "symfony/mailgun-mailer": "^4.4|^5.0", + "symfony/messenger": "^4.4|^5.0", + "symfony/postmark-mailer": "^4.4|^5.0", + "symfony/sendgrid-mailer": "^4.4|^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Mailer Component", + "homepage": "https://symfony.com", + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-24T12:01:57+00:00" + }, + { + "name": "symfony/mime", + "version": "v5.1.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "f5485a92c24d4bcfc2f3fc648744fb398482ff1b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/f5485a92c24d4bcfc2f3fc648744fb398482ff1b", + "reference": "f5485a92c24d4bcfc2f3fc648744fb398482ff1b", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php80": "^1.15" + }, + "conflict": { + "symfony/mailer": "<4.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10", + "symfony/dependency-injection": "^4.4|^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A library to manipulate MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-24T12:01:57+00:00" + }, { "name": "symfony/monolog-bridge", "version": "v5.1.8", @@ -4878,6 +5085,90 @@ ], "time": "2020-10-23T14:02:19+00:00" }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "3b75acd829741c768bc8b1f84eb33265e7cc5117" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/3b75acd829741c768bc8b1f84eb33265e7cc5117", + "reference": "3b75acd829741c768bc8b1f84eb33265e7cc5117", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "symfony/polyfill-intl-normalizer": "^1.10", + "symfony/polyfill-php72": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.20-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, { "name": "symfony/polyfill-intl-normalizer", "version": "v1.20.0", @@ -6976,6 +7267,52 @@ ], "time": "2020-10-24T12:03:25+00:00" }, + { + "name": "symfonycasts/reset-password-bundle", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/SymfonyCasts/reset-password-bundle.git", + "reference": "ac39892a5de861209cb7491e056a77a0b872e87d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SymfonyCasts/reset-password-bundle/zipball/ac39892a5de861209cb7491e056a77a0b872e87d", + "reference": "ac39892a5de861209cb7491e056a77a0b872e87d", + "shasum": "" + }, + "require": { + "php": "^7.2", + "symfony/config": "^4.4 | ^5.0", + "symfony/dependency-injection": "^4.4 | ^5.0", + "symfony/http-kernel": "^4.4 | ^5.0" + }, + "conflict": { + "doctrine/orm": "<2.7", + "symfony/framework-bundle": "<4.4", + "symfony/http-foundation": "<4.4" + }, + "require-dev": { + "doctrine/doctrine-bundle": "^2.0.3", + "doctrine/orm": "^2.7", + "friendsofphp/php-cs-fixer": "^2.16", + "symfony/framework-bundle": "^4.4 | ^5.0", + "symfony/phpunit-bridge": "^5.0", + "vimeo/psalm": "^3.8" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "SymfonyCasts\\Bundle\\ResetPassword\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Symfony bundle that adds password reset functionality.", + "time": "2020-04-18T00:37:02+00:00" + }, { "name": "twig/cache-extension", "version": "v1.5.0", @@ -7393,6 +7730,44 @@ } ], "packages-dev": [ + { + "name": "alexandresalome/mailcatcher", + "version": "v1.3.0", + "target-dir": "Alex/MailCatcher", + "source": { + "type": "git", + "url": "https://github.com/alexandresalome/mailcatcher.git", + "reference": "eb708293f72d99581f52cc1caf27eb76d54c0b08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/alexandresalome/mailcatcher/zipball/eb708293f72d99581f52cc1caf27eb76d54c0b08", + "reference": "eb708293f72d99581f52cc1caf27eb76d54c0b08", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=5.3.3", + "symfony/dom-crawler": "~2.3 || ~3.0 || ~4.0 || ~5.0" + }, + "require-dev": { + "behat/behat": "~3.0", + "phpunit/phpunit": "~4.6", + "swiftmailer/swiftmailer": "~5.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "Alex\\MailCatcher": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A library to access MailCatcher", + "time": "2020-08-04T14:58:27+00:00" + }, { "name": "behat/behat", "version": "v3.8.0", diff --git a/config/bootstrap.php b/config/bootstrap.php index 4fbd83c6..9ebfc525 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -31,4 +31,3 @@ $_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null) ?: 'dev'; $_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? 'prod' !== $_SERVER['APP_ENV']; $_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN) ? '1' : '0'; - diff --git a/config/packages/reset_password.yaml b/config/packages/reset_password.yaml index 796ff0cb..52415a5c 100644 --- a/config/packages/reset_password.yaml +++ b/config/packages/reset_password.yaml @@ -1,2 +1,3 @@ symfonycasts_reset_password: request_password_repository: App\Repository\ResetPasswordRequestRepository + throttle_limit: 60 diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 6aef86a5..c32a27ab 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -10,7 +10,7 @@ security: algorithm: auto role_hierarchy: - ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ALLOWED_TO_SWITCH] + ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ALLOWED_TO_SWITCH, ROLE_ORGANIZATION] firewalls: dev: @@ -40,5 +40,6 @@ security: - { path: ^/user/new$, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/login$, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/reset-password, roles: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: ^/, roles: ROLE_USER } - { path: ^/organizations/, roles: ROLE_ORGANIZATION } + - { path: ^/admin/, roles: ROLE_SUPER_ADMIN } + - { path: ^/, roles: ROLE_USER } diff --git a/config/routes.yaml b/config/routes.yaml index 8af5e3ff..79cf0049 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -1,4 +1,9 @@ # {organization} parameter is useless for the moment, but will be useful in ticket https://github.com/crf-devs/resop/issues/338 +_admin: + resource: ../src/Controller/Admin/ + type: annotation + prefix: /admin + _organizations: resource: ../src/Controller/Organization/ type: annotation diff --git a/docker-compose.yml b/docker-compose.yml index 7f996e9b..ad3d8f53 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -93,3 +93,7 @@ services: mailcatcher: image: tophfr/mailcatcher:0.6.5 + labels: + - 'traefik.enable=true' + - 'traefik.port=80' + - 'traefik.frontend.rule=Host:mailcatcher.vcap.me' diff --git a/docker/php-flex/Dockerfile b/docker/php-flex/Dockerfile index def203a8..b179b3da 100644 --- a/docker/php-flex/Dockerfile +++ b/docker/php-flex/Dockerfile @@ -54,13 +54,13 @@ RUN set -eux; \ \ apk del .build-deps -RUN curl https://getcomposer.org/composer-1.phar -o /usr/bin/composer \ +RUN curl https://getcomposer.org/composer-stable.phar -o /usr/bin/composer \ && chmod +x /usr/bin/composer \ && mkdir /.composer \ && chown -R www-data:www-data /.composer \ && setfacl -R -m o::rwX /.composer \ && setfacl -dR -m o::rwX /.composer \ - && su-exec www-data composer global require "hirak/prestissimo" "jderusse/composer-warmup" --prefer-dist --no-progress --no-suggest --classmap-authoritative \ + && su-exec www-data composer global require "jderusse/composer-warmup" --prefer-dist --no-progress --classmap-authoritative \ && su-exec www-data composer clear-cache -n RUN test -z "$DEBUG_TOOLS" || ( \ @@ -134,7 +134,7 @@ COPY --chown=www-data:www-data composer.* symfony.lock /srv/ RUN chown -R www-data:www-data /srv USER www-data -RUN composer install --no-dev --no-scripts --prefer-dist --no-suggest && composer clear-cache -n +RUN composer install --no-dev --no-scripts --prefer-dist && composer clear-cache -n COPY --chown=www-data:www-data .env ./ RUN composer dump-env prod && rm .env diff --git a/docs/features-fr.md b/docs/features-fr.md index fea104bd..c8190a38 100644 --- a/docs/features-fr.md +++ b/docs/features-fr.md @@ -60,7 +60,7 @@ Chaque `structure` (une Unité Locale, par exemple) peut : Identifiants de démo : -* structure: `UL09` +* structure: `admin201@resop.com` * mot de passe: `covid19` ### Planning @@ -86,5 +86,36 @@ Les `structure parentes` (une Direction Territoriale, par exemple) peut : Identifiants de démo : -* structure: `DT75` +* structure: `admin101@resop.com` * mot de passe: `covid19` + +---- + +# Espace admin + +L'interface d'administration permet de : + +- Se connecter à toutes les structures +- Rendre un bénévole administrateur d'une structure +- Se connecter en tant qu'un autre utilisateur + +Identifiants de démo : + +* structure: `super_admin1@resop.com` +* mot de passe: `covid19` + +---- + +# Installation + +Voir la partie [Usage in production](docs/technical.md) de la documentation technique pour installer l'application. + +## Super admin + +Le premier utilisateur créé sera automatiquement Super Admin de l'application et pourra nommer des administrateurs de structures. + +Il est possible de promouvoir un autre utilisateur en tant que super admin grâce à la commande: + +```bash +bin/console app:admin-promote --email foobar@resop.com +``` diff --git a/docs/technical.md b/docs/technical.md index 32c7e32a..da9f513d 100644 --- a/docs/technical.md +++ b/docs/technical.md @@ -97,6 +97,7 @@ The `*.vcap.me` domain names are binded on localhost. In order to use them offli - [http://resop.vcap.me:7500](http://resop.vcap.me:7500) - [http://adminer.vcap.me:7500](http://adminer.vcap.me:7500) +- [http://mailcatcher.vcap.me:7500](http://mailcatcher.vcap.me:7500) - [http://traefik.vcap.me:7500](http://traefik.vcap.me:7500) Caution: the traefik proxy will only serve healthy containers. The api container can be unaccessible before the first healthcheck (5s). @@ -143,6 +144,9 @@ bin/tools sh -c "APP_NB_USERS=20 APP_NB_AVAILABILITIES=6 bin/console doctrine:fi - APP_NB_USERS: number of users per organization (default: 15) - APP_NB_AVAILABILITIES: number of days on which generating availabilities per user (default: 3) +### Mails + +When using the default dev env, all mails are sent to the [mailcatcher](http://mailcatcher.vcap.me:7500) service. ### HTTPS diff --git a/features/organization/home.feature b/features/organization/home.feature index dcfa63f6..cd71bc6b 100644 --- a/features/organization/home.feature +++ b/features/organization/home.feature @@ -1,3 +1,4 @@ +@home Feature: In order to manage an organization, As an admin of an organization, @@ -11,7 +12,7 @@ Feature: Scenario: As an admin of an organization, I can go to the homepage of my organization Given I am authenticated as "admin201@resop.com" And I am on the homepage - When I follow "Gérer ma structure" + When I follow "DT75" Then I should be on "/organizations/201" And the response status code should be 200 And I should see "DT75" diff --git a/features/organization/planning.feature b/features/organization/planning.feature index 6f1158ab..858dd331 100644 --- a/features/organization/planning.feature +++ b/features/organization/planning.feature @@ -52,21 +52,21 @@ Feature: Given I am authenticated as "admin201@resop.com" And I am on "/organizations/201/planning/" Then I should see "Jane DOE" - And I should see "John DOE" + And I should see "Jill DOE" And I should see "VPSP - 75992" And I should see "VL - 75996" When I select "VPSP" from "assetTypes[]" And I select "1" from "userPropertyFilters[vulnerable]" And I press "search-planning-button" Then I should be on "/organizations/201/planning/" - And I should see "John DOE" + And I should see "Jill DOE" And I should not see "Jane DOE" And I should see "VPSP - 75992" And I should not see "VL - 75996" And I select "0" from "userPropertyFilters[vulnerable]" And I press "search-planning-button" Then I should be on "/organizations/201/planning/" - And I should not see "John DOE" + And I should not see "Jill DOE" And I should see "Jane DOE" When I check "hideUsers" And I check "hideAssets" diff --git a/features/organization/users.feature b/features/organization/users.feature index 76ec35ad..e4240dc6 100644 --- a/features/organization/users.feature +++ b/features/organization/users.feature @@ -139,9 +139,9 @@ Feature: Scenario: As an admin of an organization, I can promote a user as admin of an organization and this user has admin privilege Given I am authenticated as "admin201@resop.com" - When I go to "/organizations/201/users/103/edit" + When I go to "/organizations/201/users/103" And I follow "Promouvoir" - Then I should be on "/organizations/201/users/103/edit" + Then I should be on "/organizations/201/users/103" And the response status code should be 200 And I should see "L'utilisateur \"Jill DOE\" a été promu administrateur de \"UL 01-02\" avec succès." And I should see "Révoquer" @@ -155,13 +155,13 @@ Feature: And I press "Je me connecte" Then I should be on "/" And the response status code should be 200 - And I should see "Vous devez renseigner votre mot de passe afin d'administrer votre structure." + And I should see "Vous devez renseigner votre mot de passe afin d'administrer votre structure" Scenario: As an admin of an organization, I can revoke a user admin privilege of an organization and this user doesn't have admin privilege anymore Given I am authenticated as "admin201@resop.com" - When I go to "/organizations/201/users/102/edit" + When I go to "/organizations/201/users/102" And I follow "Révoquer" - Then I should be on "/organizations/201/users/102/edit" + Then I should be on "/organizations/201/users/102" And the response status code should be 200 And I should see "Le privilège d'administrateur pour la structure \"UL 01-02\" de \"Jane DOE\" a été révoquée avec succès." And I should see "Promouvoir" @@ -175,11 +175,11 @@ Feature: And I press "Je me connecte" Then I should be on "/" And the response status code should be 200 - And I should not see "Vous devez renseigner votre mot de passe afin d'administrer votre structure." + And I should not see "Vous devez renseigner votre mot de passe afin d'administrer votre structure" Scenario: As an admin of an organization, I cannot revoke my own admin privilege Given I am authenticated as "admin201@resop.com" - When I go to "/organizations/201/users/101/edit" + When I go to "/organizations/201/users/101" Then I should not see "Révoquer" When I go to "/organizations/201/users/101/revoke" And the response status code should be 403 diff --git a/features/user/password.feature b/features/user/password.feature index 98204dad..d3a82c02 100644 --- a/features/user/password.feature +++ b/features/user/password.feature @@ -1,3 +1,4 @@ +@password Feature: In order to update my password, As a user, @@ -13,7 +14,7 @@ Feature: When I go to "/" Then I should be on "/" And the response status code should be 200 - And I should see "Vous devez renseigner votre mot de passe afin d'administrer votre structure." + And I should see "Vous devez renseigner votre mot de passe afin d'administrer votre structure" Scenario: As a user, I cannot set my password with empty data Given I am authenticated as "admin203@resop.com" @@ -22,7 +23,7 @@ Feature: When I fill in the following: | user_password[plainPassword][first] | | | user_password[plainPassword][second] | | - And I press "Valider" + And I press "Modifier mon mot de passe" Then I should be on "/user/password" And the response status code should be 400 And I should see "Cette valeur ne doit pas être vide." in the "label[for=user_password_plainPassword_first] .form-error-message" element @@ -34,7 +35,7 @@ Feature: When I fill in the following: | user_password[plainPassword][first] | foo | | user_password[plainPassword][second] | bar | - And I press "Valider" + And I press "Modifier mon mot de passe" Then I should be on "/user/password" And the response status code should be 400 And I should see "Cette valeur n'est pas valide." in the "label[for=user_password_plainPassword_first] .form-error-message" element @@ -46,7 +47,7 @@ Feature: | user_password[currentPassword] | | | user_password[plainPassword][first] | foo | | user_password[plainPassword][second] | foo | - And I press "Valider" + And I press "Modifier mon mot de passe" Then I should be on "/user/password" And the response status code should be 400 And I should see "" in the "label[for=user_password_currentPassword] .form-error-message" element @@ -62,7 +63,7 @@ Feature: | user_password[currentPassword] | covid19 | | user_password[plainPassword][first] | covid20 | | user_password[plainPassword][second] | covid20 | - And I press "Valider" + And I press "Modifier mon mot de passe" Then I should be on "/" And the response status code should be 200 And I should see "Votre mot de passe a été mis à jour avec succès." @@ -86,7 +87,7 @@ Feature: When I fill in the following: | user_password[plainPassword][first] | covid20 | | user_password[plainPassword][second] | covid20 | - And I press "Valider" + And I press "Modifier mon mot de passe" Then I should be on "/" And the response status code should be 200 And I should see "Votre mot de passe a été mis à jour avec succès." diff --git a/features/user/register.feature b/features/user/register.feature index c3eddd57..3ec5dede 100644 --- a/features/user/register.feature +++ b/features/user/register.feature @@ -64,7 +64,7 @@ Feature: Then I should be on "/user/new" And the response status code should be 400 And I should see "Cette valeur ne doit pas être vide." in the "label[for=user_identificationNumber] .form-error-message" element - And I should see "Cette valeur ne doit pas être nulle." in the "label[for=user_organization] .form-error-message" element + And I should see "Cette valeur n'est pas valide." in the "label[for=user_organization] .form-error-message" element And I should see "Cette valeur ne doit pas être vide." in the "label[for=user_firstName] .form-error-message" element And I should see "Cette valeur ne doit pas être vide." in the "label[for=user_lastName] .form-error-message" element And I should see "Cette valeur ne doit pas être vide." in the "label[for=user_emailAddress] .form-error-message" element diff --git a/features/user/reset_password.feature b/features/user/reset_password.feature index 147e3625..89382f79 100644 --- a/features/user/reset_password.feature +++ b/features/user/reset_password.feature @@ -1,3 +1,4 @@ +@reset_password Feature: In order to reset my password, As a user, @@ -13,22 +14,27 @@ Feature: When I go to "/" Then I should be on "/login" And the response status code should be 200 - And I should see "Mot de passe oublié ?" - When I follow "Mot de passe oublié ?" + And I should see "J'ai oublié mon mot de passe" + When I follow "J'ai oublié mon mot de passe" Then I should be on "/reset-password" And the response status code should be 200 When I fill in "reset_password_request_form[emailAddress]" with "admin201@resop.com" - And I press "Envoyer le lien" + And I press "Valider" Then I should be on "/reset-password/check-email" And I should see "Un email vous a été envoyé contenant un lien vous permettant de réinitialiser mon mot de passe. Ce lien expirera dans 1 heure(s)." And 1 mail should be sent + # TODO Check email content & link + Then I open mail with subject "J'ai oublié mon mot de passe" + And I click on the "#reset-password" link in mail + Then I should be on "/reset-password/reset" + Then I purge mails - Scenario: As anonymous, I cannot request a token if I already requested one in the configured time + # As anonymous, I cannot request a token if I already requested one in the configured time Given I am on "/reset-password" - When I fill in "reset_password_request_form[emailAddress]" with "admin203@resop.com" - And I press "Envoyer le lien" + When I fill in "reset_password_request_form[emailAddress]" with "admin201@resop.com" + And I press "Valider" Then I should be on "/reset-password" - And I should see "Une erreur est survenue durant la réinitialisation de votre mot de passe - You have already requested a reset password email. Please check your email or try again soon." + And I should see "Vous avez déjà demandé la réinitialisation de votre mot de passe." And 0 mail should be sent Scenario: As a user, I cannot reset my password using a valid token @@ -62,3 +68,4 @@ Feature: When I go to "/reset-password/reset/invalid" Then I should be on "/reset-password" And the response status code should be 200 + And I should see "Le lien de réinitialisation est invalide ou a expiré" diff --git a/fixtures/users.yaml b/fixtures/users.yaml index 8e4a996d..c49918d2 100644 --- a/fixtures/users.yaml +++ b/fixtures/users.yaml @@ -1,3 +1,5 @@ +# This file is only used for tests. +# See App\DataFixtures\ApplicationFixtures for dev data App\Entity\User: # John DOE is admin of DT75. He is not volunteer. # As organization admin, his password is required. @@ -13,7 +15,7 @@ App\Entity\User: birthday: '1990-01-01' skillSet: '' properties: {"occupation": "Pharmacien", "organizationOccupation": "Secouriste", "vulnerable": true, "fullyEquipped": true, "drivingLicence": true} - organizations: ['@Organization.DT75'] + managedOrganizations: ['@Organization.DT75'] # Jane DOE is volunteer and admin of UL 01-02, an organization children of DT75 managed by John DOE. # As organization admin, her password is required, but she didn't filled it yet. @@ -28,7 +30,7 @@ App\Entity\User: birthday: '1990-01-01' skillSet: '' properties: {"occupation": "", "organizationOccupation": "Secouriste", "vulnerable": , "fullyEquipped": , "drivingLicence": } - organizations: ['@Organization.UL-01-02'] + managedOrganizations: ['@Organization.UL-01-02'] # Jill DOE is volunteer of UL 01-02, an organization children of DT75 managed by John DOE. # As volunteer, she doesn't any password, and connects using her birth date. @@ -42,7 +44,7 @@ App\Entity\User: phoneNumber: '' birthday: '1990-01-01' skillSet: '' - properties: {"occupation": "", "organizationOccupation": "Secouriste", "vulnerable": , "fullyEquipped": , "drivingLicence": } + properties: {"occupation": "Pompier", "organizationOccupation": "Secouriste", "vulnerable": true, "fullyEquipped": true, "drivingLicence": true} # Freddy MERCURY is volunteer in Organization.UL-DE-BRIE-ET-CHANTEREINE, and admin of UL DE BRIE ET CHANTEREINE, # an organization children of DT77 managed Lady GAGA. @@ -59,7 +61,7 @@ App\Entity\User: birthday: '1990-01-01' skillSet: '' properties: {"occupation": "", "organizationOccupation": "Secouriste", "vulnerable": , "fullyEquipped": , "drivingLicence": } - organizations: ['@Organization.UL-DE-BRIE-ET-CHANTEREINE'] + managedOrganizations: ['@Organization.UL-DE-BRIE-ET-CHANTEREINE'] # Chuck NORRIS is volunteer in UL DE BRIE ET CHANTEREINE, an organization children of DT77 managed by Lady GAGA. # Because he has a password, he has to connect using it. @@ -90,7 +92,7 @@ App\Entity\User: birthday: '1990-01-01' skillSet: '' properties: {"occupation": "", "organizationOccupation": "Secouriste", "vulnerable": , "fullyEquipped": , "drivingLicence": } - organizations: ['@Organization.DT77'] + managedOrganizations: ['@Organization.DT77'] # Super ADMIN is a super-admin, he can do whatever he wants. User.super_admin: diff --git a/src/Command/AdminPromoteCommand.php b/src/Command/AdminPromoteCommand.php new file mode 100644 index 00000000..36490cc0 --- /dev/null +++ b/src/Command/AdminPromoteCommand.php @@ -0,0 +1,55 @@ +em = $entityManager; + + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setDescription('Promote an user as a super admin') + ->addOption('email', null, InputOption::VALUE_REQUIRED, 'The user email address'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $userRepository = $this->em->getRepository(User::class); + + $email = $input->getOption('email'); + if (!\is_string($email) || empty($email)) { + throw new \InvalidArgumentException('Bad email provided'); + } + + $user = $userRepository->findOneBy(['emailAddress' => $email]); + if (empty($user)) { + throw new \InvalidArgumentException('User not found'); + } + + $user->roles = array_unique(array_merge((array) $user->roles, ['ROLE_SUPER_ADMIN'])); + $this->em->flush(); + + $output->writeln('User is now a super admin'); + + return 0; + } +} diff --git a/src/Controller/Admin/OrganizationsListController.php b/src/Controller/Admin/OrganizationsListController.php new file mode 100644 index 00000000..45c66832 --- /dev/null +++ b/src/Controller/Admin/OrganizationsListController.php @@ -0,0 +1,23 @@ +render('admin/organizations.html.twig', [ + 'organizations' => $organizationRepository->findAllWithParent(), + ]); + } +} diff --git a/src/Controller/Organization/User/AddToMissionModalController.php b/src/Controller/Organization/User/AddToMissionModalController.php index 32df7030..fad1be62 100644 --- a/src/Controller/Organization/User/AddToMissionModalController.php +++ b/src/Controller/Organization/User/AddToMissionModalController.php @@ -15,8 +15,8 @@ use Symfony\Component\Routing\Annotation\Route; /** - * @Route("/{user<\d+>}/missions/add/modal", name="app_organization_user_add_to_mission_modal", methods={"GET"}) - * @Security("is_granted('ROLE_PARENT_ORGANIZATION', user.organization)") + * @Route("/{item<\d+>}/missions/add/modal", name="app_organization_user_add_to_mission_modal", methods={"GET"}) + * @Security("is_granted('ROLE_PARENT_ORGANIZATION', item.organization)") */ class AddToMissionModalController extends AbstractController { @@ -29,13 +29,13 @@ public function __construct(PlanningDomain $planningDomain, MissionRepository $m $this->missionRepository = $missionRepository; } - public function __invoke(User $user, Organization $organization): Response + public function __invoke(User $item, Organization $organization): Response { $form = $this->planningDomain->generateForm($organization, MissionsSearchType::class); $filters = $form->getData(); return $this->render('organization/mission/add-to-mission-modal-content.html.twig', [ - 'userToAdd' => $user, + 'userToAdd' => $item, 'filters' => $filters, 'form' => $form->createView(), 'missions' => $this->missionRepository->findByFilters($filters), diff --git a/src/Controller/Organization/User/PromoteRevokeController.php b/src/Controller/Organization/User/PromoteRevokeController.php index 34f6920b..a98fc0a5 100644 --- a/src/Controller/Organization/User/PromoteRevokeController.php +++ b/src/Controller/Organization/User/PromoteRevokeController.php @@ -26,14 +26,14 @@ class PromoteRevokeController extends AbstractOrganizationController public function __invoke(EntityManagerInterface $entityManager, Organization $organization, User $item, bool $promote): Response { if ($promote) { - $item->addOrganization($organization); + $item->addManagedOrganization($organization); $this->addFlash('success', sprintf('L\'utilisateur "%s" a été promu administrateur de "%s" avec succès.', $item->getFullName(), $organization->getName())); } else { - $item->removeOrganization($organization); + $item->removeManagedOrganization($organization); $this->addFlash('success', sprintf('Le privilège d\'administrateur pour la structure "%s" de "%s" a été révoquée avec succès.', $organization->getName(), $item->getFullName())); } - $entityManager->flush($item); + $entityManager->flush(); - return $this->redirectToRoute('app_organization_user_edit', ['user' => $item->id, 'organization' => $item->getNotNullOrganization()->id]); + return $this->redirectToRoute('app_organization_user_list', ['organization' => $item->getNotNullOrganization()->id]); } } diff --git a/src/Controller/Organization/User/UserDeleteController.php b/src/Controller/Organization/User/UserDeleteController.php index 6c41eb66..c6aaafaa 100644 --- a/src/Controller/Organization/User/UserDeleteController.php +++ b/src/Controller/Organization/User/UserDeleteController.php @@ -13,8 +13,8 @@ use Symfony\Component\Routing\Annotation\Route; /** - * @Route("/{user<\d+>}/delete", name="app_organization_user_delete", methods={"GET"}) - * @Security("is_granted('ROLE_PARENT_ORGANIZATION', user.organization)") + * @Route("/{item<\d+>}/delete", name="app_organization_user_delete", methods={"GET"}) + * @Security("is_granted('ROLE_PARENT_ORGANIZATION', item.organization)") */ class UserDeleteController extends AbstractOrganizationController { @@ -25,16 +25,16 @@ public function __construct(UserAvailabilityRepository $userAvailabilityReposito $this->userAvailabilityRepository = $userAvailabilityRepository; } - public function __invoke(EntityManagerInterface $entityManager, User $user): RedirectResponse + public function __invoke(EntityManagerInterface $entityManager, User $item): RedirectResponse { $entityManager->beginTransaction(); - $this->userAvailabilityRepository->deleteByOwner($user); - $entityManager->remove($user); + $this->userAvailabilityRepository->deleteByOwner($item); + $entityManager->remove($item); $entityManager->flush(); $entityManager->commit(); $this->addFlash('success', 'Le bénévole a été supprimé avec succès.'); - return $this->redirectToRoute('app_organization_user_list', ['organization' => $user->getNotNullOrganization()->id]); + return $this->redirectToRoute('app_organization_user_list', ['organization' => $item->getNotNullOrganization()->id]); } } diff --git a/src/Controller/Organization/User/UserEditController.php b/src/Controller/Organization/User/UserEditController.php index 8c789519..6597887c 100644 --- a/src/Controller/Organization/User/UserEditController.php +++ b/src/Controller/Organization/User/UserEditController.php @@ -13,29 +13,29 @@ use Symfony\Component\Routing\Annotation\Route; /** - * @Route("/{user<\d+>}/edit", name="app_organization_user_edit", methods={"GET", "POST"}) - * @Security("is_granted('ROLE_PARENT_ORGANIZATION', user.organization)") + * @Route("/{item<\d+>}/edit", name="app_organization_user_edit", methods={"GET", "POST"}) + * @Security("is_granted('ROLE_PARENT_ORGANIZATION', item.organization)") */ class UserEditController extends AbstractOrganizationController { - public function __invoke(Request $request, User $user): Response + public function __invoke(Request $request, User $item): Response { $form = $this - ->createForm(UserType::class, $user, ['display_type' => UserType::DISPLAY_ORGANIZATION]) + ->createForm(UserType::class, $item, ['display_type' => UserType::DISPLAY_ORGANIZATION]) ->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $entityManager = $this->getDoctrine()->getManager(); - $entityManager->persist($user); + $entityManager->persist($item); $entityManager->flush(); $this->addFlash('success', 'Les informations ont été mises à jour avec succès.'); - return $this->redirectToRoute('app_organization_user_list', ['organization' => $user->getNotNullOrganization()->id]); + return $this->redirectToRoute('app_organization_user_list', ['organization' => $item->getNotNullOrganization()->id]); } return $this->render('organization/user/edit.html.twig', [ - 'user' => $user, + 'user' => $item, 'form' => $form->createView(), ])->setStatusCode($form->isSubmitted() ? Response::HTTP_BAD_REQUEST : Response::HTTP_OK); } diff --git a/src/Controller/Organization/User/UserMissionsListController.php b/src/Controller/Organization/User/UserMissionsListController.php index b861a3c5..924e2084 100644 --- a/src/Controller/Organization/User/UserMissionsListController.php +++ b/src/Controller/Organization/User/UserMissionsListController.php @@ -11,16 +11,16 @@ use Symfony\Component\Routing\Annotation\Route; /** - * @Route("/{user<\d+>}/missions", name="app_organization_user_missions_list", methods={"GET"}) + * @Route("/{item<\d+>}/missions", name="app_organization_user_missions_list", methods={"GET"}) * @Security("is_granted('ROLE_PARENT_ORGANIZATION', user.organization)") */ class UserMissionsListController extends AbstractController { - public function __invoke(User $user): Response + public function __invoke(User $item): Response { return $this->render('organization/user/missions_list.html.twig', [ - 'user' => $user, - 'missions' => $user->missions, + 'user' => $item, + 'missions' => $item->missions, ]); } } diff --git a/src/Controller/Organization/User/UserShowModalController.php b/src/Controller/Organization/User/UserShowModalController.php index 4ee7589d..fc5635d6 100644 --- a/src/Controller/Organization/User/UserShowModalController.php +++ b/src/Controller/Organization/User/UserShowModalController.php @@ -11,15 +11,15 @@ use Symfony\Component\Routing\Annotation\Route; /** - * @Route("/{user<\d+>}/modal", name="app_organization_user_show_modal", methods={"GET"}) - * @Security("is_granted('ROLE_PARENT_ORGANIZATION', user.organization)") + * @Route("/{item<\d+>}/modal", name="app_organization_user_show_modal", methods={"GET"}) + * @Security("is_granted('ROLE_PARENT_ORGANIZATION', item.organization)") */ class UserShowModalController extends AbstractController { - public function __invoke(User $user): Response + public function __invoke(User $item): Response { return $this->render('organization/user/show-modal-content.html.twig', [ - 'user' => $user, + 'user' => $item, ]); } } diff --git a/src/Controller/User/Account/CreateAccountController.php b/src/Controller/User/Account/CreateAccountController.php index 36633028..eea7fc8a 100644 --- a/src/Controller/User/Account/CreateAccountController.php +++ b/src/Controller/User/Account/CreateAccountController.php @@ -6,6 +6,7 @@ use App\Entity\User; use App\Form\Type\UserType; +use App\Repository\UserRepository; use App\Security\UserAutomaticLoginHandler; use App\Security\UserLoginFormAuthenticator; use Doctrine\ORM\EntityManagerInterface; @@ -23,15 +24,18 @@ final class CreateAccountController extends AbstractController private AuthenticationUtils $authenticationUtils; private EntityManagerInterface $entityManager; private UserAutomaticLoginHandler $automaticLoginHandler; + private UserRepository $userRepository; public function __construct( AuthenticationUtils $authenticationUtils, EntityManagerInterface $entityManager, - UserAutomaticLoginHandler $automaticLoginHandler + UserAutomaticLoginHandler $automaticLoginHandler, + UserRepository $userRepository ) { $this->authenticationUtils = $authenticationUtils; $this->entityManager = $entityManager; $this->automaticLoginHandler = $automaticLoginHandler; + $this->userRepository = $userRepository; } public function __invoke(Request $request): Response @@ -52,6 +56,7 @@ public function __invoke(Request $request): Response $form = $this->createForm(UserType::class, $user)->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + $this->userRepository->addUserRoles($user); $this->entityManager->persist($user); $this->entityManager->flush(); diff --git a/src/Controller/User/Security/ResetPasswordController.php b/src/Controller/User/Security/ResetPasswordController.php index 7ed22072..eb2194ac 100644 --- a/src/Controller/User/Security/ResetPasswordController.php +++ b/src/Controller/User/Security/ResetPasswordController.php @@ -16,22 +16,28 @@ use Symfony\Component\Mime\Address; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; +use Symfony\Contracts\Translation\TranslatorInterface; use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait; +use SymfonyCasts\Bundle\ResetPassword\Exception\ExpiredResetPasswordTokenException; +use SymfonyCasts\Bundle\ResetPassword\Exception\InvalidResetPasswordTokenException; use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface; +use SymfonyCasts\Bundle\ResetPassword\Exception\TooManyPasswordRequestsException; use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface; /** * @Route("/reset-password") */ -class ResetPasswordController extends AbstractController +final class ResetPasswordController extends AbstractController { use ResetPasswordControllerTrait; - private $resetPasswordHelper; + private ResetPasswordHelperInterface $resetPasswordHelper; + private TranslatorInterface $translator; - public function __construct(ResetPasswordHelperInterface $resetPasswordHelper) + public function __construct(ResetPasswordHelperInterface $resetPasswordHelper, TranslatorInterface $translator) { $this->resetPasswordHelper = $resetPasswordHelper; + $this->translator = $translator; } /** @@ -56,7 +62,7 @@ public function request(Request $request, MailerInterface $mailer): Response } return $this->render('reset_password/request.html.twig', [ - 'requestForm' => $form->createView(), + 'form' => $form->createView(), ]); } @@ -108,11 +114,16 @@ public function reset(Request $request, UserPasswordEncoderInterface $passwordEn try { /** @var User $user */ $user = $this->resetPasswordHelper->validateTokenAndFetchUser($token); + } catch (ExpiredResetPasswordTokenException $e) { + $this->addFlash('error', $this->translator->trans('user.resetPassword.expired')); + + return $this->redirectToRoute('app_forgot_password_request'); + } catch (InvalidResetPasswordTokenException $e) { + $this->addFlash('error', $this->translator->trans('user.resetPassword.expired')); + + return $this->redirectToRoute('app_forgot_password_request'); } catch (ResetPasswordExceptionInterface $e) { - $this->addFlash('error', sprintf( - 'Une erreur est survenue durant la réinitialisation de votre mot de passe - %s', - $e->getReason() - )); + $this->addFlash('error', $e->getReason()); return $this->redirectToRoute('app_forgot_password_request'); } @@ -136,7 +147,7 @@ public function reset(Request $request, UserPasswordEncoderInterface $passwordEn // The session is cleaned up after the password has been changed. $this->cleanSessionAfterReset(); - $this->addFlash('success', 'Votre mot de passe a été mis à jour avec succès.'); + $this->addFlash('success', $this->translator->trans('user.resetPassword.sucess')); return $this->redirectToRoute('app_login'); } @@ -162,19 +173,20 @@ private function processSendingPasswordResetEmail(string $emailFormData, MailerI try { $resetToken = $this->resetPasswordHelper->generateResetToken($user); + } catch (TooManyPasswordRequestsException $e) { + $this->addFlash('error', $this->translator->trans('user.resetPassword.tooMany')); + + return $this->redirectToRoute('app_forgot_password_request'); } catch (ResetPasswordExceptionInterface $e) { - $this->addFlash('error', sprintf( - 'Une erreur est survenue durant la réinitialisation de votre mot de passe - %s', - $e->getReason() - )); + $this->addFlash('error', $e->getReason()); return $this->redirectToRoute('app_forgot_password_request'); } $email = (new TemplatedEmail()) - ->from(new Address('noreply@resop.com', 'Réserve opérationnelle - Croix-Rouge Française')) + ->from(new Address($this->translator->trans('project.emailSender'), $this->translator->trans('project.name'))) ->to($user->getEmailAddress()) - ->subject('Mot de passe oublié') + ->subject($this->translator->trans('user.passwordForgotten.title')) ->htmlTemplate('reset_password/email.html.twig') ->context([ 'resetToken' => $resetToken, diff --git a/src/DataFixtures/ApplicationFixtures.php b/src/DataFixtures/ApplicationFixtures.php index 287ecad9..40970732 100644 --- a/src/DataFixtures/ApplicationFixtures.php +++ b/src/DataFixtures/ApplicationFixtures.php @@ -119,7 +119,7 @@ public function __construct( PhoneNumberUtil $phoneNumberUtil, string $slotInterval, int $nbUsers = 15, - int $nbAvailabilities = null + ?int $nbAvailabilities = null ) { $this->validator = $validator; $this->skillSetDomain = $skillSetDomain; @@ -335,7 +335,7 @@ private function createUser(int $organizationUserNumber, Organization $organizat $user->password = '$argon2id$v=19$m=65536,t=4,p=1$cEjk39WnLC+QRVJfNI5nmw$eM0J3UZ75hwFJRGQmph2OiBGRzJU6/NGVWcj0j+WVYw'; if (null !== $organization) { - $user->addOrganization($organization); + $user->addManagedOrganization($organization); } } diff --git a/src/Entity/Organization.php b/src/Entity/Organization.php index 7c288e27..b924376b 100644 --- a/src/Entity/Organization.php +++ b/src/Entity/Organization.php @@ -46,7 +46,7 @@ class Organization public Collection $children; /** - * @ORM\ManyToMany(targetEntity="App\Entity\User", mappedBy="organizations") + * @ORM\ManyToMany(targetEntity="App\Entity\User", mappedBy="managedOrganizations") */ public Collection $admins; @@ -137,7 +137,7 @@ public function addAdmin(User $admin): void { if (!$this->admins->contains($admin)) { $this->admins[] = $admin; - $admin->addOrganization($this); + $admin->addManagedOrganization($this); } } diff --git a/src/Entity/ResetPasswordRequest.php b/src/Entity/ResetPasswordRequest.php index 0f9f654a..2a34e687 100644 --- a/src/Entity/ResetPasswordRequest.php +++ b/src/Entity/ResetPasswordRequest.php @@ -23,7 +23,7 @@ class ResetPasswordRequest implements ResetPasswordRequestInterface private ?int $id = null; /** - * @ORM\ManyToOne(targetEntity="App\Entity\User") + * @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="resetPasswordRequests") */ private User $user; diff --git a/src/Entity/User.php b/src/Entity/User.php index 2b1daeb1..9d112773 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -45,7 +45,7 @@ class User implements UserPasswordInterface, AvailabilitableInterface, UserSeria public ?int $id = null; /** - * @ORM\Column + * @ORM\Column(unique=true) * @Assert\NotBlank * @Assert\Regex( * pattern=User::NIVOL_FORMAT, @@ -55,7 +55,7 @@ class User implements UserPasswordInterface, AvailabilitableInterface, UserSeria private string $identificationNumber = ''; /** - * @ORM\Column + * @ORM\Column(unique=true) * @Assert\NotBlank * @Assert\Email */ @@ -100,7 +100,7 @@ class User implements UserPasswordInterface, AvailabilitableInterface, UserSeria * @ORM\ManyToMany(targetEntity="App\Entity\Organization", inversedBy="admins") * @ORM\OrderBy({"name"="ASC"}) */ - public Collection $organizations; + public Collection $managedOrganizations; /** * @ORM\Column(type="text[]", nullable=true) @@ -117,6 +117,11 @@ class User implements UserPasswordInterface, AvailabilitableInterface, UserSeria */ private iterable $availabilities = []; + /** + * @ORM\OneToMany(targetEntity="App\Entity\ResetPasswordRequest", mappedBy="user", cascade={"remove"}) + */ + private iterable $resetPasswordRequests = []; // Used for cascade + /** * @ORM\ManyToMany(targetEntity="App\Entity\Mission", mappedBy="users") */ @@ -181,7 +186,7 @@ public static function normalizeEmailAddress(string $emailAddress): string public function __construct() { - $this->organizations = new ArrayCollection(); + $this->managedOrganizations = new ArrayCollection(); } public function __toString(): string @@ -229,12 +234,13 @@ public function serialize(): string */ public function unserialize($serialized): void { - list( + [ $this->id, $this->identificationNumber, $this->emailAddress, $this->birthday, - $this->password) = unserialize($serialized, ['allowed_classes' => [__CLASS__]]); + $this->password, + ] = unserialize($serialized, ['allowed_classes' => [__CLASS__]]); } public function getId(): ?int @@ -314,21 +320,21 @@ public function getAvailabilities(): iterable /** * @return Collection|Organization[] */ - public function getOrganizations(): Collection + public function getManagedOrganizations(): Collection { - return $this->organizations; + return $this->managedOrganizations; } - public function addOrganization(Organization $organization): void + public function addManagedOrganization(Organization $organization): void { - if (!$this->organizations->contains($organization) && !$this->organizations->contains($organization->getParentOrganization())) { - $this->organizations[] = $organization; + if (!$this->managedOrganizations->contains($organization)) { + $this->managedOrganizations[] = $organization; $organization->addAdmin($this); } } - public function removeOrganization(Organization $organization): void + public function removeManagedOrganization(Organization $organization): void { - $this->organizations->removeElement($organization); + $this->managedOrganizations->removeElement($organization); } } diff --git a/src/EventListener/OrganizationListener.php b/src/EventListener/OrganizationListener.php index 438547f9..3e9be575 100644 --- a/src/EventListener/OrganizationListener.php +++ b/src/EventListener/OrganizationListener.php @@ -29,7 +29,7 @@ public function __construct(OrganizationRepository $organizationRepository, Auth public static function getSubscribedEvents(): array { return [ - KernelEvents::REQUEST => 'onKernelRequest', + KernelEvents::REQUEST => ['onKernelRequest'], ]; } diff --git a/src/Form/Type/ChangePasswordFormType.php b/src/Form/Type/ChangePasswordFormType.php index 7eddd2b5..734cd4d4 100644 --- a/src/Form/Type/ChangePasswordFormType.php +++ b/src/Form/Type/ChangePasswordFormType.php @@ -27,12 +27,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'max' => 4096, ]), ], - 'label' => 'Mot de passe', + 'label' => 'user.newPassword', ], 'second_options' => [ - 'label' => 'Confirmation', + 'label' => 'user.confirmNewPassword', ], - 'invalid_message' => 'Les mots de passe ne correspondent pas.', // Instead of being set onto the object directly, // this is read and encoded in the controller 'mapped' => false, diff --git a/src/Form/Type/ResetPasswordRequestFormType.php b/src/Form/Type/ResetPasswordRequestFormType.php index dc88446b..a19eb36e 100644 --- a/src/Form/Type/ResetPasswordRequestFormType.php +++ b/src/Form/Type/ResetPasswordRequestFormType.php @@ -7,7 +7,6 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\NotBlank; class ResetPasswordRequestFormType extends AbstractType @@ -16,17 +15,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder ->add('emailAddress', EmailType::class, [ + 'label' => 'user.email', 'constraints' => [ - new NotBlank([ - 'message' => 'Please enter your email', - ]), + new NotBlank(), ], ]) ; } - - public function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([]); - } } diff --git a/src/Form/Type/UserPasswordType.php b/src/Form/Type/UserPasswordType.php index a725a665..6e59bf6b 100644 --- a/src/Form/Type/UserPasswordType.php +++ b/src/Form/Type/UserPasswordType.php @@ -21,10 +21,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'type' => PasswordType::class, 'required' => true, 'first_options' => [ - 'label' => 'user.password', + 'label' => 'user.newPassword', ], 'second_options' => [ - 'label' => 'user.confirmPassword', + 'label' => 'user.confirmNewPassword', ], ]); diff --git a/src/Form/Type/UserType.php b/src/Form/Type/UserType.php index 8067e6fe..f1fa712a 100644 --- a/src/Form/Type/UserType.php +++ b/src/Form/Type/UserType.php @@ -36,8 +36,9 @@ public function __construct(SkillSetDomain $skillSetDomain, array $userPropertie public function buildForm(FormBuilderInterface $builder, array $options): void { - /** @var Organization|null $organization */ - $organization = $builder->getData()->organization; + /** @var User|null $data */ + $data = $builder->getData(); + $organization = $data->organization; $builder ->add('organization', OrganizationEntityType::class, [ @@ -49,7 +50,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->addOrderBy('o.name', 'ASC'); if ($organization instanceof Organization) { - $qb = $repository->findByIdOrParentIdQueryBuilder($organization->getId(), $qb); + $qb = $repository->findByIdOrParentIdQueryBuilder($organization->getParentOrganization()->getId(), $qb); } return $qb; diff --git a/src/Migrations/Version20200519114430.php b/src/Migrations/Version20200519114430.php index aedde14c..f6749711 100644 --- a/src/Migrations/Version20200519114430.php +++ b/src/Migrations/Version20200519114430.php @@ -11,7 +11,7 @@ final class Version20200519114430 extends AbstractMigration { public function getDescription(): string { - return ''; + return 'Add user reset password request table'; } public function up(Schema $schema): void diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 33c5b8be..8ec1b4c0 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -29,7 +29,7 @@ public function __construct(ManagerRegistry $registry, string $slotInterval) { parent::__construct($registry, User::class); - $this->slotInterval = $slotInterval; + $this->slotInterval = $slotInterval; // Used in AvailabilityQueryTrait methods } public function findOneByIdAndOrganization(int $id, Organization $organization): ?User @@ -181,4 +181,21 @@ public function findByOrganizationAndChildrenQb(Organization $organization, bool return $qb; } + + public function usersCount(): int + { + return$this->createQueryBuilder('u') + ->select('COUNT(u) as total_count') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult()['total_count'] ?? 0; + } + + public function addUserRoles(User $user): void + { + // The first user is the super admin + if (0 === $this->usersCount()) { + $user->roles[] = 'ROLE_SUPER_ADMIN'; + } + } } diff --git a/src/Security/Voter/OrganizationVoter.php b/src/Security/Voter/OrganizationVoter.php index 6a75e9da..d1145a13 100644 --- a/src/Security/Voter/OrganizationVoter.php +++ b/src/Security/Voter/OrganizationVoter.php @@ -27,7 +27,7 @@ protected function supports($attribute, $subject): bool return \in_array($attribute, [ self::ROLE_ORGANIZATION, self::ROLE_PARENT_ORGANIZATION, - ], true) && (null === $subject || $subject instanceof Organization); + ], true); } /** @@ -43,8 +43,8 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token): return false; } - if (null === $subject) { - return true; + if (!$subject instanceof Organization) { + return !$user->getManagedOrganizations()->isEmpty(); } if ($this->decisionManager->decide($token, ['ROLE_SUPER_ADMIN'])) { diff --git a/src/Twig/Extension/OrganizationExtension.php b/src/Twig/Extension/OrganizationExtension.php index 36da8746..06dcf20c 100644 --- a/src/Twig/Extension/OrganizationExtension.php +++ b/src/Twig/Extension/OrganizationExtension.php @@ -42,21 +42,25 @@ public function getOrganizationUrl(string $name, array $parameters = [], bool $s return $this->routingExtension->getUrl($name, $this->buildParameters($name, $parameters), $schemeRelative); } - private function buildParameters(string $name, array $parameters): array + private function buildParameters(string $routeName, array $parameters): array { $request = $this->requestStack->getCurrentRequest(); - if (!preg_match('/^app_organization_.*$/', $name) + if (0 !== strpos($routeName, 'app_organization_') || !$request - || !($organization = $request->attributes->get('currentOrganization')) - || !$organization instanceof Organization + || !($currentOrganization = $request->attributes->get('currentOrganization')) + || !$currentOrganization instanceof Organization ) { return $parameters; } - $parameter = $parameters['organization'] ?? null; - $parameters = array_merge($parameters, ['organization' => $organization->getId()]); - if (null !== $parameter && $parameters['organization'] !== $parameter) { - $parameters['organizationId'] = $parameter; + $organizationParameter = $parameters['organization'] ?? null; + $parameters = array_merge($parameters, ['organization' => $currentOrganization->getId()]); + if (null !== $organizationParameter && $parameters['organization'] !== $organizationParameter) { + if ('app_organization_dashboard' === $routeName) { + $parameters['organization'] = $organizationParameter; + } else { + $parameters['organizationId'] = $organizationParameter; + } } return $parameters; diff --git a/templates/_navbar.html.twig b/templates/_navbar.html.twig index df542b02..c4f607ac 100644 --- a/templates/_navbar.html.twig +++ b/templates/_navbar.html.twig @@ -20,14 +20,32 @@ {% else %} {{ 'nav.profile' | trans }} {{ 'nav.availability' | trans }} - {% if is_granted('ROLE_PREVIOUS_ADMIN') %} - Retour à l'admin - {% endif %} - {{ 'action.logout' | trans }} + - - {{ app.user }} - + + {% endif %} diff --git a/templates/admin/organizations.html.twig b/templates/admin/organizations.html.twig new file mode 100644 index 00000000..d8a89574 --- /dev/null +++ b/templates/admin/organizations.html.twig @@ -0,0 +1,26 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'nav.section.adminArganization' | trans }}{% endblock %} + +{% block body %} +

{{ 'nav.section.admin' | trans }}

+ + + + + + + + + + {% for organization in organizations | sort %} + + + + + {% endfor %} + +
{{ 'common.name' | trans }}{{ 'common.actions' | trans }}
{{ organization }} + {{ 'action.edit' | trans }} +
+{% endblock %} diff --git a/templates/base.html.twig b/templates/base.html.twig index a4491ab8..4cfc8297 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -15,14 +15,6 @@ {% include '_navbar.html.twig' %}
- {% if app.user and 0 < app.user.organizations.count and app.user.password is empty %} -

- - Vous devez renseigner votre mot de passe afin d'administrer votre structure. - -

- {% endif %} - {% block body %}{% endblock %}
diff --git a/templates/mailer/_macro.html.twig b/templates/mailer/_macro.html.twig new file mode 100644 index 00000000..91872c7d --- /dev/null +++ b/templates/mailer/_macro.html.twig @@ -0,0 +1,17 @@ +{% macro button(link, text, linkId) %} + + + + + + + +{% endmacro %} diff --git a/templates/mailer/_template.html.twig b/templates/mailer/_template.html.twig new file mode 100644 index 00000000..9b0a7b94 --- /dev/null +++ b/templates/mailer/_template.html.twig @@ -0,0 +1,411 @@ +{# This email template comes from https://github.com/leemunroe/responsive-html-email-template #} +{# For an API service you need to inline the CSS before sending. See https://symfony.com/doc/current/mailer.html#inlining-css-styles or https://github.com/leemunroe/responsive-html-email-template#sending-emails-directly-from-your-codebase-or-using-a-developer-service #} + + + + + + + {{ 'project.name'|trans }} + + + +{{ 'project.name'|trans }} + + + + + + + + + diff --git a/templates/misc/flash-messages.html.twig b/templates/misc/flash-messages.html.twig index a69a5b66..4e7dce76 100644 --- a/templates/misc/flash-messages.html.twig +++ b/templates/misc/flash-messages.html.twig @@ -1,10 +1,10 @@ {% for message in app.flashes('success') %} -
diff --git a/templates/organization/user/list.html.twig b/templates/organization/user/list.html.twig index cf6013de..95ef5b16 100644 --- a/templates/organization/user/list.html.twig +++ b/templates/organization/user/list.html.twig @@ -12,5 +12,45 @@ {{ form(organization_selector_form) }} +
+ + + + + + + + + {% for user in organization.admins %} + + + + + {% else %} + + + + {% endfor %} + +
{{ 'user.admin' | trans }}
{{ user }} + + {% if is_granted('ROLE_ALLOWED_TO_SWITCH') %} + {{ 'action.impersonate' | trans }} + {% endif %} + {% if is_granted('ROLE_PARENT_ORGANIZATION', organization) and app.user != user %} + + {{ 'action.revoke' | trans({ '%organization%': organization.name, '%user%': user.fullName }) }} + + {% endif %} +
{{ 'message.noAvailableData' | trans }}
+
+ +

+ {{ 'organization.addAdmin' | trans }}
+ {{ 'organization.addAdminOtherOrganization' | trans }} +

+ {{ include('organization/user/_list.html.twig') }} {% endblock %} diff --git a/templates/reset_password/check_email.html.twig b/templates/reset_password/check_email.html.twig index 83a9ae93..2f444222 100644 --- a/templates/reset_password/check_email.html.twig +++ b/templates/reset_password/check_email.html.twig @@ -1,9 +1,14 @@ {% extends 'base.html.twig' %} -{% block title %}Réinitialiser mon mot de passe{% endblock %} +{% block title %}{{ 'user.passwordForgotten.title' | trans }}{% endblock %} {% block body %} -

Un email vous a été envoyé contenant un lien vous permettant de réinitialiser mon mot de passe. - Ce lien expirera dans {{ tokenLifetime|date('g') }} heure(s).

-

Si vous ne recevez pas cet email, veuillez vérifier vos spams ou réessayez.

+ +

{{ 'user.passwordForgotten.title' | trans }}

+ +

+ {{ 'user.passwordForgotten.emailCheck' | trans({'%hours%': tokenLifetime|date('g')}) }} +

+ {{ 'user.passwordForgotten.emailCheckAgain' | trans({'%link%': path('app_forgot_password_request')}) | raw }} +

{% endblock %} diff --git a/templates/reset_password/email.html.twig b/templates/reset_password/email.html.twig index 7330234f..35645016 100644 --- a/templates/reset_password/email.html.twig +++ b/templates/reset_password/email.html.twig @@ -1,11 +1,11 @@ -

Bonjour !

+{% extends 'mailer/_template.html.twig' %} -

- Pour réinitialiser votre mot de passe, veuillez aller sur - - {{ url('app_reset_password', {token: resetToken.token}) }} - - Ce lien expirera dans {{ tokenLifetime|date('g') }} heure(s).. -

+{% block content %} + {% import 'mailer/_macro.html.twig' as template %} -

Cordialement

+

+ {{ 'user.resetPassword.emailText'|trans({'%hours%': tokenLifetime|date('g')}) }} +

+ + {{ template.button(url('app_reset_password', {token: resetToken.token}), 'user.resetPassword.button'|trans, 'reset-password') }} +{% endblock %} diff --git a/templates/reset_password/request.html.twig b/templates/reset_password/request.html.twig index 95ede9cc..a5f79894 100644 --- a/templates/reset_password/request.html.twig +++ b/templates/reset_password/request.html.twig @@ -1,21 +1,21 @@ {% extends 'base.html.twig' %} -{% block title %}Réinitialiser mon mot de passe{% endblock %} +{% block title %}{{ 'user.passwordForgotten.title' | trans }}{% endblock %} {% block body %} - {% for flashError in app.flashes('reset_password_error') %} - - {% endfor %} -

Réinitialiser mon mot de passe

- - {{ form_start(requestForm) }} - {{ form_row(requestForm.emailAddress) }} -
- - Veuillez saisir votre adresse email afin d'obtenir un lien de réinitialisation de votre mot de passe. - -
- - - {{ form_end(requestForm) }} + +

{{ 'user.passwordForgotten.title' | trans }}

+ +

{{ 'user.passwordForgotten.request' | trans }}

+ + {{ form_start(form) }} + + {% for error in app.flashes('error') %} + + {% endfor %} + + {{ form_row(form.emailAddress) }} + + + {{ form_end(form) }} {% endblock %} diff --git a/templates/reset_password/reset.html.twig b/templates/reset_password/reset.html.twig index 5bcade89..bf447028 100644 --- a/templates/reset_password/reset.html.twig +++ b/templates/reset_password/reset.html.twig @@ -1,12 +1,14 @@ {% extends 'base.html.twig' %} -{% block title %}Réinitialiser mon mot de passe{% endblock %} +{% block title %}{{ 'user.passwordForgotten.title' | trans }}{% endblock %} {% block body %} -

Réinitialiser mon mot de passe

+ +

{{ 'user.passwordForgotten.title' | trans }}

{{ form_start(resetForm) }} {{ form_row(resetForm.plainPassword) }} - + + {{ form_end(resetForm) }} {% endblock %} diff --git a/templates/user/account-form.html.twig b/templates/user/account-form.html.twig index c2232583..711c46c2 100644 --- a/templates/user/account-form.html.twig +++ b/templates/user/account-form.html.twig @@ -9,6 +9,10 @@ {% endblock %} {% block body %} + {% if user is defined%} + {{ 'user.editMyPassword'|trans }} + {% endif %} +

{{ 'user.accountAction' | trans({ '%action%' : actionName }) }}

{% if user is not defined or user.id is not defined or user.id is null %} diff --git a/templates/user/index.html.twig b/templates/user/index.html.twig index e4301236..8b016f8c 100644 --- a/templates/user/index.html.twig +++ b/templates/user/index.html.twig @@ -10,8 +10,6 @@ {% block body %}
- {{ include('misc/flash-messages.html.twig') }} -

{{ 'user.welcome'|trans }} {{ app.user.fullName }}

{{ app.user.organization }}

@@ -25,17 +23,13 @@

- {% if app.user.organizations|length %} - {% if app.user.password is empty %} -

- {{ 'user.passwordRequired'|trans }} -

- {% else %} - {% for organization in app.user.organizations %} -

{{ organization.name }}

- {% endfor %} - {% endif %} + {% if app.user.managedOrganizations|length and app.user.password is empty %} +

+ {{ 'user.passwordRequired'|trans }} +

{% endif %} + + {{ include('misc/flash-messages.html.twig') }}
diff --git a/templates/user/login.html.twig b/templates/user/login.html.twig index ecc0e045..67dd73a6 100644 --- a/templates/user/login.html.twig +++ b/templates/user/login.html.twig @@ -10,25 +10,53 @@ {% include '_navbar.html.twig' with {navbarClass: 'navbar-dark'} %}
- {{ include('misc/flash-messages.html.twig') }} -
-
-
- -
+
+
-
+ {{ form_end(form) }} {% endblock %} diff --git a/tests/Behat/MailsContext.php b/tests/Behat/MailsContext.php new file mode 100644 index 00000000..e5111616 --- /dev/null +++ b/tests/Behat/MailsContext.php @@ -0,0 +1,51 @@ +getCrawler($this->getCurrentMessage()); + $link = $crawler->filter($selector); + + if (!$link->count()) { + throw new \RuntimeException("Unable to find the $selector link in mail"); + } + + if (empty($link->attr('href'))) { + throw new \RuntimeException("The $selector link does not have any href"); + } + + $this->getMinkContext()->visitPath((string) $link->attr('href')); + } + + private function getCrawler(Message $message): Crawler + { + if (!$message->isMultipart() || !$message->hasPart('text/html')) { + throw new \RuntimeException(sprintf('The current message has no html part.')); + } + + return new Crawler($message->getPart('text/html')->getContent()); + } + + private function getCurrentMessage(): Message + { + if (null === $this->currentMessage) { + throw new \RuntimeException('No message selected'); + } + + return $this->currentMessage; + } +} diff --git a/tests/Behat/MinkContextTrait.php b/tests/Behat/MinkContextTrait.php new file mode 100644 index 00000000..a7f556c2 --- /dev/null +++ b/tests/Behat/MinkContextTrait.php @@ -0,0 +1,39 @@ +getEnvironment(); + $minkContext = $environment->getContext(MinkContext::class); + + if (!$minkContext instanceof MinkContext) { + throw new \RuntimeException('Invalid mink context'); + } + + $this->minkContext = $minkContext; + } + + private function getMinkContext(): MinkContext + { + if (null === $this->minkContext) { + throw new \RuntimeException('Invalid mink context value'); + } + + return $this->minkContext; + } +} diff --git a/tests/Behat/SecurityContext.php b/tests/Behat/SecurityContext.php index 2798ce3b..496c6ef3 100644 --- a/tests/Behat/SecurityContext.php +++ b/tests/Behat/SecurityContext.php @@ -5,11 +5,8 @@ namespace App\Tests\Behat; use App\Repository\UserRepository; -use Behat\Behat\Context\Environment\InitializedContextEnvironment; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; use Behat\Mink\Driver\BrowserKitDriver; use Behat\Mink\Exception\ExpectationException; -use Behat\MinkExtension\Context\MinkContext; use Behat\MinkExtension\Context\RawMinkContext; use PantherExtension\Driver\PantherDriver; use Symfony\Component\BrowserKit\Cookie; @@ -20,9 +17,10 @@ final class SecurityContext extends RawMinkContext { + use MinkContextTrait; + private UserRepository $userRepository; private SessionInterface $session; - private MinkContext $minkContext; public function __construct(UserRepository $userRepository, SessionInterface $session) { @@ -30,18 +28,6 @@ public function __construct(UserRepository $userRepository, SessionInterface $se $this->session = $session; } - /** - * @BeforeScenario - */ - public function gatherContext(BeforeScenarioScope $scope): void - { - /** @var InitializedContextEnvironment $environment */ - $environment = $scope->getEnvironment(); - /** @var MinkContext $minkContext */ - $minkContext = $environment->getContext(MinkContext::class); - $this->minkContext = $minkContext; - } - /** * @Given I am authenticated as :username */ @@ -75,12 +61,13 @@ public function login(string $username): void */ private function loginForPanther(UserInterface $user): void { + $minkContext = $this->getMinkContext(); try { - $this->minkContext->visit('/login'); - $this->minkContext->fillField('user_login[identifier]', $user->getUsername()); - $this->minkContext->fillField('user_login[password]', 'covid19'); - $this->minkContext->pressButton('Je me connecte'); - $this->minkContext->assertPageAddress('/'); + $minkContext->visit('/login'); + $minkContext->fillField('user_login[identifier]', $user->getUsername()); + $minkContext->fillField('user_login[password]', 'covid19'); + $minkContext->pressButton('Je me connecte'); + $minkContext->assertPageAddress('/'); } catch (\Exception $exception) { throw new ExpectationException(sprintf('Impossible to connect user: %s', $exception->getMessage()), $this->getSession(), $exception); } diff --git a/translations/messages.fr.yaml b/translations/messages.fr.yaml index d1a20635..f718baf3 100644 --- a/translations/messages.fr.yaml +++ b/translations/messages.fr.yaml @@ -73,6 +73,8 @@ nav: section: volunteer: Espace bénévole organization: Espace structure + admin: Admin + exitImpersonation: Retour à l'admin welcome: Bienvenue organization: add: Ajouter une structure @@ -180,6 +182,9 @@ organization: skillset: Compétences Croix-Rouge userTitle: Bénévole users: Bénévoles + addAdmin: Pour ajouter un admin, utiliser le bouton "Promouvoir en tant qu'admin" dans la fiche d'un bénévole. + addAdminOtherOrganization: Pour promouvoir un bénévole d'une autre structure, vous devez le changer de structure, le promouvoir, puis changer à nouveau. + isAdminOf: Ce bénévole est administrateur des structures (et de leurs sous-structures) asset_type: main_title: Types de véhicule add_new: Ajouter un nouveau type de véhicule @@ -214,7 +219,9 @@ project: description: Réserve opérationnelle - Croix-Rouge Française à Paris name: ResOP version: Version %tag% + emailSender: noreply@resop.com user: + admin: Admin accountAction: "%action% de compte" passwordAction: Mon mot de passe availabilities: Vos disponibilités @@ -243,9 +250,13 @@ user: identificationNumber: NIVOL lastName: Nom login: Numéro NIVOL ou Adresse e-mail + loginWithBirthday: Je me connecte avec ma date de naissance + loginWithPassword: Je me connecte avec mon mot de passe currentPassword: Mot de passe actuel password: Mot de passe confirmPassword: Confirmation du mot de passe + newPassword: Nouveau mot de passe + confirmNewPassword: Confirmation du nouveau mot de passe mobile: Numéro de téléphone portable occupationTitle: Profession occupation: @@ -265,7 +276,22 @@ user: skill: Compétence skills: Compétences organizationOccupation: Cadre - welcome: Bienvenue, + welcome: Bienvenue, + passwordForgotten: + title: J'ai oublié mon mot de passe + request: Veuillez saisir votre adresse email afin d'obtenir un lien de réinitialisation de votre mot de passe. + emailCheck: | + Si votre adresse mail est valide, un email vous a été envoyé contenant un lien vous permettant de réinitialiser mon mot de passe. + Ce lien expirera dans %hours% heure(s). + emailCheckAgain: Si vous ne recevez pas cet email, veuillez vérifier vos spams ou réessayez. + resetPassword: + button: Réinitialiser mon mot de passe + tooMany: Vous avez déjà demandé la réinitialisation de votre mot de passe. Attendez une minute avant de recommencer si vous n'avez rien reçu. + expired: Le lien de réinitialisation est invalide ou a expiré. Réessayez à nouveau de réinitialiser votre mot de passe. + emailText: | + Afin de réinitialiser votre mot de passe, cliquez sur le bouton ci-dessous. + Attention: le lien expirera dans %hours% heure(s). + sucess: Votre mot de passe a été mis à jour avec succès. # Symfony default translations Submit: Enregistrer