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/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..453daf65 100644 --- a/features/organization/users.feature +++ b/features/organization/users.feature @@ -155,7 +155,7 @@ 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" @@ -175,7 +175,7 @@ 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" 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/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/PromoteRevokeController.php b/src/Controller/Organization/User/PromoteRevokeController.php index 34f6920b..0a3c94a6 100644 --- a/src/Controller/Organization/User/PromoteRevokeController.php +++ b/src/Controller/Organization/User/PromoteRevokeController.php @@ -26,13 +26,13 @@ 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]); } 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..1d6ab209 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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->contains($organization->getParentOrganization())) { + $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/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..f84d33ff 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 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/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') %} -