Compare commits

...
15 Commits
Author SHA1 Message Date
fromme f48e53fdd0 fix: verify the oauth2 state before the error parameter 2026-09-23 17:48:24 +02:00
fromme 3f4586ce06 feat: harden the myep oauth2 client 2026-09-23 16:06:25 +02:00
frommeandClaude Opus 5 2a8649fc6d chore: run MariaDB 11.8 in ddev to match production
Development ran MySQL 8.4 while production runs MariaDB, so nothing engine-specific
was ever exercised on the engine it ships on.

DATABASE_URL carries serverVersion=mariadb-* now, which is what makes DBAL pick its
MariaDB platform. This matters most for the test environment: it reads DATABASE_URL
from the committed .env and not from .env.dev.local, so a MySQL version there had the
suite running on MySQL84Platform against a MariaDB server - and schema:validate
reporting every nullable and every json column as drift. With the platform right, a
db_test built from the mapping is in sync.

The post-start hooks create db_test and grant the db user, which ddev does not do on
its own, and sync the schema from the entity mapping. Without them the first test run
after a fresh start fails on the missing database.

The remaining changes in config.yaml are ddev's own regeneration of the commented
reference section.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 09:02:56 +02:00
fromme 161cfb9e62 chore: exclude secrets file from repository 2026-09-23 08:06:45 +02:00
fromme eac125517f fix: use a SortDirection instance for the availabilities ordering 2026-09-21 12:20:55 +02:00
fromme af46c57bd1 feat: normalize email addresses to lowercase on write 2026-09-21 12:20:55 +02:00
fromme d6327f033e fix: stagger cron triggered tasks between prod and staging 2026-09-20 13:32:48 +02:00
fromme 752db081b3 chore: update project dependencies 2026-09-20 13:32:23 +02:00
fromme 518f3a46d0 test: adopt the ROLE_TEAM_ADMIN label rename 2026-09-19 11:36:23 +02:00
fromme a05143bfbe fix: make BusProNet response failures diagnosable and non-fatal 2026-09-19 11:36:14 +02:00
fromme 29cedd5916 chore: disable unused ddev ssh-agent 2026-09-17 08:31:29 +02:00
fromme 5c57dc823f fix: add missing access checks 2026-09-16 18:03:04 +02:00
fromme d4908b6ab9 feat: rename ROLE_ADMIN to ROLE_TEAM_ADMIN since being too broad 2026-09-16 17:20:45 +02:00
fromme 39a343911a chore: update project dependencies 2026-09-16 17:15:31 +02:00
fromme 66d560f98d fix: pass query parameters individually to querybuilder 2026-09-16 17:14:24 +02:00
112 changed files with 1131 additions and 412 deletions
+50 -28
View File
@@ -1,3 +1,4 @@
name: myep-team
type: php type: php
docroot: public docroot: public
php_version: "8.4" php_version: "8.4"
@@ -6,8 +7,20 @@ xdebug_enabled: false
additional_hostnames: [] additional_hostnames: []
additional_fqdns: [] additional_fqdns: []
database: database:
type: mysql type: mariadb
version: "8.4" version: "11.8"
hooks:
# The agent hooks in config.codex-claude.yaml are merged with these, not replaced by them.
post-start:
# ddev creates "db" alone, so the test database has to be created and granted here.
# Without it the first test run after a fresh start fails with "Access denied for user
# 'db'@'%' to database 'db_test'".
- exec: mysql -uroot -proot -hdb -e "CREATE DATABASE IF NOT EXISTS db_test CHARACTER SET utf8mb4; GRANT ALL ON db_test.* TO 'db'@'%';"
# Schema from the entity mapping, never from the migrations: the squashed history is not
# replayable from an empty database (Version20260824124850 alters a table that
# Version20260901090029 only creates later). No fixtures - a test persists its own rows.
- exec: php bin/console doctrine:schema:update --force --env=test --no-interaction
omit_containers: [ddev-ssh-agent]
use_dns_when_possible: true use_dns_when_possible: true
timezone: Europe/Berlin timezone: Europe/Berlin
composer_version: "2" composer_version: "2"
@@ -15,7 +28,6 @@ web_environment: []
nodejs_version: "18" nodejs_version: "18"
corepack_enable: false corepack_enable: false
disable_upload_dirs_warning: true disable_upload_dirs_warning: true
xhgui_https_port: "8142" xhgui_https_port: "8142"
xhgui_http_port: "8143" xhgui_http_port: "8143"
@@ -26,16 +38,16 @@ xhgui_http_port: "8143"
# If the name is omitted, the project will take the name of the enclosing directory, # If the name is omitted, the project will take the name of the enclosing directory,
# which is useful if you want to have a copy of the project side by side with this one. # which is useful if you want to have a copy of the project side by side with this one.
# type: <projecttype> # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress # type: <projecttype> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, maho, modx, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
# See https://docs.ddev.com/en/stable/users/quickstart/ for more # See https://docs.ddev.com/en/stable/users/quickstart/ for more
# information on the different project types # information on the different project types
# docroot: <relative_path> # Relative path to the directory containing index.php. # docroot: <relative_path> # Relative path to the directory containing index.php.
# php_version: "8.3" # PHP version to use, "5.6" through "8.5" # php_version: "8.4" # PHP version to use, "5.6" through "8.5"
# You can explicitly specify the webimage but this # You can explicitly specify the webimage but this
# is not recommended, as the images are often closely tied to DDEV's' behavior, # is not recommended, as the images are often closely tied to DDEV's behavior,
# so this can break upgrades. # so this can break upgrades.
# webimage: <docker_image> # webimage: <docker_image>
@@ -45,10 +57,18 @@ xhgui_http_port: "8143"
# database: # database:
# type: <dbtype> # mysql, mariadb, postgres # type: <dbtype> # mysql, mariadb, postgres
# version: <version> # database version, like "10.11" or "8.0" # version: <version> # database version, like "10.11" or "8.0"
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8 # MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8, 12.3
# MySQL versions can be 5.5-8.0, 8.4 # MySQL versions can be 5.5-8.0, 8.4, 9.7
# PostgreSQL versions can be 9-18 # PostgreSQL versions can be 9-18
# You can explicitly specify the dbimage but this
# is not recommended, as the images are often closely tied to DDEV's behavior,
# so this can break upgrades.
# dbimage: <docker_image>
# Its unusual to change this option, and we dont recommend it without Docker experience and a good reason.
# Typically, this means additions to the existing db image using a .ddev/db-build/Dockerfile.*
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80) # router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443) # router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
@@ -68,8 +88,7 @@ xhgui_http_port: "8143"
# bind_all_ports is used (normally with router disabled) # bind_all_ports is used (normally with router disabled)
# xhprof_mode: [prepend|xhgui|global] # xhprof_mode: [prepend|xhgui|global]
# Set to "xhgui" to enable XHGui features # Default is "xhgui"
# "xhgui" will become default in a future major release
# webserver_type: nginx-fpm, apache-fpm, generic # webserver_type: nginx-fpm, apache-fpm, generic
@@ -87,22 +106,24 @@ xhgui_http_port: "8143"
# commands are executed. # commands are executed.
# composer_version: "2" # composer_version: "2"
# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1 # You can set it to "" or "2" (default) for Composer v2
# to use the latest major version available at the time your container is built. # to use the latest major version available at the time your container is built.
# It is also possible to use each other Composer version channel. This includes: # It is also possible to use any other Composer version channel. This includes:
# - 2.2 (latest Composer LTS version) # - 2.2 (latest Composer LTS version)
# - stable # - stable
# - preview # - preview
# - snapshot # - snapshot
# Alternatively, an explicit Composer version may be specified, for example "2.2.18". # Alternatively, an explicit Composer version may be specified, for example "2.2.18".
# To reinstall Composer after the image was built, run "ddev debug rebuild". # To reinstall Composer after the image was built, run "ddev utility rebuild".
# nodejs_version: "22" # nodejs_root: <relative_path>
# Relative path to the directory containing the Node.js version file from the
# project root. Only used with "nodejs_version: auto" or "nodejs_version: engine".
# nodejs_version: "24"
# change from the default system Node.js version to any other version. # change from the default system Node.js version to any other version.
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information # See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation, # and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use,
# can specify any version, and is more robust than using 'nvm'.
# corepack_enable: false # corepack_enable: false
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm # Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
@@ -162,9 +183,7 @@ xhgui_http_port: "8143"
# - "global": uses the value from the global config. # - "global": uses the value from the global config.
# - "none": disables performance optimization for this project. # - "none": disables performance optimization for this project.
# - "mutagen": enables Mutagen for this project. # - "mutagen": enables Mutagen for this project.
# - "nfs": enables NFS for this project.
# #
# See https://docs.ddev.com/en/stable/users/install/performance/#nfs
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen # See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
# fail_on_hook_fail: False # fail_on_hook_fail: False
@@ -194,10 +213,10 @@ xhgui_http_port: "8143"
# The mailpit port is not normally bound on the host at all, instead being routed # The mailpit port is not normally bound on the host at all, instead being routed
# through ddev-router, but it can be bound directly to localhost if specified here. # through ddev-router, but it can be bound directly to localhost if specified here.
# webimage_extra_packages: [php7.4-tidy, php-bcmath] # webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
# Extra Debian packages that are needed in the webimage can be added here # Extra Debian packages that are needed in the webimage can be added here
# dbimage_extra_packages: [telnet,netcat] # dbimage_extra_packages: [netcat, telnet, sudo]
# Extra Debian packages that are needed in the dbimage can be added here # Extra Debian packages that are needed in the dbimage can be added here
# use_dns_when_possible: true # use_dns_when_possible: true
@@ -209,12 +228,15 @@ xhgui_http_port: "8143"
# project_tld: ddev.site # project_tld: ddev.site
# The top-level domain used for project URLs # The top-level domain used for project URLs
# The default "ddev.site" allows DNS lookup via a wildcard # The default "ddev.site" allows DNS lookup via a wildcard
# If you prefer you can change this to "ddev.local" to preserve
# pre-v1.9 behavior.
# ngrok_args: --basic-auth username:pass1234 # share_default_provider: ngrok
# Provide extra flags to the "ngrok http" command, see # The default share provider to use for "ddev share"
# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h" # Defaults to global configuration, usually "ngrok"
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/
# share_provider_args: --basic-auth username:pass1234
# Provide extra flags to the share provider script
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args
# disable_settings_management: false # disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like # If true, DDEV will not create CMS-specific settings files like
@@ -276,7 +298,7 @@ xhgui_http_port: "8143"
# override_config: false # override_config: false
# By default, config.*.yaml files are *merged* into the configuration # By default, config.*.yaml files are *merged* into the configuration
# But this means that some things can't be overridden # But this means that some things can't be overridden
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge # For example, if you have 'use_dns_when_possible: true' you can't override it with a merge
# and you can't erase existing hooks or all environment variables. # and you can't erase existing hooks or all environment variables.
# However, with "override_config: true" in a particular config.*.yaml file, # However, with "override_config: true" in a particular config.*.yaml file,
# 'use_dns_when_possible: false' can override the existing values, and # 'use_dns_when_possible: false' can override the existing values, and
@@ -286,7 +308,7 @@ xhgui_http_port: "8143"
# web_environment: [] # web_environment: []
# or # or
# additional_hostnames: [] # additional_hostnames: []
# can have their intended affect. 'override_config' affects only behavior of the # can have their intended effect. 'override_config' affects only behavior of the
# config.*.yaml file it exists in. # config.*.yaml file it exists in.
# Many DDEV commands can be extended to run tasks before or after the # Many DDEV commands can be extended to run tasks before or after the
+10 -2
View File
@@ -27,7 +27,10 @@ APP_SECRET=b3cb0285aea14bbffb57df0cc3aa84f4
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4" # DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" # DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=15&charset=utf8" # DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=15&charset=utf8"
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.4.5&charset=utf8mb4" # ddev runs MariaDB to match production. The serverVersion is what makes DBAL pick its
# MariaDB platform - left at a MySQL version it speaks MySQL to a MariaDB server, and the
# test suite then runs against a different platform than production.
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=mariadb-11.8.9&charset=utf8mb4"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
###> symfony/messenger ### ###> symfony/messenger ###
@@ -46,6 +49,11 @@ MAILING_MAILER_DSN=null://null
APP_BASE_URI=https://myep-team.ddev.site APP_BASE_URI=https://myep-team.ddev.site
# Regex matched against the Host header; requests for any other host are refused. The
# OAuth2 redirect_uri is generated from the request, so this is what pins it. Production
# sets its own value outside the repository, like APP_BASE_URI above.
APP_TRUSTED_HOSTS=^myep-team\.ddev\.site$
APP_BPN_USER= APP_BPN_USER=
APP_BPN_PASSWORD= APP_BPN_PASSWORD=
APP_BPN_IP= APP_BPN_IP=
@@ -55,7 +63,7 @@ APP_BPN_DEBUG=false
# This hotel code will be assigned to admin users together with ROLE_HOTEL_MANAGER # This hotel code will be assigned to admin users together with ROLE_HOTEL_MANAGER
# in dev and staging environments for testing purposes # in dev and staging environments for testing purposes
APP_BPN_DEFAULT_HOTEL_CODE= APP_BPN_DEFAULT_HOTEL_CODE=
APP_BPN_CRM_ID_ADMIN=1292 APP_BPN_CRM_ID_TEAM_ADMIN=1484
APP_BPN_CRM_ID_MANAGER=1293 APP_BPN_CRM_ID_MANAGER=1293
APP_BPN_CRM_ID_TEAMER=1070 APP_BPN_CRM_ID_TEAMER=1070
+2
View File
@@ -4,3 +4,5 @@ APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999 SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
# the test client requests http://localhost/
APP_TRUSTED_HOSTS='^localhost$'
+2
View File
@@ -35,3 +35,5 @@ yarn-error.log
/.php-cs-fixer.php /.php-cs-fixer.php
/.php-cs-fixer.cache /.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ### ###< friendsofphp/php-cs-fixer ###
/http-client.private.env.json
Generated
+176 -140
View File
@@ -1757,26 +1757,27 @@
}, },
{ {
"name": "guzzlehttp/guzzle", "name": "guzzlehttp/guzzle",
"version": "7.15.5", "version": "8.2.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/guzzle.git", "url": "https://github.com/guzzle/guzzle.git",
"reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" "reference": "93939470950a9b11e2e84204166ef5e048c55fe4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", "url": "https://api.github.com/repos/guzzle/guzzle/zipball/93939470950a9b11e2e84204166ef5e048c55fe4",
"reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", "reference": "93939470950a9b11e2e84204166ef5e048c55fe4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-json": "*", "ext-json": "*",
"guzzlehttp/promises": "^2.5.3", "guzzlehttp/promises": "^3.0.2",
"guzzlehttp/psr7": "^2.13.1", "guzzlehttp/psr7": "^3.1",
"php": "^7.2.5 || ^8.0", "php": "^7.4 || ^8.0",
"psr/http-client": "^1.0", "psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0", "psr/http-factory": "^1.0",
"symfony/polyfill-php80": "^1.25" "symfony/polyfill-php80": "^1.25",
"symfony/polyfill-php82": "^1.27"
}, },
"provide": { "provide": {
"psr/http-client-implementation": "1.0" "psr/http-client-implementation": "1.0"
@@ -1784,10 +1785,10 @@
"require-dev": { "require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*", "ext-curl": "*",
"guzzle/client-integration-tests": "3.0.3", "guzzle/client-integration-tests": "4.0.1",
"guzzlehttp/test-server": "^0.7", "guzzlehttp/test-server": "^1.0.1",
"php-http/message-factory": "^1.1", "php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34", "phpunit/phpunit": "^9.6.34",
"psr/log": "^1.1 || ^2.0 || ^3.0" "psr/log": "^1.1 || ^2.0 || ^3.0"
}, },
"suggest": { "suggest": {
@@ -1803,9 +1804,6 @@
} }
}, },
"autoload": { "autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": { "psr-4": {
"GuzzleHttp\\": "src/" "GuzzleHttp\\": "src/"
} }
@@ -1865,7 +1863,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/guzzle/issues", "issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.15.5" "source": "https://github.com/guzzle/guzzle/tree/8.2.0"
}, },
"funding": [ "funding": [
{ {
@@ -1881,29 +1879,28 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-08-24T09:21:06+00:00" "time": "2026-09-06T13:55:09+00:00"
}, },
{ {
"name": "guzzlehttp/promises", "name": "guzzlehttp/promises",
"version": "2.5.3", "version": "3.0.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/promises.git", "url": "https://github.com/guzzle/promises.git",
"reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" "reference": "42118e66a53c492effaf92bc357e931985d5c6f9"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", "url": "https://api.github.com/repos/guzzle/promises/zipball/42118e66a53c492effaf92bc357e931985d5c6f9",
"reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", "reference": "42118e66a53c492effaf92bc357e931985d5c6f9",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2.5 || ^8.0", "php": "^7.4 || ^8.0"
"symfony/deprecation-contracts": "^2.5 || ^3.0"
}, },
"require-dev": { "require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.52 || ^9.6.34" "phpunit/phpunit": "^9.6.34"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -1949,7 +1946,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/promises/issues", "issues": "https://github.com/guzzle/promises/issues",
"source": "https://github.com/guzzle/promises/tree/2.5.3" "source": "https://github.com/guzzle/promises/tree/3.0.2"
}, },
"funding": [ "funding": [
{ {
@@ -1965,39 +1962,39 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-08-24T09:11:28+00:00" "time": "2026-08-24T10:00:26+00:00"
}, },
{ {
"name": "guzzlehttp/psr7", "name": "guzzlehttp/psr7",
"version": "2.13.1", "version": "3.1.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/psr7.git", "url": "https://github.com/guzzle/psr7.git",
"reference": "95e7828100de18b4e269fb1703be530082d5166d" "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", "url": "https://api.github.com/repos/guzzle/psr7/zipball/a3059ba1a84c9139c4ae03cf0f45bea276c97c74",
"reference": "95e7828100de18b4e269fb1703be530082d5166d", "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2.5 || ^8.0", "php": "^7.4 || ^8.0",
"psr/http-factory": "^1.0", "psr/http-factory": "^1.1",
"psr/http-message": "^1.1 || ^2.0", "psr/http-message": "^2.0",
"ralouphie/getallheaders": "^3.0", "symfony/polyfill-php80": "^1.25",
"symfony/deprecation-contracts": "^2.5 || ^3.0", "symfony/polyfill-php82": "^1.27"
"symfony/polyfill-php80": "^1.25"
}, },
"provide": { "provide": {
"psr/http-factory-implementation": "1.0", "psr/http-factory-implementation": "1.1",
"psr/http-message-implementation": "1.0" "psr/http-message-implementation": "2.0"
}, },
"require-dev": { "require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "1.1.0", "http-interop/http-factory-tests": "1.1.0",
"jshttp/mime-db": "1.54.0.1", "jshttp/mime-db": "1.54.0.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34" "php-http/psr7-integration-tests": "^1.5.1",
"phpunit/phpunit": "^9.6.34"
}, },
"suggest": { "suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
@@ -2068,7 +2065,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/psr7/issues", "issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.13.1" "source": "https://github.com/guzzle/psr7/tree/3.1.0"
}, },
"funding": [ "funding": [
{ {
@@ -2084,7 +2081,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-08-24T09:13:11+00:00" "time": "2026-08-24T11:02:13+00:00"
}, },
{ {
"name": "imagine/imagine", "name": "imagine/imagine",
@@ -2953,21 +2950,22 @@
}, },
{ {
"name": "league/oauth2-client", "name": "league/oauth2-client",
"version": "2.9.0", "version": "2.9.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/oauth2-client.git", "url": "https://github.com/thephpleague/oauth2-client.git",
"reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e" "reference": "8cedfef9d01a8d1fd2ecfabb41734b17592c4b71"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/26e8c5da4f3d78cede7021e09b1330a0fc093d5e", "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/8cedfef9d01a8d1fd2ecfabb41734b17592c4b71",
"reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e", "reference": "8cedfef9d01a8d1fd2ecfabb41734b17592c4b71",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-json": "*", "ext-json": "*",
"guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", "guzzlehttp/guzzle": "^6.5.8 || ^7.8.2 || ^8.0",
"guzzlehttp/psr7": "^1.9.1 || ^2.6.3 || ^3.0",
"php": "^7.1 || >=8.0.0 <8.6.0" "php": "^7.1 || >=8.0.0 <8.6.0"
}, },
"require-dev": { "require-dev": {
@@ -3012,9 +3010,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/thephpleague/oauth2-client/issues", "issues": "https://github.com/thephpleague/oauth2-client/issues",
"source": "https://github.com/thephpleague/oauth2-client/tree/2.9.0" "source": "https://github.com/thephpleague/oauth2-client/tree/2.9.1"
}, },
"time": "2025-11-25T22:17:17+00:00" "time": "2026-09-16T13:14:31+00:00"
}, },
{ {
"name": "liip/imagine-bundle", "name": "liip/imagine-bundle",
@@ -4139,16 +4137,16 @@
}, },
{ {
"name": "phpoffice/phpspreadsheet", "name": "phpoffice/phpspreadsheet",
"version": "5.9.0", "version": "5.10.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339" "reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339", "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339", "reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4177,6 +4175,7 @@
"dealerdirect/phpcodesniffer-composer-installer": "dev-main", "dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0", "dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*", "ext-intl": "*",
"ext-openssl": "*",
"friendsofphp/php-cs-fixer": "^3.2", "friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.5", "mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1", "mpdf/mpdf": "^8.1.1",
@@ -4186,11 +4185,12 @@
"phpstan/phpstan-phpunit": "^1.0 || ^2.0", "phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5 || ^11.0", "phpunit/phpunit": "^10.5 || ^11.0",
"squizlabs/php_codesniffer": "^3.7", "squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5" "tecnickcom/tcpdf": ">=6.8.0 <7.0.0"
}, },
"suggest": { "suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer", "dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()", "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"ext-openssl": "Handline Agile-encrypted Xlsx spreadsheets",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer", "mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
@@ -4242,9 +4242,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0" "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.10.0"
}, },
"time": "2026-07-12T19:17:39+00:00" "time": "2026-09-17T03:54:50+00:00"
}, },
{ {
"name": "phpseclib/phpseclib", "name": "phpseclib/phpseclib",
@@ -4920,50 +4920,6 @@
}, },
"time": "2021-10-29T13:26:27+00:00" "time": "2021-10-29T13:26:27+00:00"
}, },
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/ralouphie/getallheaders.git",
"reference": "120b605dfeb996808c31b6477290a714d356e822"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
"reference": "120b605dfeb996808c31b6477290a714d356e822",
"shasum": ""
},
"require": {
"php": ">=5.6"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.1",
"phpunit/phpunit": "^5 || ^6.5"
},
"type": "library",
"autoload": {
"files": [
"src/getallheaders.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ralph Khattar",
"email": "[email protected]"
}
],
"description": "A polyfill for getallheaders.",
"support": {
"issues": "https://github.com/ralouphie/getallheaders/issues",
"source": "https://github.com/ralouphie/getallheaders/tree/develop"
},
"time": "2019-03-08T08:55:37+00:00"
},
{ {
"name": "scienta/doctrine-json-functions", "name": "scienta/doctrine-json-functions",
"version": "6.5.0", "version": "6.5.0",
@@ -8760,6 +8716,86 @@
], ],
"time": "2026-05-27T06:59:30+00:00" "time": "2026-05-27T06:59:30+00:00"
}, },
{
"name": "symfony/polyfill-php82",
"version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php82.git",
"reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b",
"reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php82\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "[email protected]"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-26T12:45:58+00:00"
},
{ {
"name": "symfony/polyfill-php83", "name": "symfony/polyfill-php83",
"version": "v1.41.0", "version": "v1.41.0",
@@ -11358,16 +11394,16 @@
}, },
{ {
"name": "symfony/webpack-encore-bundle", "name": "symfony/webpack-encore-bundle",
"version": "v2.4.1", "version": "2.4.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/webpack-encore-bundle.git", "url": "https://github.com/symfony/webpack-encore-bundle.git",
"reference": "cac8d6c722999c8add9272f9de6e8079628df4f5" "reference": "0cbc3485f127cd9f85e395f8177607ef00535634"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/cac8d6c722999c8add9272f9de6e8079628df4f5", "url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/0cbc3485f127cd9f85e395f8177607ef00535634",
"reference": "cac8d6c722999c8add9272f9de6e8079628df4f5", "reference": "0cbc3485f127cd9f85e395f8177607ef00535634",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11410,7 +11446,7 @@
"description": "Integration of your Symfony app with Webpack Encore", "description": "Integration of your Symfony app with Webpack Encore",
"support": { "support": {
"issues": "https://github.com/symfony/webpack-encore-bundle/issues", "issues": "https://github.com/symfony/webpack-encore-bundle/issues",
"source": "https://github.com/symfony/webpack-encore-bundle/tree/v2.4.1" "source": "https://github.com/symfony/webpack-encore-bundle/tree/2.4.2"
}, },
"funding": [ "funding": [
{ {
@@ -11430,7 +11466,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-24T07:21:58+00:00" "time": "2026-09-17T12:45:02+00:00"
}, },
{ {
"name": "symfony/workflow", "name": "symfony/workflow",
@@ -11604,16 +11640,16 @@
}, },
{ {
"name": "twig/extra-bundle", "name": "twig/extra-bundle",
"version": "v3.24.0", "version": "v3.29.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/twig-extra-bundle.git", "url": "https://github.com/twigphp/twig-extra-bundle.git",
"reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9" "reference": "aaa2993e19293a99240c4c61aa461d743b0dd569"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/aaa2993e19293a99240c4c61aa461d743b0dd569",
"reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", "reference": "aaa2993e19293a99240c4c61aa461d743b0dd569",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11662,7 +11698,7 @@
"twig" "twig"
], ],
"support": { "support": {
"source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.24.0" "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.29.0"
}, },
"funding": [ "funding": [
{ {
@@ -11674,20 +11710,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-02-07T08:07:38+00:00" "time": "2026-09-11T08:59:50+00:00"
}, },
{ {
"name": "twig/html-extra", "name": "twig/html-extra",
"version": "v3.28.0", "version": "v3.29.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/html-extra.git", "url": "https://github.com/twigphp/html-extra.git",
"reference": "760893ed7bdd0a381e4e00004c6f6e26ad3881d7" "reference": "7147611979df81edb78baf7d003860b72e335c3f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/html-extra/zipball/760893ed7bdd0a381e4e00004c6f6e26ad3881d7", "url": "https://api.github.com/repos/twigphp/html-extra/zipball/7147611979df81edb78baf7d003860b72e335c3f",
"reference": "760893ed7bdd0a381e4e00004c6f6e26ad3881d7", "reference": "7147611979df81edb78baf7d003860b72e335c3f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11730,7 +11766,7 @@
"twig" "twig"
], ],
"support": { "support": {
"source": "https://github.com/twigphp/html-extra/tree/v3.28.0" "source": "https://github.com/twigphp/html-extra/tree/v3.29.0"
}, },
"funding": [ "funding": [
{ {
@@ -11742,20 +11778,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-25T06:50:01+00:00" "time": "2026-09-06T20:35:36+00:00"
}, },
{ {
"name": "twig/intl-extra", "name": "twig/intl-extra",
"version": "v3.26.0", "version": "v3.29.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/intl-extra.git", "url": "https://github.com/twigphp/intl-extra.git",
"reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9" "reference": "51c52470aca59f3a9715c88f6fe505133b88c752"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/intl-extra/zipball/98f5ad5bff13230fcd2d834d9e79b50adf3ccda9", "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/51c52470aca59f3a9715c88f6fe505133b88c752",
"reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9", "reference": "51c52470aca59f3a9715c88f6fe505133b88c752",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11794,7 +11830,7 @@
"twig" "twig"
], ],
"support": { "support": {
"source": "https://github.com/twigphp/intl-extra/tree/v3.26.0" "source": "https://github.com/twigphp/intl-extra/tree/v3.29.0"
}, },
"funding": [ "funding": [
{ {
@@ -11806,7 +11842,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-19T20:44:48+00:00" "time": "2026-09-06T20:35:36+00:00"
}, },
{ {
"name": "twig/string-extra", "name": "twig/string-extra",
@@ -11877,16 +11913,16 @@
}, },
{ {
"name": "twig/twig", "name": "twig/twig",
"version": "v3.28.0", "version": "v3.29.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/Twig.git", "url": "https://github.com/twigphp/Twig.git",
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" "reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", "url": "https://api.github.com/repos/twigphp/Twig/zipball/45a3c6e9224c3377a39c7b150bb29d5d97d2c75d",
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", "reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11941,7 +11977,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/twigphp/Twig/issues", "issues": "https://github.com/twigphp/Twig/issues",
"source": "https://github.com/twigphp/Twig/tree/v3.28.0" "source": "https://github.com/twigphp/Twig/tree/v3.29.0"
}, },
"funding": [ "funding": [
{ {
@@ -11953,7 +11989,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-07-03T20:44:34+00:00" "time": "2026-09-18T09:10:14+00:00"
}, },
{ {
"name": "ua-parser/uap-php", "name": "ua-parser/uap-php",
@@ -12672,16 +12708,16 @@
}, },
{ {
"name": "friendsofphp/php-cs-fixer", "name": "friendsofphp/php-cs-fixer",
"version": "v3.95.25", "version": "v3.95.26",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff" "reference": "11839dfaf25718e1617c522c3dde01ebf96c875f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/2cdfc1f3daf173d83a1ebb6177949b58c95de6ff", "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/11839dfaf25718e1617c522c3dde01ebf96c875f",
"reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff", "reference": "11839dfaf25718e1617c522c3dde01ebf96c875f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -12715,15 +12751,15 @@
"require-dev": { "require-dev": {
"facile-it/paraunit": "^1.3.1 || ^2.11.0", "facile-it/paraunit": "^1.3.1 || ^2.11.0",
"infection/infection": "^0.32.7", "infection/infection": "^0.32.7",
"justinrainbow/json-schema": "^6.10.0", "justinrainbow/json-schema": "^6.12.0",
"keradus/cli-executor": "^2.3", "keradus/cli-executor": "^2.3",
"php-coveralls/php-coveralls": "^2.9.1", "php-coveralls/php-coveralls": "^2.9.1",
"php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8",
"php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8",
"phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", "phpunit/phpunit": "^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.35 || ^13.3.3",
"symfony/polyfill-php85": "^1.38", "symfony/polyfill-php85": "^1.41",
"symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", "symfony/var-dumper": "^5.4.48 || ^6.4.45 || ^7.4.18 || ^8.1.7",
"symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1" "symfony/yaml": "^5.4.53 || ^6.4.45 || ^7.4.18 || ^8.1.6"
}, },
"suggest": { "suggest": {
"ext-dom": "For handling output formats in XML", "ext-dom": "For handling output formats in XML",
@@ -12764,7 +12800,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.25" "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.26"
}, },
"funding": [ "funding": [
{ {
@@ -12772,7 +12808,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-09-08T11:11:37+00:00" "time": "2026-09-19T19:04:32+00:00"
}, },
{ {
"name": "marcocesarato/php-conventional-changelog", "name": "marcocesarato/php-conventional-changelog",
+5
View File
@@ -5,6 +5,11 @@ framework:
http_method_override: false http_method_override: false
handle_all_throwables: true handle_all_throwables: true
# The OAuth2 redirect_uri handed to MyE&P is generated from the incoming request, so an
# unvalidated Host header would let a crafted request point the authorization code
# somewhere else. Regex patterns, matched against the host without the scheme or port.
trusted_hosts: ['%env(APP_TRUSTED_HOSTS)%']
# Enables session support. Note that the session will ONLY be started if you read or write from it. # Enables session support. Note that the session will ONLY be started if you read or write from it.
# Remove or comment this section to explicitly disable session support. # Remove or comment this section to explicitly disable session support.
session: session:
+1 -1
View File
@@ -9,7 +9,7 @@ security:
property: email property: email
role_hierarchy: role_hierarchy:
ROLE_ADMIN: [ ROLE_ADMINISTRATIVE ] ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ]
ROLE_MANAGER: [ ROLE_ADMINISTRATIVE ] ROLE_MANAGER: [ ROLE_ADMINISTRATIVE ]
firewalls: firewalls:
+44 -16
View File
@@ -12,23 +12,51 @@ zenstruck_schedule:
enabled: true enabled: true
to: [email protected] to: [email protected]
tasks: when@prod:
- task: app:bpn-import zenstruck_schedule:
frequency: '0 3 * * *' tasks:
description: "Imports dates, products and hotels from BusProNet" - task: app:bpn-import
frequency: '0 3 * * *'
description: "Imports dates, products and hotels from BusProNet"
- task: app:cron - task: app:cron
frequency: '0 1 * * *' frequency: '0 1 * * *'
description: "Executes several tasks triggered on a daily basis" description: "Executes several tasks triggered on a daily basis"
- task: app:teamer-status - task: app:teamer-status
frequency: '30 1 * * *' frequency: '30 1 * * *'
description: "Updates status of teamers depending on their disposition" description: "Updates status of teamers depending on their disposition"
- task: oneup:uploader:clear-orphans - task: oneup:uploader:clear-orphans
frequency: '30 * * * *' frequency: '30 * * * *'
description: "Removes orphaned uploads" description: "Removes orphaned uploads"
- task: app:cleanup:xml-dumps - task: app:cleanup:xml-dumps
frequency: "0 2 * * *" frequency: '0 2 * * *'
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging" description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
when@staging:
zenstruck_schedule:
mailer:
subject_prefix: "[E&P-Team STAGING]"
tasks:
- task: app:bpn-import
frequency: '30 3 * * *'
description: "Imports dates, products and hotels from BusProNet"
- task: app:cron
frequency: '30 2 * * *'
description: "Executes several tasks triggered on a daily basis"
- task: app:teamer-status
frequency: '30 1 * * *'
description: "Updates status of teamers depending on their disposition"
- task: oneup:uploader:clear-orphans
frequency: '30 * * * *'
description: "Removes orphaned uploads"
- task: app:cleanup:xml-dumps
frequency: '0 2 * * *'
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
+2 -2
View File
@@ -1,6 +1,6 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json # yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
parameters: parameters:
bpn_crm_id_admin: '%env(int:APP_BPN_CRM_ID_ADMIN)%' bpn_crm_id_team_admin: '%env(int:APP_BPN_CRM_ID_TEAM_ADMIN)%'
bpn_crm_id_manager: '%env(int:APP_BPN_CRM_ID_MANAGER)%' bpn_crm_id_manager: '%env(int:APP_BPN_CRM_ID_MANAGER)%'
bpn_crm_id_teamer: '%env(int:APP_BPN_CRM_ID_TEAMER)%' bpn_crm_id_teamer: '%env(int:APP_BPN_CRM_ID_TEAMER)%'
bpn_default_hotel_code: '%env(default::APP_BPN_DEFAULT_HOTEL_CODE)%' bpn_default_hotel_code: '%env(default::APP_BPN_DEFAULT_HOTEL_CODE)%'
@@ -162,7 +162,7 @@ services:
App\BusProNet\ResponseParser: App\BusProNet\ResponseParser:
arguments: arguments:
$options: $options:
bpn_crm_id_admin: '%bpn_crm_id_admin%' bpn_crm_id_team_admin: '%bpn_crm_id_team_admin%'
bpn_crm_id_manager: '%bpn_crm_id_manager%' bpn_crm_id_manager: '%bpn_crm_id_manager%'
bpn_crm_id_teamer: '%bpn_crm_id_teamer%' bpn_crm_id_teamer: '%bpn_crm_id_teamer%'
bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%' bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%'
+44 -12
View File
@@ -30,17 +30,35 @@ assign:
| Role | Label | Granted by | Revoked by | Hierarchy | | Role | Label | Granted by | Revoked by | Hierarchy |
|------|-------|-----------|------------|-----------| |------|-------|-----------|------------|-----------|
| `ROLE_ADMIN` | Admin | super admin, approving a CRM claim | the CRM, automatically | ⇒ `ROLE_ADMINISTRATIVE` | | `ROLE_TEAM_ADMIN` | Admin | super admin, approving a CRM claim | the CRM, automatically | ⇒ `ROLE_ADMINISTRATIVE` |
| `ROLE_MANAGER` | Reisemanager | super admin, approving a CRM claim | the CRM, automatically | ⇒ `ROLE_ADMINISTRATIVE` | | `ROLE_MANAGER` | Reisemanager | super admin, approving a CRM claim | the CRM, automatically | ⇒ `ROLE_ADMINISTRATIVE` |
| `ROLE_HOUSE_MANAGER` | Hausleitung | super admin, approving a CRM claim | the CRM, automatically | — | | `ROLE_HOUSE_MANAGER` | Hausleitung | super admin, approving a CRM claim | the CRM, automatically | — |
| `ROLE_TEAMER` | Teamer | the CRM, automatically | the CRM, automatically | — | | `ROLE_TEAMER` | Teamer | the CRM, automatically | the CRM, automatically | — |
### A note on the `TEAM_` prefix
`ROLE_TEAM_ADMIN`, `ROLE_TEAM_ADMIN_PENDING` and `ROLE_TEAM_SUPER_ADMIN` were renamed from
`ROLE_ADMIN`, `ROLE_ADMIN_PENDING` and `ROLE_SUPER_ADMIN`. The MyE&P identity provider is
shared with a sibling portal that uses `ROLE_ADMIN` for a different privilege, so the plain
name was ambiguous across the estate; the prefix makes it unambiguously *this* application's
admin. The German label is unchanged — it still reads "Admin" everywhere in the UI.
Two consequences worth remembering:
- **`ROLE_ADMINISTRATIVE` is a different role and was not renamed.** It is granted only by
the hierarchy, never stored, and it shares the old `ROLE_ADMIN` prefix — so any
search-and-replace over role names must match on a word boundary
(`ROLE_TEAM_ADMIN(?![A-Z_])`) or it will corrupt ~100 call sites silently.
- **`ELIGIBLE_ROLES` in `MyEpAuthenticator` is a wire contract**, not an internal name: it is
compared directly against the IdP's `roles` claim. It only works while MyE&P emits
`ROLE_TEAM_ADMIN`, so the two sides have to move together.
`User::PENDING_ROLES` holds a marker for each of the three administrative roles, keyed by `User::PENDING_ROLES` holds a marker for each of the three administrative roles, keyed by
the role it stands for: the role it stands for:
| Marker | Meaning | | Marker | Meaning |
|--------|---------| |--------|---------|
| `ROLE_ADMIN_PENDING` | the CRM claims this person is an admin, nobody has confirmed it | | `ROLE_TEAM_ADMIN_PENDING` | the CRM claims this person is an admin, nobody has confirmed it |
| `ROLE_MANAGER_PENDING` | likewise for Reisemanager | | `ROLE_MANAGER_PENDING` | likewise for Reisemanager |
| `ROLE_HOUSE_MANAGER_PENDING` | likewise for Hausleitung | | `ROLE_HOUSE_MANAGER_PENDING` | likewise for Hausleitung |
@@ -50,10 +68,10 @@ effects are cosmetic (rendered as "Admin (nicht freigeschaltet)") and organisati
put the user on the approval list). `ROLE_TEAMER` has no marker: it needs no approval. put the user on the approval list). `ROLE_TEAMER` has no marker: it needs no approval.
Two further roles are synthesized by `User::getRoles()` and never stored: `ROLE_USER` for Two further roles are synthesized by `User::getRoles()` and never stored: `ROLE_USER` for
everybody, and `ROLE_SUPER_ADMIN` when the separate `superAdmin` boolean column is set. A everybody, and `ROLE_TEAM_SUPER_ADMIN` when the separate `superAdmin` boolean column is set. A
validation callback (`User::validateSuperAdmin()`) refuses `superAdmin` without validation callback (`User::validateSuperAdmin()`) refuses `superAdmin` without
`ROLE_ADMIN` alongside it — super admin is an elevation, never a standalone grant. The sync `ROLE_TEAM_ADMIN` alongside it — super admin is an elevation, never a standalone grant. The sync
enforces the same rule from the other side: revoking `ROLE_ADMIN` clears the flag, or the one enforces the same rule from the other side: revoking `ROLE_TEAM_ADMIN` clears the flag, or the one
role that outranks every check in the application would outlive the role it depends on. role that outranks every check in the application would outlive the role it depends on.
### Storage and accessors ### Storage and accessors
@@ -63,7 +81,7 @@ slice it, and picking the right one matters:
| Accessor | Returns | | Accessor | Returns |
|----------|---------| |----------|---------|
| `getRoles()` | the column **plus** synthesized `ROLE_USER` / `ROLE_SUPER_ADMIN` — what Symfony authorises against | | `getRoles()` | the column **plus** synthesized `ROLE_USER` / `ROLE_TEAM_SUPER_ADMIN` — what Symfony authorises against |
| `getAssignedRoles()` | only the four real roles from the column — what the sync works on | | `getAssignedRoles()` | only the four real roles from the column — what the sync works on |
| `getPendingRoles()` | only the markers | | `getPendingRoles()` | only the markers |
| `getNominatedRoles()` | the roles behind those markers, as `role => label` — what an approver acts on | | `getNominatedRoles()` | the roles behind those markers, as `role => label` — what an approver acts on |
@@ -77,7 +95,7 @@ slice it, and picking the right one matters:
| CRM attribute | Recognised by | Sets | | CRM attribute | Recognised by | Sets |
|---------------|---------------|------| |---------------|---------------|------|
| admin | attribute id `%bpn_crm_id_admin%`, selected | `isAdmin` | | admin | attribute id `%bpn_crm_id_team_admin%`, selected | `isAdmin` |
| Reisemanager | attribute id `%bpn_crm_id_manager%`, selected | `isManager` | | Reisemanager | attribute id `%bpn_crm_id_manager%`, selected | `isManager` |
| teamer | attribute id `%bpn_crm_id_teamer%`, selected | `isTeamer` | | teamer | attribute id `%bpn_crm_id_teamer%`, selected | `isTeamer` |
| Hausleitung | attribute id listed in `%bpn_crm_house_manager_ids%`, selected | `isHouseManager` + the hotel code that id maps to | | Hausleitung | attribute id listed in `%bpn_crm_house_manager_ids%`, selected | `isHouseManager` + the hotel code that id maps to |
@@ -111,12 +129,26 @@ roles were revoked.
> >
> | Parameter | Attribute | > | Parameter | Attribute |
> |-----------|-----------| > |-----------|-----------|
> | `APP_BPN_CRM_ID_ADMIN` | `Admin` | > | `APP_BPN_CRM_ID_TEAM_ADMIN` | the team-admin selection — **not** the old portal-wide `Admin` (1292) |
> | `APP_BPN_CRM_ID_MANAGER` | `Manager` | > | `APP_BPN_CRM_ID_MANAGER` | `Manager` |
> | `APP_BPN_CRM_ID_TEAMER` | `E&P Teamer - allg. Merkmal` | > | `APP_BPN_CRM_ID_TEAMER` | `E&P Teamer - allg. Merkmal` |
> >
> Matching is by id and never by label, so `Preisrechner Admin` does not trip the admin flag. > Matching is by id and never by label, so `Preisrechner Admin` does not trip the admin flag.
> >
> `APP_BPN_CRM_ID_TEAM_ADMIN` was renamed from `APP_BPN_CRM_ID_ADMIN` with the
> `ROLE_TEAM_ADMIN` rename, and its **value has to change too**. The old value 1292 is the
> portal-wide admin selection, which still means `ROLE_ADMIN` in the sibling portal — keeping
> it would have left this app granting its admin off the very selection the rename was meant
> to stop sharing. It ships as `0` — a valid int that matches no attribute, so the container
> boots but nobody is granted the role — and must be set to the new selection's id.
>
> **This is a hard cutover.** `revokeUnclaimedRoles()` withdraws any granted role the CRM no
> longer claims, and `revokeSuperAdminWithoutRoleAdmin()` takes the super admin flag down with
> `ROLE_TEAM_ADMIN`. So the new selection must exist **and already be assigned to every admin**
> in BusPro before this is deployed; otherwise each of them is demoted on their next login and
> needs a super admin to re-approve. Admins who also hold `ROLE_TEAMER` degrade to teamer
> access; an admin without it is blocked outright by `disableForRevokedCrmRoles()`.
>
> `bpn_crm_house_manager_ids` (`config/services.yaml`) is deployment-critical for the same > `bpn_crm_house_manager_ids` (`config/services.yaml`) is deployment-critical for the same
> reason, and more sharply so: since roles are synced, an id missing from that map does not > reason, and more sharply so: since roles are synced, an id missing from that map does not
> merely fail to nominate a Hausleitung, it **revokes** the role from everyone holding it, one > merely fail to nominate a Hausleitung, it **revokes** the role from everyone holding it, one
@@ -137,7 +169,7 @@ roles were revoked.
`BpnAuthenticator::getOrCreateLocalUser()``UserDataHandler::createLocalUser()` writes `BpnAuthenticator::getOrCreateLocalUser()``UserDataHandler::createLocalUser()` writes
`collectRoles()` verbatim, together with the hotel codes from the Hausleitung attributes. `collectRoles()` verbatim, together with the hotel codes from the Hausleitung attributes.
A CRM admin who is not also a teamer therefore starts with `['ROLE_ADMIN_PENDING']` and no A CRM admin who is not also a teamer therefore starts with `['ROLE_TEAM_ADMIN_PENDING']` and no
privileges at all: they can authenticate, but `UserChecker` refuses the session until a privileges at all: they can authenticate, but `UserChecker` refuses the session until a
super admin approves them. super admin approves them.
@@ -148,7 +180,7 @@ the roles to **`syncRoles()`**, which is the whole policy in four steps:
1. **revoke** every granted role the CRM no longer claims. This is what makes BusPro the 1. **revoke** every granted role the CRM no longer claims. This is what makes BusPro the
source of truth, and it applies to `ROLE_TEAMER` as much as to the administrative roles. source of truth, and it applies to `ROLE_TEAMER` as much as to the administrative roles.
2. **clear the super admin flag** when `ROLE_ADMIN` was among them — `ROLE_SUPER_ADMIN` is 2. **clear the super admin flag** when `ROLE_TEAM_ADMIN` was among them — `ROLE_TEAM_SUPER_ADMIN` is
synthesized from a separate column and would otherwise survive its own precondition. synthesized from a separate column and would otherwise survive its own precondition.
3. **`refreshPendingRoles()`** recomputes the marker set from the current claims. A marker 3. **`refreshPendingRoles()`** recomputes the marker set from the current claims. A marker
whose real role is already granted is dropped — an approved role is never marked again. whose real role is already granted is dropped — an approved role is never marked again.
@@ -189,7 +221,7 @@ runs again on submit to catch a sync that revoked the claim while the dialog was
A denial is not recorded anywhere: as long as the CRM keeps claiming the role, the A denial is not recorded anywhere: as long as the CRM keeps claiming the role, the
nomination is back on the next login. nomination is back on the next login.
**Super admin** is only offered to somebody who already holds `ROLE_ADMIN` — approve first, **Super admin** is only offered to somebody who already holds `ROLE_TEAM_ADMIN` — approve first,
elevate afterwards. The one exception is a flag that outlived its role, which stays editable elevate afterwards. The one exception is a flag that outlived its role, which stays editable
so the account can be saved at all while `User::validateSuperAdmin()` is violated; the sync so the account can be saved at all while `User::validateSuperAdmin()` is violated; the sync
clears it (see 2), so it should never occur in practice. clears it (see 2), so it should never occur in practice.
@@ -243,7 +275,7 @@ administrative users, teamers are an admin's business:
| Surface | Who | Notes | | Surface | Who | Notes |
|---------|-----|-------| |---------|-----|-------|
| `/admin/teamer/disable-user/{uuid}` and `/administrative/teamer/enable-user/{uuid}` | `ROLE_ADMIN` | teamers; public reason mandatory, internal optional | | `/admin/teamer/disable-user/{uuid}` and `/administrative/teamer/enable-user/{uuid}` | `ROLE_TEAM_ADMIN` | teamers; public reason mandatory, internal optional |
| "Account gesperrt" checkbox on the user edit form | super admin (`UserVoter`) | everyone else; both reasons optional | | "Account gesperrt" checkbox on the user edit form | super admin (`UserVoter`) | everyone else; both reasons optional |
Both go through `User::setDisabled()`, which is a no-op when the state is unchanged — saving Both go through `User::setDisabled()`, which is a no-op when the state is unchanged — saving
+24 -5
View File
@@ -162,17 +162,36 @@ class ApiClient
$this->config['max_retries'] $this->config['max_retries']
); );
$this->send($socket, $body); $this->send($socket, $body);
$response = $this->receive($socket);
$this->disconnect($socket);
// message length (10 bytes) is prepended to actual message try {
$xml = substr($response, 10); // the length header is consumed by receive(), this is the payload
$xml = $this->receive($socket);
} finally {
$this->disconnect($socket);
}
if (true === $this->config['debug']) { if (true === $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml); $this->dumpXmlToFile('response', $requestId, $xml);
} }
return $this->responseParser->parseXmlString($type, $xml); try {
return $this->responseParser->parseXmlString($type, $xml);
} catch (ResponseParserException $e) {
// Keep the evidence even outside debug mode: without the raw response a parse
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
if (true !== $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml);
}
$this->logger->error('Unable to parse BusProNet response', [
'request_id' => $requestId,
'type' => $type,
'response_length' => strlen($xml),
'error_message' => $e->getMessage(),
]);
throw $e;
}
} }
private function dumpXmlToFile(string $type, string $requestId, string $body): void private function dumpXmlToFile(string $type, string $requestId, string $body): void
+47 -4
View File
@@ -58,15 +58,58 @@ trait ApiClientTrait
fwrite($socket, $send); fwrite($socket, $send);
} }
/**
* Reads a single response message. The protocol prepends the payload length as a
* 10 byte header, so read exactly that many bytes rather than guessing at EOF:
* a peer that closes mid-stream would otherwise yield a silently truncated body.
*
* @throws ApiClientException
*/
private function receive($socket): string private function receive($socket): string
{ {
$response = ''; $header = $this->readBytes($socket, 10);
while (false === feof($socket)) { if (10 !== strlen($header)) {
$response .= fread($socket, 4096); throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($header)));
} }
return $response; $expectedLength = (int) trim($header);
if (1 > $expectedLength) {
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
}
$body = $this->readBytes($socket, $expectedLength);
if (strlen($body) !== $expectedLength) {
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($body), $expectedLength));
}
return $body;
}
/**
* @throws ApiClientException
*/
private function readBytes($socket, int $length): string
{
$buffer = '';
while (strlen($buffer) < $length && false === feof($socket)) {
$chunk = fread($socket, min(4096, $length - strlen($buffer)));
if (false === $chunk || '' === $chunk) {
if (true === (stream_get_meta_data($socket)['timed_out'] ?? false)) {
throw new ApiClientException(sprintf('Timed out reading response after %d of %d bytes', strlen($buffer), $length));
}
break;
}
$buffer .= $chunk;
}
return $buffer;
} }
private function disconnect($socket): void private function disconnect($socket): void
@@ -6,6 +6,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException; use App\BusProNet\ApiClientException;
use App\BusProNet\Model\Country; use App\BusProNet\Model\Country;
use App\BusProNet\Model\NotificationResponse; use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\ResponseParserException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
@@ -35,7 +36,8 @@ class CountryDataProvider
return $response->getItems(); return $response->getItems();
}); });
} catch (ApiClientException $e) { } catch (ApiClientException|ResponseParserException $e) {
$this->logger->error('Unable to fetch country base data from BusProNet: '.$e->getMessage());
$countries = []; $countries = [];
} }
@@ -6,6 +6,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException; use App\BusProNet\ApiClientException;
use App\BusProNet\Model\Hotel; use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\NotificationResponse; use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\ResponseParserException;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
@@ -36,7 +37,8 @@ class HotelDataProvider
return $response->getItems(); return $response->getItems();
}); });
} catch (ApiClientException|InvalidArgumentException $e) { } catch (ApiClientException|InvalidArgumentException|ResponseParserException $e) {
$this->logger->error('Unable to fetch hotel base data from BusProNet: '.$e->getMessage());
$hotels = []; $hotels = [];
} }
@@ -6,6 +6,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException; use App\BusProNet\ApiClientException;
use App\BusProNet\Model\NotificationResponse; use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Pickup;
use App\BusProNet\ResponseParserException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
@@ -35,7 +36,8 @@ class PickupDataProvider
return $response->getItems(); return $response->getItems();
}); });
} catch (ApiClientException $e) { } catch (ApiClientException|ResponseParserException $e) {
$this->logger->error('Unable to fetch pickup base data from BusProNet: '.$e->getMessage());
$pickups = []; $pickups = [];
} }
+3 -1
View File
@@ -39,7 +39,9 @@ class Communication
public function setEmail(?string $email): static public function setEmail(?string $email): static
{ {
$this->email = $email; // BusPro compares addresses case-insensitively; normalize on the way in so the DTO
// carries the same shape the entities store.
$this->email = null === $email ? null : mb_strtolower(trim($email));
return $this; return $this;
} }
+49 -8
View File
@@ -30,10 +30,7 @@ class ResponseParser
*/ */
public function parseXmlString(string $type, string $content): mixed public function parseXmlString(string $type, string $content): mixed
{ {
$xml = simplexml_load_string($content); $xml = $this->loadXml($content);
if (false === $xml) {
throw new ResponseParserException('Unable to parse XML response');
}
// Override type when present in XML to catch error responses // Override type when present in XML to catch error responses
$responseType = $type; $responseType = $type;
@@ -65,7 +62,7 @@ class ResponseParser
return $this->createHotelsResponse($xml); return $this->createHotelsResponse($xml);
} }
throw new ResponseParserException('Unable to parse XML response'); throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
} }
public function createNotificationResponse(\SimpleXMLElement $xml): NotificationResponse public function createNotificationResponse(\SimpleXMLElement $xml): NotificationResponse
@@ -200,7 +197,7 @@ class ResponseParser
$isHouseManager = true; $isHouseManager = true;
$hotelCodes[] = $houseManagerCode; $hotelCodes[] = $houseManagerCode;
} }
if ($this->config['bpn_crm_id_admin'] === $attribute->getId() && true === $attribute->isSelected()) { if ($this->config['bpn_crm_id_team_admin'] === $attribute->getId() && true === $attribute->isSelected()) {
$isAdmin = true; $isAdmin = true;
} }
if ($this->config['bpn_crm_id_manager'] === $attribute->getId() && true === $attribute->isSelected()) { if ($this->config['bpn_crm_id_manager'] === $attribute->getId() && true === $attribute->isSelected()) {
@@ -302,14 +299,58 @@ class ResponseParser
return new BaseDataResponse($hotels); return new BaseDataResponse($hotels);
} }
/**
* The BusProNet endpoint can answer with an empty or truncated body. Keep the libxml
* reason instead of collapsing every shape of broken response into one message.
*
* @throws ResponseParserException
*/
private function loadXml(string $content): \SimpleXMLElement
{
if ('' === trim($content)) {
throw new ResponseParserException(sprintf('Unable to parse XML response (%d bytes): empty response', strlen($content)));
}
$previousUseErrors = libxml_use_internal_errors(true);
libxml_clear_errors();
try {
$xml = simplexml_load_string($content);
if (false === $xml) {
throw new ResponseParserException(sprintf('Unable to parse XML response (%d bytes): %s', strlen($content), $this->describeLibxmlErrors()));
}
return $xml;
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previousUseErrors);
}
}
private function describeLibxmlErrors(): string
{
$messages = [];
foreach (libxml_get_errors() as $error) {
$messages[] = sprintf('%s (line %d, column %d)', trim($error->message), $error->line, $error->column);
}
if ([] === $messages) {
return 'unknown XML error';
}
return implode('; ', array_unique($messages));
}
private function resolveOptions(array $options): array private function resolveOptions(array $options): array
{ {
$optionsResolver = new OptionsResolver(); $optionsResolver = new OptionsResolver();
$optionsResolver->setRequired(['bpn_crm_id_admin', 'bpn_crm_id_manager', 'bpn_crm_id_teamer', 'bpn_crm_house_manager_ids']); $optionsResolver->setRequired(['bpn_crm_id_team_admin', 'bpn_crm_id_manager', 'bpn_crm_id_teamer', 'bpn_crm_house_manager_ids']);
$optionsResolver->setDefaults([ $optionsResolver->setDefaults([
'bpn_default_hotel_code' => null, 'bpn_default_hotel_code' => null,
]); ]);
$optionsResolver->setAllowedTypes('bpn_crm_id_admin', 'int'); $optionsResolver->setAllowedTypes('bpn_crm_id_team_admin', 'int');
$optionsResolver->setAllowedTypes('bpn_crm_id_manager', 'int'); $optionsResolver->setAllowedTypes('bpn_crm_id_manager', 'int');
$optionsResolver->setAllowedTypes('bpn_crm_id_teamer', 'int'); $optionsResolver->setAllowedTypes('bpn_crm_id_teamer', 'int');
$optionsResolver->setAllowedTypes('bpn_crm_house_manager_ids', 'array'); $optionsResolver->setAllowedTypes('bpn_crm_house_manager_ids', 'array');
+7 -7
View File
@@ -69,7 +69,7 @@ class UserDataHandler
$claimedRoles = []; $claimedRoles = [];
if ($crmAttributes->isAdmin()) { if ($crmAttributes->isAdmin()) {
$claimedRoles[] = 'ROLE_ADMIN'; $claimedRoles[] = 'ROLE_TEAM_ADMIN';
} }
if ($crmAttributes->isManager()) { if ($crmAttributes->isManager()) {
@@ -370,7 +370,7 @@ class UserDataHandler
* The whole policy, in the order it has to run: * The whole policy, in the order it has to run:
* *
* 1. revoke what is no longer claimed - the identity source leads; * 1. revoke what is no longer claimed - the identity source leads;
* 2. drop the super admin flag along with ROLE_ADMIN, or the highest privilege in the * 2. drop the super admin flag along with ROLE_TEAM_ADMIN, or the highest privilege in the
* application would outlive the role it depends on; * application would outlive the role it depends on;
* 3. refresh the pending markers, after the revocation so that a role just revoked is * 3. refresh the pending markers, after the revocation so that a role just revoked is
* not immediately marked again - it is unclaimed in both steps; * not immediately marked again - it is unclaimed in both steps;
@@ -424,7 +424,7 @@ class UserDataHandler
* *
* Only the roles of User::ROLES are touched: getAssignedRoles() excludes the pending * Only the roles of User::ROLES are touched: getAssignedRoles() excludes the pending
* markers as well as the implicit ROLE_USER, and the markers are dealt with by * markers as well as the implicit ROLE_USER, and the markers are dealt with by
* refreshPendingRoles(). ROLE_SUPER_ADMIN is not a stored role at all but a flag, so * refreshPendingRoles(). ROLE_TEAM_SUPER_ADMIN is not a stored role at all but a flag, so
* it is handled separately below. * it is handled separately below.
* *
* @param string[] $claimedRoles * @param string[] $claimedRoles
@@ -450,22 +450,22 @@ class UserDataHandler
} }
/** /**
* Takes the super admin flag down with ROLE_ADMIN. * Takes the super admin flag down with ROLE_TEAM_ADMIN.
* *
* The flag is stored on its own and getRoles() turns it into ROLE_SUPER_ADMIN whatever * The flag is stored on its own and getRoles() turns it into ROLE_TEAM_SUPER_ADMIN whatever
* else the user holds, so without this a person the CRM no longer calls an admin would * else the user holds, so without this a person the CRM no longer calls an admin would
* keep the one role that outranks every check in the application. User::validateSuperAdmin() * keep the one role that outranks every check in the application. User::validateSuperAdmin()
* enforces the same rule on the edit form, but only there. * enforces the same rule on the edit form, but only there.
*/ */
private function revokeSuperAdminWithoutRoleAdmin(User $user): void private function revokeSuperAdminWithoutRoleAdmin(User $user): void
{ {
if (false === $user->isSuperAdmin() || true === $user->hasRole('ROLE_ADMIN')) { if (false === $user->isSuperAdmin() || true === $user->hasRole('ROLE_TEAM_ADMIN')) {
return; return;
} }
$user->setSuperAdmin(false); $user->setSuperAdmin(false);
$this->logger->info('Revoke super admin flag along with ROLE_ADMIN', [ $this->logger->info('Revoke super admin flag along with ROLE_TEAM_ADMIN', [
'user_id' => $user->getId(), 'user_id' => $user->getId(),
'user_email' => $user->getEmail(), 'user_email' => $user->getEmail(),
]); ]);
+21 -2
View File
@@ -56,11 +56,30 @@ class BpnImportCommand extends Command
return Command::FAILURE; return Command::FAILURE;
} }
// Resolve the BusProNet base data once, before touching a single row. A destination
// cannot be written without its hotel, so an unavailable hotel list would otherwise
// skip every record and still report success. Holding both lists locally also keeps
// the loop off the providers: on a failed fetch nothing is cached, and a per-lookup
// ->get() would re-open the socket for every pickup of all 174 files.
$hotels = $this->hotelDataProvider->getAll();
$pickups = $this->pickupDataProvider->getAll();
if ([] === $hotels) {
$io->error('Hotel-Stammdaten konnten nicht von BusProNet geladen werden Import abgebrochen.');
$this->logger->error('BPN import aborted: hotel base data unavailable');
return Command::FAILURE;
}
$io->info('Found '.$totalCount.' XML files'); $io->info('Found '.$totalCount.' XML files');
$addedCount = 0; $addedCount = 0;
$updatedCount = 0; $updatedCount = 0;
$warnings = []; $warnings = [];
if ([] === $pickups) {
$warnings[] = 'Zustiegs-Stammdaten konnten nicht von BusProNet geladen werden';
}
$progressBar = $io->createProgressBar($totalCount); $progressBar = $io->createProgressBar($totalCount);
$progressBar->setFormat(" %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%\n %message%"); $progressBar->setFormat(" %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%\n %message%");
$progressBar->setMessage('Starting'); $progressBar->setMessage('Starting');
@@ -92,7 +111,7 @@ class BpnImportCommand extends Command
foreach ($destinationXml->xpath('zustiege/zustieg') as $pickupXml) { foreach ($destinationXml->xpath('zustiege/zustieg') as $pickupXml) {
$pickupBusProId = (int) $pickupXml->attributes()['idbuspro']; $pickupBusProId = (int) $pickupXml->attributes()['idbuspro'];
if ($pickupBusProId) { if ($pickupBusProId) {
if (null === $pickup = $this->pickupDataProvider->get($pickupBusProId)) { if (null === $pickup = $pickups[$pickupBusProId] ?? null) {
$warnings[] = 'Pickup with busProId '.$pickupBusProId.' not found'; $warnings[] = 'Pickup with busProId '.$pickupBusProId.' not found';
continue; continue;
} }
@@ -109,7 +128,7 @@ class BpnImportCommand extends Command
// Iterate over all hotel entries // Iterate over all hotel entries
foreach ($destinationXml->xpath('hotel') as $hotelXml) { foreach ($destinationXml->xpath('hotel') as $hotelXml) {
$hotelBusProId = (int) $hotelXml->attributes()['idbuspro']; $hotelBusProId = (int) $hotelXml->attributes()['idbuspro'];
if (null === $hotel = $this->hotelDataProvider->get($hotelBusProId)) { if (null === $hotel = $hotels[$hotelBusProId] ?? null) {
$warnings[] = 'Hotel with busProId '.$hotelBusProId.' not found'; $warnings[] = 'Hotel with busProId '.$hotelBusProId.' not found';
continue; continue;
} }
+2 -4
View File
@@ -91,10 +91,8 @@ class TeamerStatusCommand extends Command
$qb->expr()->eq('disposition.status', ':disposition_status') $qb->expr()->eq('disposition.status', ':disposition_status')
)) ))
->groupBy('teamer.id') ->groupBy('teamer.id')
->setParameters([ ->setParameter('teamer_status', Teamer::STATUS_NEW)
'teamer_status' => Teamer::STATUS_NEW, ->setParameter('disposition_status', Disposition::STATUS_COMPLETED)
'disposition_status' => Disposition::STATUS_COMPLETED,
])
->getQuery() ->getQuery()
->getResult() ->getResult()
; ;
@@ -16,7 +16,7 @@ class UserController extends AbstractController
} }
#[Route('/admin/autocomplete/user', name: 'app_admin_autocomplete_user')] #[Route('/admin/autocomplete/user', name: 'app_admin_autocomplete_user')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
try { try {
@@ -22,7 +22,7 @@ class ApproveController extends AbstractController
} }
#[Route('/admin/feedback/approve/{uuid}', name: 'app_admin_feedback_approve')] #[Route('/admin/feedback/approve/{uuid}', name: 'app_admin_feedback_approve')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Feedback $feedback, Request $request): Response public function index(Feedback $feedback, Request $request): Response
{ {
$form = $this->createForm(FeedbackApproveType::class, $feedback); $form = $this->createForm(FeedbackApproveType::class, $feedback);
@@ -24,7 +24,7 @@ class ProvideController extends AbstractController
} }
#[Route('/admin/feedback/provide', name: 'app_admin_feedback_provide')] #[Route('/admin/feedback/provide', name: 'app_admin_feedback_provide')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response public function index(Request $request): Response
{ {
$form = $this->getFeedbackForm(); $form = $this->getFeedbackForm();
@@ -58,7 +58,7 @@ class ProvideController extends AbstractController
} }
#[Route('/admin/feedback/provide/form', name: 'app_admin_feedback_provide_form')] #[Route('/admin/feedback/provide/form', name: 'app_admin_feedback_provide_form')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function form(Request $request): Response public function form(Request $request): Response
{ {
$form = $this->getFeedbackForm(); $form = $this->getFeedbackForm();
+1 -1
View File
@@ -21,7 +21,7 @@ class IndexController extends AbstractController
} }
#[Route('/admin', name: 'app_admin_index')] #[Route('/admin', name: 'app_admin_index')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response public function index(): Response
{ {
$applicationRepository = $this->entityManager->getRepository(Application::class); $applicationRepository = $this->entityManager->getRepository(Application::class);
+1 -1
View File
@@ -20,7 +20,7 @@ class IndexController extends AbstractController
} }
#[Route('/admin/log', name: 'app_admin_log_index')] #[Route('/admin/log', name: 'app_admin_log_index')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response public function index(Request $request): Response
{ {
$qb = $this $qb = $this
@@ -22,7 +22,7 @@ class CreateController extends AbstractController
} }
#[Route('/admin/system/availability/create', name: 'app_admin_system_availability_create')] #[Route('/admin/system/availability/create', name: 'app_admin_system_availability_create')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response public function index(Request $request): Response
{ {
$availability = new Availability(); $availability = new Availability();
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
} }
#[Route('/admin/system/availability/delete/{id}', name: 'app_admin_system_availability_delete', methods: ['POST'])] #[Route('/admin/system/availability/delete/{id}', name: 'app_admin_system_availability_delete', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Availability $availability, Request $request): Response public function index(Availability $availability, Request $request): Response
{ {
if (true === $request->isMethod('POST')) { if (true === $request->isMethod('POST')) {
@@ -22,7 +22,7 @@ class DuplicateController extends AbstractController
} }
#[Route('/admin/system/availability/duplicate/{id}', name: 'app_admin_system_availability_duplicate')] #[Route('/admin/system/availability/duplicate/{id}', name: 'app_admin_system_availability_duplicate')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Availability $availability, Request $request): Response public function index(Availability $availability, Request $request): Response
{ {
$copy = Availability::duplicate($availability); $copy = Availability::duplicate($availability);
@@ -22,7 +22,7 @@ class EditController extends AbstractController
} }
#[Route('/admin/system/availability/edit/{id}', name: 'app_admin_system_availability_edit')] #[Route('/admin/system/availability/edit/{id}', name: 'app_admin_system_availability_edit')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Availability $availability, Request $request): Response public function index(Availability $availability, Request $request): Response
{ {
$form = $this->createForm(AvailabilityType::class, $availability); $form = $this->createForm(AvailabilityType::class, $availability);
@@ -19,7 +19,7 @@ class IndexController extends AbstractController
} }
#[Route('/admin/system/availability', name: 'app_admin_system_availability_index')] #[Route('/admin/system/availability', name: 'app_admin_system_availability_index')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response public function index(Request $request): Response
{ {
$query = $this $query = $this
@@ -28,7 +28,7 @@ class EditController extends AbstractController
} }
#[Route('/admin/system/email-text/edit/{key}', name: 'app_admin_system_email_text_edit')] #[Route('/admin/system/email-text/edit/{key}', name: 'app_admin_system_email_text_edit')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(EmailTextKey $key, Request $request): Response public function index(EmailTextKey $key, Request $request): Response
{ {
$definition = $this->catalog->get($key); $definition = $this->catalog->get($key);
@@ -18,7 +18,7 @@ class IndexController extends AbstractController
} }
#[Route('/admin/system/email-text', name: 'app_admin_system_email_text_index')] #[Route('/admin/system/email-text', name: 'app_admin_system_email_text_index')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response public function index(): Response
{ {
// The list is driven by the catalogue, not by the table: a mail that has never // The list is driven by the catalogue, not by the table: a mail that has never
@@ -25,7 +25,7 @@ class PreviewController extends AbstractController
* a real mail is being assembled. * a real mail is being assembled.
*/ */
#[Route('/admin/system/email-text/preview/{key}', name: 'app_admin_system_email_text_preview')] #[Route('/admin/system/email-text/preview/{key}', name: 'app_admin_system_email_text_preview')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(EmailTextKey $key): Response public function index(EmailTextKey $key): Response
{ {
$definition = $this->catalog->get($key); $definition = $this->catalog->get($key);
@@ -27,7 +27,7 @@ class PreviewDraftController extends AbstractController
* the submitted values instead of the stored ones. * the submitted values instead of the stored ones.
*/ */
#[Route('/admin/system/email-text/preview-draft/{key}', name: 'app_admin_system_email_text_preview_draft', methods: ['POST'])] #[Route('/admin/system/email-text/preview-draft/{key}', name: 'app_admin_system_email_text_preview_draft', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(EmailTextKey $key, Request $request): Response public function index(EmailTextKey $key, Request $request): Response
{ {
$definition = $this->catalog->get($key); $definition = $this->catalog->get($key);
@@ -25,7 +25,7 @@ class ResetController extends AbstractController
} }
#[Route('/admin/system/email-text/reset/{key}', name: 'app_admin_system_email_text_reset')] #[Route('/admin/system/email-text/reset/{key}', name: 'app_admin_system_email_text_reset')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(EmailTextKey $key, Request $request): Response public function index(EmailTextKey $key, Request $request): Response
{ {
$emailText = $this->emailTextRepository->findByKey($key); $emailText = $this->emailTextRepository->findByKey($key);
@@ -22,7 +22,7 @@ class CreateController extends AbstractController
} }
#[Route('/admin/system/fee/create', name: 'app_admin_system_fee_create')] #[Route('/admin/system/fee/create', name: 'app_admin_system_fee_create')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response public function index(Request $request): Response
{ {
$fee = new Fee(); $fee = new Fee();
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
} }
#[Route('/admin/system/fee/delete/{id}', name: 'app_admin_system_fee_delete')] #[Route('/admin/system/fee/delete/{id}', name: 'app_admin_system_fee_delete')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Fee $fee, Request $request): Response public function index(Fee $fee, Request $request): Response
{ {
if (true === $request->isMethod('POST')) { if (true === $request->isMethod('POST')) {
@@ -22,7 +22,7 @@ class DuplicateController extends AbstractController
} }
#[Route('/admin/system/fee/duplicate/{id}', name: 'app_admin_system_fee_duplicate')] #[Route('/admin/system/fee/duplicate/{id}', name: 'app_admin_system_fee_duplicate')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Fee $fee, Request $request): Response public function index(Fee $fee, Request $request): Response
{ {
$copy = Fee::duplicate($fee); $copy = Fee::duplicate($fee);
@@ -22,7 +22,7 @@ class EditController extends AbstractController
} }
#[Route('/admin/system/fee/edit/{id}', name: 'app_admin_system_fee_edit')] #[Route('/admin/system/fee/edit/{id}', name: 'app_admin_system_fee_edit')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Fee $fee, Request $request): Response public function index(Fee $fee, Request $request): Response
{ {
$form = $this->createForm(FeeType::class, $fee); $form = $this->createForm(FeeType::class, $fee);
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
} }
#[Route('/admin/system/fee', name: 'app_admin_system_fee_index')] #[Route('/admin/system/fee', name: 'app_admin_system_fee_index')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response public function index(): Response
{ {
$fees = $this->feeRepository->getList(); $fees = $this->feeRepository->getList();
@@ -21,7 +21,7 @@ class CreateController extends AbstractController
} }
#[Route('/admin/system/feedback-set/create', name: 'app_admin_system_feedback_set_create')] #[Route('/admin/system/feedback-set/create', name: 'app_admin_system_feedback_set_create')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response public function index(Request $request): Response
{ {
$feedbackSet = new FeedbackSet(); $feedbackSet = new FeedbackSet();
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
} }
#[Route('/admin/system/feedback-set/delete/{id}', name: 'app_admin_system_feedback_set_delete')] #[Route('/admin/system/feedback-set/delete/{id}', name: 'app_admin_system_feedback_set_delete')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
#[IsGranted('DELETE', subject: 'feedbackSet')] #[IsGranted('DELETE', subject: 'feedbackSet')]
public function index(FeedbackSet $feedbackSet, Request $request): Response public function index(FeedbackSet $feedbackSet, Request $request): Response
{ {
@@ -21,7 +21,7 @@ class EditController extends AbstractController
} }
#[Route('/admin/system/feedback-set/edit/{id}', name: 'app_admin_system_feedback_set_edit')] #[Route('/admin/system/feedback-set/edit/{id}', name: 'app_admin_system_feedback_set_edit')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(FeedbackSet $feedbackSet, Request $request): Response public function index(FeedbackSet $feedbackSet, Request $request): Response
{ {
$form = $this->createForm(FeedbackSetType::class, $feedbackSet); $form = $this->createForm(FeedbackSetType::class, $feedbackSet);
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
} }
#[Route('/admin/system/feedback-set', name: 'app_admin_system_feedback_set_index')] #[Route('/admin/system/feedback-set', name: 'app_admin_system_feedback_set_index')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response public function index(): Response
{ {
$feedbackSets = $this $feedbackSets = $this
@@ -21,7 +21,7 @@ class CreateController extends AbstractController
} }
#[Route('/admin/system/job-profile/create', name: 'app_admin_system_job_profile_create')] #[Route('/admin/system/job-profile/create', name: 'app_admin_system_job_profile_create')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response public function index(Request $request): Response
{ {
$jobProfile = new JobProfile(); $jobProfile = new JobProfile();
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
} }
#[Route('/admin/system/job-profile/delete/{id}', name: 'app_admin_system_job_profile_delete')] #[Route('/admin/system/job-profile/delete/{id}', name: 'app_admin_system_job_profile_delete')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(JobProfile $jobProfile, Request $request): Response public function index(JobProfile $jobProfile, Request $request): Response
{ {
if (true === $request->isMethod('POST')) { if (true === $request->isMethod('POST')) {
@@ -21,7 +21,7 @@ class EditController extends AbstractController
} }
#[Route('/admin/system/job-profile/edit/{id}', name: 'app_admin_system_job_profile_edit')] #[Route('/admin/system/job-profile/edit/{id}', name: 'app_admin_system_job_profile_edit')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(JobProfile $jobProfile, Request $request): Response public function index(JobProfile $jobProfile, Request $request): Response
{ {
$form = $this->createForm(JobProfileType::class, $jobProfile); $form = $this->createForm(JobProfileType::class, $jobProfile);
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
} }
#[Route('/admin/system/job-profile', name: 'app_admin_system_job_profile_index')] #[Route('/admin/system/job-profile', name: 'app_admin_system_job_profile_index')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response public function index(): Response
{ {
$jobProfiles = $this $jobProfiles = $this
@@ -22,7 +22,7 @@ class CreateController extends AbstractController
} }
#[Route('/admin/system/training/create', name: 'app_admin_system_training_create')] #[Route('/admin/system/training/create', name: 'app_admin_system_training_create')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response public function index(Request $request): Response
{ {
$training = new Training(); $training = new Training();
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
} }
#[Route('/admin/system/training/delete/{id}', name: 'app_admin_system_training_delete')] #[Route('/admin/system/training/delete/{id}', name: 'app_admin_system_training_delete')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Training $training, Request $request): Response public function index(Training $training, Request $request): Response
{ {
if (true === $request->isMethod('POST')) { if (true === $request->isMethod('POST')) {
@@ -22,7 +22,7 @@ class EditController extends AbstractController
} }
#[Route('/admin/system/training/edit/{id}', name: 'app_admin_system_training_edit')] #[Route('/admin/system/training/edit/{id}', name: 'app_admin_system_training_edit')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Training $training, Request $request): Response public function index(Training $training, Request $request): Response
{ {
$form = $this->createForm(TrainingType::class, $training); $form = $this->createForm(TrainingType::class, $training);
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
} }
#[Route('/admin/system/training', name: 'app_admin_system_training_index')] #[Route('/admin/system/training', name: 'app_admin_system_training_index')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response public function index(): Response
{ {
$trainings = $this $trainings = $this
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
} }
#[Route('/admin/system/user', name: 'app_admin_system_user_index')] #[Route('/admin/system/user', name: 'app_admin_system_user_index')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response public function index(): Response
{ {
$users = $this->userRepository->getAdministrativeUsers(); $users = $this->userRepository->getAdministrativeUsers();
@@ -11,7 +11,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class CrmSelectionsController extends AbstractController class CrmSelectionsController extends AbstractController
{ {
#[Route('/admin/teamer/crm-selections/{uuid}', name: 'app_admin_teamer_crm_selections')] #[Route('/admin/teamer/crm-selections/{uuid}', name: 'app_admin_teamer_crm_selections')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Teamer $teamer): Response public function index(Teamer $teamer): Response
{ {
return $this->render('admin/teamer/crm_selections.html.twig', [ return $this->render('admin/teamer/crm_selections.html.twig', [
@@ -23,7 +23,7 @@ class DeleteAccountController extends AbstractController
} }
#[Route('/admin/teamer/delete-account/{uuid}', name: 'app_admin_teamer_delete_account')] #[Route('/admin/teamer/delete-account/{uuid}', name: 'app_admin_teamer_delete_account')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Teamer $teamer, Request $request): Response public function index(Teamer $teamer, Request $request): Response
{ {
if (true === $request->isMethod(Request::METHOD_POST)) { if (true === $request->isMethod(Request::METHOD_POST)) {
@@ -44,7 +44,7 @@ class DeleteAccountController extends AbstractController
} }
#[Route('/admin/teamer/restore-account/{uuid}', name: 'app_admin_teamer_restore_account')] #[Route('/admin/teamer/restore-account/{uuid}', name: 'app_admin_teamer_restore_account')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function restore(Teamer $teamer, Request $request): Response public function restore(Teamer $teamer, Request $request): Response
{ {
if (true === $request->isMethod(Request::METHOD_POST)) { if (true === $request->isMethod(Request::METHOD_POST)) {
@@ -22,7 +22,7 @@ class DisableUserController extends AbstractController
} }
#[Route('/admin/teamer/disable-user/{uuid}', name: 'app_admin_teamer_disable_user')] #[Route('/admin/teamer/disable-user/{uuid}', name: 'app_admin_teamer_disable_user')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Teamer $teamer, Request $request): Response public function index(Teamer $teamer, Request $request): Response
{ {
$user = $teamer->getUser(); $user = $teamer->getUser();
@@ -50,7 +50,7 @@ class DisableUserController extends AbstractController
} }
#[Route('/administrative/teamer/enable-user/{uuid}', name: 'app_admin_teamer_enable_user')] #[Route('/administrative/teamer/enable-user/{uuid}', name: 'app_admin_teamer_enable_user')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function enable(Teamer $teamer, Request $request): Response public function enable(Teamer $teamer, Request $request): Response
{ {
$user = $teamer->getUser(); $user = $teamer->getUser();
@@ -39,7 +39,7 @@ class MailingController extends AbstractController
* the real send goes through the confirmation modal below. * the real send goes through the confirmation modal below.
*/ */
#[Route('/admin/teamer/mailing', name: 'app_admin_teamer_mailing')] #[Route('/admin/teamer/mailing', name: 'app_admin_teamer_mailing')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response public function index(Request $request): Response
{ {
$form = $this->createMailingForm($request); $form = $this->createMailingForm($request);
@@ -82,7 +82,7 @@ class MailingController extends AbstractController
* one that was parked. * one that was parked.
*/ */
#[Route('/admin/teamer/mailing/draft', name: 'app_admin_teamer_mailing_draft', methods: ['POST'])] #[Route('/admin/teamer/mailing/draft', name: 'app_admin_teamer_mailing_draft', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function draft(Request $request): Response public function draft(Request $request): Response
{ {
$mailingDto = $this->createMailingForm($request)->getData(); $mailingDto = $this->createMailingForm($request)->getData();
@@ -93,7 +93,7 @@ class MailingController extends AbstractController
} }
#[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')] #[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function discard(): Response public function discard(): Response
{ {
$this->draftHandler->resetDraft(); $this->draftHandler->resetDraft();
@@ -102,7 +102,7 @@ class MailingController extends AbstractController
} }
#[Route('/admin/teamer/mailing/confirm', name: 'app_admin_teamer_mailing_confirm', methods: ['POST'])] #[Route('/admin/teamer/mailing/confirm', name: 'app_admin_teamer_mailing_confirm', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function confirm(Request $request): Response public function confirm(Request $request): Response
{ {
$form = $this->createMailingForm($request); $form = $this->createMailingForm($request);
@@ -119,7 +119,7 @@ class MailingController extends AbstractController
} }
#[Route('/admin/teamer/mailing/send', name: 'app_admin_teamer_mailing_send', methods: ['POST'])] #[Route('/admin/teamer/mailing/send', name: 'app_admin_teamer_mailing_send', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function send(Request $request): Response public function send(Request $request): Response
{ {
$form = $this->createMailingForm($request); $form = $this->createMailingForm($request);
@@ -21,7 +21,7 @@ class RemarksController extends AbstractController
} }
#[Route('/admin/teamer/remarks/{uuid}', name: 'app_admin_teamer_remarks_internal')] #[Route('/admin/teamer/remarks/{uuid}', name: 'app_admin_teamer_remarks_internal')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Teamer $teamer, Request $request): Response public function index(Teamer $teamer, Request $request): Response
{ {
$returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index'); $returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index');
@@ -25,7 +25,7 @@ class SkillsController extends AbstractController
} }
#[Route('/admin/teamer/skills/{uuid}', name: 'app_admin_teamer_skills')] #[Route('/admin/teamer/skills/{uuid}', name: 'app_admin_teamer_skills')]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Teamer $teamer, Request $request): Response public function index(Teamer $teamer, Request $request): Response
{ {
$trainings = $this->trainingRepository->getList(); $trainings = $this->trainingRepository->getList();
@@ -34,7 +34,7 @@ class CreateController extends AbstractController
path: '/admin/teamer/skills/training-attendance/create/{training_id}/{teamer_id}', path: '/admin/teamer/skills/training-attendance/create/{training_id}/{teamer_id}',
name: 'app_admin_teamer_skills_training_attendance_create' name: 'app_admin_teamer_skills_training_attendance_create'
)] )]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index( public function index(
#[MapEntity(mapping: ['training_id' => 'id'])] #[MapEntity(mapping: ['training_id' => 'id'])]
Training $training, Training $training,
@@ -30,7 +30,7 @@ class DeleteController extends AbstractController
path: '/admin/teamer/skills/training-attendance/delete/{uuid}', path: '/admin/teamer/skills/training-attendance/delete/{uuid}',
name: 'app_admin_teamer_skills_training_attendance_delete' name: 'app_admin_teamer_skills_training_attendance_delete'
)] )]
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_TEAM_ADMIN')]
public function index(TrainingAttendance $attendance, Request $request): Response public function index(TrainingAttendance $attendance, Request $request): Response
{ {
if (true === $request->isMethod('POST')) { if (true === $request->isMethod('POST')) {
@@ -25,7 +25,7 @@ class CallOffController extends AbstractController
} }
#[Route('/administrative/assignment/call-off/{uuid}', name: 'app_administrative_assignment_call_off')] #[Route('/administrative/assignment/call-off/{uuid}', name: 'app_administrative_assignment_call_off')]
#[IsGranted('ROLE_ADMINISTRATIVE')] #[IsGranted('CALL_OFF', subject: 'assignment')]
public function index(Assignment $assignment, Request $request): Response public function index(Assignment $assignment, Request $request): Response
{ {
if (true === $request->isMethod('POST')) { if (true === $request->isMethod('POST')) {
@@ -11,6 +11,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class PublishController extends AbstractController class PublishController extends AbstractController
{ {
@@ -23,6 +24,7 @@ class PublishController extends AbstractController
} }
#[Route('/administrative/assignment/publish/{uuid}', name: 'app_administrative_assignment_publish')] #[Route('/administrative/assignment/publish/{uuid}', name: 'app_administrative_assignment_publish')]
#[IsGranted('PUBLISH', subject: 'assignment')]
public function index(Assignment $assignment, Request $request): Response public function index(Assignment $assignment, Request $request): Response
{ {
$returnUrl = $this->getReturnUrl($request, 'app_administrative_assignment_index'); $returnUrl = $this->getReturnUrl($request, 'app_administrative_assignment_index');
@@ -13,6 +13,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class EditController extends AbstractController class EditController extends AbstractController
{ {
@@ -27,6 +28,7 @@ class EditController extends AbstractController
} }
#[Route('/administrative/destination/edit/{id}', name: 'app_administrative_system_destination_edit')] #[Route('/administrative/destination/edit/{id}', name: 'app_administrative_system_destination_edit')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Destination $destination, Request $request): Response public function index(Destination $destination, Request $request): Response
{ {
$destinationDto = DestinationDto::fromEntity($destination); $destinationDto = DestinationDto::fromEntity($destination);
@@ -32,6 +32,7 @@ class IndexController extends AbstractController
} }
#[Route('/administrative/system/faq/sort', name: 'app_administrative_system_faq_sort', methods: ['POST'])] #[Route('/administrative/system/faq/sort', name: 'app_administrative_system_faq_sort', methods: ['POST'])]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function sort(Request $request): JsonResponse public function sort(Request $request): JsonResponse
{ {
$data = json_decode($request->getContent(), true); $data = json_decode($request->getContent(), true);
@@ -55,6 +55,7 @@ class ApplicationFilterController extends AbstractController
} }
#[Route('/common/application/filter/reset', name: 'app_common_application_filter_reset')] #[Route('/common/application/filter/reset', name: 'app_common_application_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response public function reset(Request $request): Response
{ {
$this->filterHandler->resetFilterSettings(); $this->filterHandler->resetFilterSettings();
@@ -53,6 +53,7 @@ class AssignmentFilterController extends AbstractController
} }
#[Route('/common/assignment/filter/reset', name: 'app_common_assignment_filter_reset')] #[Route('/common/assignment/filter/reset', name: 'app_common_assignment_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response public function reset(Request $request): Response
{ {
$this->filterHandler->resetFilterSettings(); $this->filterHandler->resetFilterSettings();
@@ -47,6 +47,7 @@ class DocumentFilterController extends AbstractController
} }
#[Route('/common/document/filter/reset', name: 'app_common_document_filter_reset')] #[Route('/common/document/filter/reset', name: 'app_common_document_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response public function reset(Request $request): Response
{ {
$this->filterHandler->resetFilterSettings(); $this->filterHandler->resetFilterSettings();
@@ -39,6 +39,7 @@ class FeedbackFilterController extends AbstractController
} }
#[Route('/common/feedback/filter/reset', name: 'app_common_feedback_filter_reset')] #[Route('/common/feedback/filter/reset', name: 'app_common_feedback_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response public function reset(Request $request): Response
{ {
$this->filterHandler->resetFilterSettings(); $this->filterHandler->resetFilterSettings();
@@ -42,6 +42,7 @@ class TeamerFilterController extends AbstractController
} }
#[Route('/common/teamer/filter/reset', name: 'app_common_teamer_filter_reset')] #[Route('/common/teamer/filter/reset', name: 'app_common_teamer_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response public function reset(Request $request): Response
{ {
$this->filterHandler->resetFilterSettings(); $this->filterHandler->resetFilterSettings();
@@ -52,6 +52,7 @@ class TimelineFilterController extends AbstractController
} }
#[Route('/common/timeline/filter/reset', name: 'app_common_timeline_filter_reset')] #[Route('/common/timeline/filter/reset', name: 'app_common_timeline_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response public function reset(Request $request): Response
{ {
$this->filterHandler->resetFilterSettings(); $this->filterHandler->resetFilterSettings();
@@ -8,6 +8,7 @@ use App\Repository\UploadRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController class IndexController extends AbstractController
{ {
@@ -18,6 +19,7 @@ class IndexController extends AbstractController
} }
#[Route('/management', name: 'app_manager_index')] #[Route('/management', name: 'app_manager_index')]
#[IsGranted('ROLE_MANAGER')]
public function index(): Response public function index(): Response
{ {
$dispositions = $this->dispositionRepository->getNew(); $dispositions = $this->dispositionRepository->getNew();
+3 -4
View File
@@ -22,10 +22,9 @@ class OAuth2Controller extends AbstractController
{ {
$this->denyUnlessFeatureIsActive(); $this->denyUnlessFeatureIsActive();
$provider = $this->client->getProvider(); // the state and the PKCE verifier belong to the client, which is what consumes
$url = $provider->getAuthorizationUrl(); // them again on the callback
$state = $provider->getState(); $url = $this->client->createAuthorizationUrl($request);
$request->getSession()->set('oauth2state', $state);
return $this->redirect($url); return $this->redirect($url);
} }
@@ -7,6 +7,7 @@ use App\Repository\ContactRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ContactController extends AbstractController class ContactController extends AbstractController
{ {
@@ -15,6 +16,7 @@ class ContactController extends AbstractController
} }
#[Route('/teamer/contact', name: 'app_teamer_contact')] #[Route('/teamer/contact', name: 'app_teamer_contact')]
#[IsGranted('ROLE_USER')]
public function index(): Response public function index(): Response
{ {
$allContacts = $this $allContacts = $this
+1 -1
View File
@@ -75,7 +75,7 @@ class Contact
public function setEmail(string $email): static public function setEmail(string $email): static
{ {
$this->email = $email; $this->email = mb_strtolower(trim($email));
return $this; return $this;
} }
+1 -1
View File
@@ -108,7 +108,7 @@ class Communication
public function setEmail(?string $email): static public function setEmail(?string $email): static
{ {
$this->email = $email; $this->email = null === $email ? null : mb_strtolower(trim($email));
return $this; return $this;
} }
+1 -1
View File
@@ -95,7 +95,7 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
private ?string $remarksInternal = null; private ?string $remarksInternal = null;
#[ORM\ManyToMany(targetEntity: Availability::class, inversedBy: 'teamers')] #[ORM\ManyToMany(targetEntity: Availability::class, inversedBy: 'teamers')]
#[ORM\OrderBy(['dateFrom' => 'ASC'])] #[ORM\OrderBy(['dateFrom' => \SortDirection::Ascending])]
private Collection $availabilities; private Collection $availabilities;
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: Availability::class)] #[ORM\OneToMany(mappedBy: 'owner', targetEntity: Availability::class)]
+7 -7
View File
@@ -22,7 +22,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
* Assignable roles and their labels. * Assignable roles and their labels.
*/ */
public const ROLES = [ public const ROLES = [
'ROLE_ADMIN' => 'Admin', 'ROLE_TEAM_ADMIN' => 'Team Admin',
'ROLE_MANAGER' => 'Reisemanager', 'ROLE_MANAGER' => 'Reisemanager',
'ROLE_HOUSE_MANAGER' => 'Hausleitung', 'ROLE_HOUSE_MANAGER' => 'Hausleitung',
'ROLE_TEAMER' => 'Teamer', 'ROLE_TEAMER' => 'Teamer',
@@ -35,7 +35,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
* a super admin. * a super admin.
*/ */
public const PENDING_ROLES = [ public const PENDING_ROLES = [
'ROLE_ADMIN' => 'ROLE_ADMIN_PENDING', 'ROLE_TEAM_ADMIN' => 'ROLE_TEAM_ADMIN_PENDING',
'ROLE_MANAGER' => 'ROLE_MANAGER_PENDING', 'ROLE_MANAGER' => 'ROLE_MANAGER_PENDING',
'ROLE_HOUSE_MANAGER' => 'ROLE_HOUSE_MANAGER_PENDING', 'ROLE_HOUSE_MANAGER' => 'ROLE_HOUSE_MANAGER_PENDING',
]; ];
@@ -194,7 +194,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
$roles = ['ROLE_USER', ...$this->roles]; $roles = ['ROLE_USER', ...$this->roles];
if (true === $this->isSuperAdmin()) { if (true === $this->isSuperAdmin()) {
$roles[] = 'ROLE_SUPER_ADMIN'; $roles[] = 'ROLE_TEAM_SUPER_ADMIN';
} }
return array_unique($roles); return array_unique($roles);
@@ -223,7 +223,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
/** /**
* The manually assignable roles held by the user, i.e. without the implicit ROLE_USER * The manually assignable roles held by the user, i.e. without the implicit ROLE_USER
* and ROLE_SUPER_ADMIN added by getRoles() and without any pending marker. Used to * and ROLE_TEAM_SUPER_ADMIN added by getRoles() and without any pending marker. Used to
* edit role assignments: saving them resolves the pending approvals. * edit role assignments: saving them resolves the pending approvals.
*/ */
public function getAssignedRoles(): array public function getAssignedRoles(): array
@@ -283,12 +283,12 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
} }
/** /**
* Super admin is an elevation of ROLE_ADMIN, never a standalone grant. * Super admin is an elevation of ROLE_TEAM_ADMIN, never a standalone grant.
*/ */
#[Assert\Callback] #[Assert\Callback]
public function validateSuperAdmin(ExecutionContextInterface $context): void public function validateSuperAdmin(ExecutionContextInterface $context): void
{ {
if (true === $this->superAdmin && false === in_array('ROLE_ADMIN', $this->roles, true)) { if (true === $this->superAdmin && false === in_array('ROLE_TEAM_ADMIN', $this->roles, true)) {
$context $context
->buildViolation('Nur Admins können zu Superadmins ernannt werden.') ->buildViolation('Nur Admins können zu Superadmins ernannt werden.')
->atPath('superAdmin') ->atPath('superAdmin')
@@ -311,7 +311,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
public function getDefaultRoute(): string public function getDefaultRoute(): string
{ {
if ($this->hasRole('ROLE_ADMIN')) { if ($this->hasRole('ROLE_TEAM_ADMIN')) {
return 'app_admin_index'; return 'app_admin_index';
} elseif ($this->hasRole('ROLE_MANAGER')) { } elseif ($this->hasRole('ROLE_MANAGER')) {
return 'app_manager_index'; return 'app_manager_index';
+2 -2
View File
@@ -67,7 +67,7 @@ class UserType extends AbstractType
}); });
// Super admin is an elevation of an existing role, never a grant of its own, so the // Super admin is an elevation of an existing role, never a grant of its own, so the
// field exists only for somebody who already holds ROLE_ADMIN - offering it to // field exists only for somebody who already holds ROLE_TEAM_ADMIN - offering it to
// anyone else would only produce the violation from User::validateSuperAdmin(). // anyone else would only produce the violation from User::validateSuperAdmin().
// A flag already set without the role is the exception: it has to stay editable, or // A flag already set without the role is the exception: it has to stay editable, or
// that user could not be saved at all until BusPro claims them an admin again. // that user could not be saved at all until BusPro claims them an admin again.
@@ -78,7 +78,7 @@ class UserType extends AbstractType
return; return;
} }
if (false === in_array('ROLE_ADMIN', $user->getAssignedRoles(), true) && false === $user->isSuperAdmin()) { if (false === in_array('ROLE_TEAM_ADMIN', $user->getAssignedRoles(), true) && false === $user->isSuperAdmin()) {
return; return;
} }
+3 -3
View File
@@ -19,7 +19,7 @@ abstract class AbstractMenuBuilder
* Note that "app_admin_" does not match the shared "app_administrative_" routes. * Note that "app_admin_" does not match the shared "app_administrative_" routes.
*/ */
protected const AREA_ROUTE_PREFIXES = [ protected const AREA_ROUTE_PREFIXES = [
'app_admin_' => 'ROLE_ADMIN', 'app_admin_' => 'ROLE_TEAM_ADMIN',
'app_manager_' => 'ROLE_MANAGER', 'app_manager_' => 'ROLE_MANAGER',
'app_house_manager_' => 'ROLE_HOUSE_MANAGER', 'app_house_manager_' => 'ROLE_HOUSE_MANAGER',
'app_teamer_' => 'ROLE_TEAMER', 'app_teamer_' => 'ROLE_TEAMER',
@@ -29,7 +29,7 @@ abstract class AbstractMenuBuilder
* Priority order of the area roles, must stay in sync with User::getDefaultRoute(). * Priority order of the area roles, must stay in sync with User::getDefaultRoute().
*/ */
protected const ROLE_PRIORITY = [ protected const ROLE_PRIORITY = [
'ROLE_ADMIN', 'ROLE_TEAM_ADMIN',
'ROLE_MANAGER', 'ROLE_MANAGER',
'ROLE_HOUSE_MANAGER', 'ROLE_HOUSE_MANAGER',
'ROLE_TEAMER', 'ROLE_TEAMER',
@@ -140,7 +140,7 @@ abstract class AbstractMenuBuilder
protected function addAdminItem(ItemInterface $menu): void protected function addAdminItem(ItemInterface $menu): void
{ {
if ($this->security->isGranted('ROLE_ADMIN')) { if ($this->security->isGranted('ROLE_TEAM_ADMIN')) {
$this->addDivider($menu); $this->addDivider($menu);
$menu->addChild('zum Adminbereich', [ $menu->addChild('zum Adminbereich', [
'route' => 'app_admin_index', 'route' => 'app_admin_index',
+2 -2
View File
@@ -32,7 +32,7 @@ class MenuBuilder extends AbstractMenuBuilder
public function createMainMenu(array $options): ItemInterface public function createMainMenu(array $options): ItemInterface
{ {
return match ($this->resolveArea()) { return match ($this->resolveArea()) {
'ROLE_ADMIN' => $this->adminMenuBuilder->createMainMenu($options), 'ROLE_TEAM_ADMIN' => $this->adminMenuBuilder->createMainMenu($options),
'ROLE_MANAGER' => $this->managerMenuBuilder->createMainMenu($options), 'ROLE_MANAGER' => $this->managerMenuBuilder->createMainMenu($options),
'ROLE_HOUSE_MANAGER' => $this->houseManagerMenuBuilder->createMainMenu($options), 'ROLE_HOUSE_MANAGER' => $this->houseManagerMenuBuilder->createMainMenu($options),
'ROLE_TEAMER' => $this->teamerMenuBuilder->createMainMenu($options), 'ROLE_TEAMER' => $this->teamerMenuBuilder->createMainMenu($options),
@@ -43,7 +43,7 @@ class MenuBuilder extends AbstractMenuBuilder
public function createTeamerMenu(array $options): ItemInterface public function createTeamerMenu(array $options): ItemInterface
{ {
return match ($this->resolveArea()) { return match ($this->resolveArea()) {
'ROLE_ADMIN' => $this->adminMenuBuilder->createTeamerMenu($options), 'ROLE_TEAM_ADMIN' => $this->adminMenuBuilder->createTeamerMenu($options),
'ROLE_MANAGER' => $this->managerMenuBuilder->createTeamerMenu($options), 'ROLE_MANAGER' => $this->managerMenuBuilder->createTeamerMenu($options),
default => $this->createRootElement(), default => $this->createRootElement(),
}; };
+2 -1
View File
@@ -148,7 +148,8 @@ class AvailabilityRepository extends ServiceEntityRepository
$qb->expr()->eq('availability.dateTo', ':dateTo'), $qb->expr()->eq('availability.dateTo', ':dateTo'),
$qb->expr()->isNull('availability.owner') $qb->expr()->isNull('availability.owner')
)) ))
->setParameters($fields) ->setParameter('dateFrom', $fields['dateFrom'])
->setParameter('dateTo', $fields['dateTo'])
->getQuery() ->getQuery()
->getResult() ->getResult()
; ;
@@ -7,7 +7,7 @@ use App\Entity\User;
abstract class AbstractRequiredTeamerCheck implements RequiredTeamerCheckInterface abstract class AbstractRequiredTeamerCheck implements RequiredTeamerCheckInterface
{ {
private const EXCLUDED_ROLES = [ private const EXCLUDED_ROLES = [
'ROLE_ADMIN', 'ROLE_TEAM_ADMIN',
'ROLE_MANAGER', 'ROLE_MANAGER',
'ROLE_HOUSE_MANAGER', 'ROLE_HOUSE_MANAGER',
]; ];
+1 -1
View File
@@ -43,7 +43,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
public function authenticate(Request $request): Passport public function authenticate(Request $request): Passport
{ {
$email = trim($request->request->getString('_username')); $email = mb_strtolower(trim($request->request->getString('_username')));
$passwordPlain = trim($request->request->getString('_password')); $passwordPlain = trim($request->request->getString('_password'));
// Very lame hashing applied here as required by BPN // Very lame hashing applied here as required by BPN
+16 -2
View File
@@ -6,6 +6,7 @@ use App\BusProNet\UserDataHandler;
use App\Entity\Teamer; use App\Entity\Teamer;
use App\Entity\User; use App\Entity\User;
use App\RequiredTeamerCheck\RequiredTeamerCheckRegistry; use App\RequiredTeamerCheck\RequiredTeamerCheckRegistry;
use App\Security\OAuth2\AuthorizationDeniedException;
use App\Security\OAuth2\AuthorizationRequestException; use App\Security\OAuth2\AuthorizationRequestException;
use App\Security\OAuth2\MyEpClient; use App\Security\OAuth2\MyEpClient;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -34,8 +35,14 @@ class MyEpAuthenticator extends AbstractAuthenticator
* The roles that entitle someone to log in here at all. Anything else MyE&P reports * The roles that entitle someone to log in here at all. Anything else MyE&P reports
* is dropped rather than stored, so that no role this application assigns a meaning * is dropped rather than stored, so that no role this application assigns a meaning
* to can be set from the outside. * to can be set from the outside.
*
* This list exists a second time on MyE&P, as the required_roles of this application's
* oauth2_client row: MyE&P refuses the authorization request outright when the account
* holds none of them, and explains why on its own page. The two are one policy written
* twice and must be changed together — widening only one either strands a user at MyE&P
* with no explanation this side can give, or lets one through to be refused here.
*/ */
private const ELIGIBLE_ROLES = ['ROLE_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER']; private const ELIGIBLE_ROLES = ['ROLE_TEAM_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER'];
public function __construct( public function __construct(
private readonly MyEpClient $client, private readonly MyEpClient $client,
@@ -63,11 +70,18 @@ class MyEpAuthenticator extends AbstractAuthenticator
{ {
try { try {
$accessToken = $this->client->fetchAccessToken($request); $accessToken = $this->client->fetchAccessToken($request);
} catch (AuthorizationDeniedException $e) {
// MyE&P said why it sent no code, so this is not a failure to report as one
$this->logger->info('Login via MyE&P was denied', [
'error' => $e->getError(),
'error_description' => $e->getErrorDescription(),
]);
throw new CustomUserMessageAuthenticationException('Login via MyE&P wurde abgebrochen');
} catch (AuthorizationRequestException|IdentityProviderException $e) { } catch (AuthorizationRequestException|IdentityProviderException $e) {
$this->logger->error('Login via MyE&P failed due to unobtainable access token', [ $this->logger->error('Login via MyE&P failed due to unobtainable access token', [
'exception' => $e, 'exception' => $e,
]); ]);
throw new CustomUserMessageAuthenticationException('Invalid token'); throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich');
} }
try { try {
@@ -0,0 +1,34 @@
<?php
namespace App\Security\OAuth2;
use Symfony\Component\HttpFoundation\Request;
/**
* MyE&P redirected back with an `error` instead of a code — the person cancelled, or the
* authorization was refused.
*
* A subclass of AuthorizationRequestException so that a caller which does not care why the
* callback carried no code still catches it, and one that does can tell this apart from a
* malformed or replayed callback and say so.
*/
class AuthorizationDeniedException extends AuthorizationRequestException
{
public function __construct(
private readonly string $error,
private readonly ?string $errorDescription,
Request $request,
) {
parent::__construct(sprintf('Authorization denied: %s', $error), 400, $request);
}
public function getError(): string
{
return $this->error;
}
public function getErrorDescription(): ?string
{
return $this->errorDescription;
}
}
+82 -11
View File
@@ -13,41 +13,108 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class MyEpClient class MyEpClient
{ {
private const SESSION_KEY_STATE = 'oauth2state';
private const SESSION_KEY_PKCE = 'oauth2pkce';
private array $config; private array $config;
public function __construct( public function __construct(
private readonly UrlGeneratorInterface $urlGenerator, private readonly UrlGeneratorInterface $urlGenerator,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
// the client secret rides in here, marked sensitive so it is redacted from stack
// traces rather than printed in full on an exception page or in a log
#[\SensitiveParameter]
array $options, array $options,
) { ) {
$this->config = $this->resolveConfig($options); $this->config = $this->resolveConfig($options);
} }
/**
* Starts the authorization code flow and stores what the callback has to prove.
*
* The state and the PKCE verifier are generated by getAuthorizationUrl() and are only
* readable afterwards, so both are taken from the provider rather than built here.
*/
public function createAuthorizationUrl(Request $request): string
{
$provider = $this->getProvider();
$url = $provider->getAuthorizationUrl();
$session = $request->getSession();
$session->set(self::SESSION_KEY_STATE, $provider->getState());
$session->set(self::SESSION_KEY_PKCE, $provider->getPkceCode());
return $url;
}
/** /**
* @throws IdentityProviderException * @throws IdentityProviderException
* @throws AuthorizationDeniedException
* @throws AuthorizationRequestException * @throws AuthorizationRequestException
*/ */
public function fetchAccessToken(Request $request): AccessTokenInterface public function fetchAccessToken(Request $request): AccessTokenInterface
{ {
$session = $request->getSession();
// Both are single-use, so they are consumed before anything can fail: a callback
// that throws must not leave a state behind that a second attempt could replay.
$expectedState = $session->remove(self::SESSION_KEY_STATE);
$pkceCode = $session->remove(self::SESSION_KEY_PKCE);
$state = $request->query->get('state');
// Before anything else, including the error parameter: RFC 6749 4.1.2.1 asks for the
// state to be verified on an error response too, and there is a practical reason to.
// The session cookie is SameSite=lax, so a top-level navigation to this route carries
// the victim's cookie, and the two removes above have already consumed their pending
// flow. Refusing here means a callback that cannot prove the state no longer destroys
// a login it did not start.
//
// hash_equals() rather than !==, the state being the CSRF secret of the flow. The
// string checks come first: a callback with no state, or a session that never held
// one, must fail here rather than reach a comparison with null.
if (
false === is_string($state)
|| false === is_string($expectedState)
|| false === hash_equals($expectedState, $state)
) {
$this->logger->error('OAuth2 login request missing state or mismatch');
throw new AuthorizationRequestException('Missing state or mismatch', 400, $request);
}
// Now that the callback is known to belong to this flow: MyE&P has said why it is
// not sending a code, and every later check would report the wrong cause. The role
// gate renders on MyE&P rather than redirecting, but a cancelled or refused
// authorization arrives here. Not logged — this is no failure of ours, and the
// caller decides what to make of it from the exception.
$error = $request->query->get('error');
if (true === is_string($error) && '' !== $error) {
$description = $request->query->get('error_description');
$description = is_string($description) && '' !== $description ? $description : null;
throw new AuthorizationDeniedException($error, $description, $request);
}
if (null === $code = $request->query->get('code')) { if (null === $code = $request->query->get('code')) {
$this->logger->error('OAuth2 login request missing code'); $this->logger->error('OAuth2 login request missing code');
throw new AuthorizationRequestException('Missing code', 400, $request); throw new AuthorizationRequestException('Missing code', 400, $request);
} }
$session = $request->getSession(); // A challenge went out with the authorization request, so MyE&P rejects an exchange
// without the verifier. Refused here instead, where the reason is still known — the
if ( // usual cause is a session replaced between the two legs of the flow.
null === $request->query->get('state') if (false === is_string($pkceCode) || '' === $pkceCode) {
|| $request->query->get('state') !== $session->get('oauth2state') $this->logger->error('OAuth2 login request without the PKCE verifier of its session');
) { throw new AuthorizationRequestException('Missing PKCE verifier', 400, $request);
$session->remove('oauth2state');
$this->logger->error('OAuth2 login request missing state or mismatch');
throw new AuthorizationRequestException('Missing state or mismatch', 400, $request);
} }
$session->remove('oauth2state'); $provider = $this->getProvider();
return $this->getProvider()->getAccessToken('authorization_code', [ // replays the verifier whose challenge was sent with the authorization request
$provider->setPkceCode($pkceCode);
return $provider->getAccessToken('authorization_code', [
'code' => $code, 'code' => $code,
]); ]);
} }
@@ -68,6 +135,10 @@ class MyEpClient
'urlResourceOwnerDetails' => $this->config['myep_oauth2_url_resource_owner_details'], 'urlResourceOwnerDetails' => $this->config['myep_oauth2_url_resource_owner_details'],
'scopes' => $this->config['myep_oauth2_scopes'], 'scopes' => $this->config['myep_oauth2_scopes'],
'scopeSeparator' => ' ', 'scopeSeparator' => ' ',
// The redirect URI above is generated from the incoming request, so it is only
// as trustworthy as the Host header. PKCE binds the authorization code to this
// flow by a second, header-independent means.
'pkceMethod' => AbstractProvider::PKCE_METHOD_S256,
]); ]);
} }
+2 -2
View File
@@ -60,7 +60,7 @@ class DispositionVoter extends Voter
static::CONTRACT, static::INVOICE => false === $disposition->isSkipFormalities() static::CONTRACT, static::INVOICE => false === $disposition->isSkipFormalities()
&& ($this->security->isGranted('ROLE_ADMINISTRATIVE') && ($this->security->isGranted('ROLE_ADMINISTRATIVE')
|| $this->assertTeamerAccess($token, $disposition)), || $this->assertTeamerAccess($token, $disposition)),
static::DELETE => $this->security->isGranted('ROLE_ADMIN'), static::DELETE => $this->security->isGranted('ROLE_TEAM_ADMIN'),
static::FEEDBACK => false === $disposition->isSkipFormalities() static::FEEDBACK => false === $disposition->isSkipFormalities()
&& ($this->security->isGranted('ROLE_ADMINISTRATIVE') && ($this->security->isGranted('ROLE_ADMINISTRATIVE')
|| $this->assertHouseManagerAccess($token, $disposition)), || $this->assertHouseManagerAccess($token, $disposition)),
@@ -162,7 +162,7 @@ class DispositionVoter extends Voter
private function assertAdminDocumentUploadAllowed(Disposition $disposition): bool private function assertAdminDocumentUploadAllowed(Disposition $disposition): bool
{ {
if (false === $this->security->isGranted('ROLE_ADMINISTRATIVE') if (false === $this->security->isGranted('ROLE_ADMINISTRATIVE')
&& false === $this->security->isGranted('ROLE_ADMIN')) { && false === $this->security->isGranted('ROLE_TEAM_ADMIN')) {
return false; return false;
} }
+1 -1
View File
@@ -24,7 +24,7 @@ class FeedbackVoter extends Voter
/** @var Feedback $feedback */ /** @var Feedback $feedback */
$feedback = $subject; $feedback = $subject;
if (in_array('ROLE_ADMIN', $token->getRoleNames())) { if (in_array('ROLE_TEAM_ADMIN', $token->getRoleNames())) {
return true; return true;
} }
+1 -1
View File
@@ -52,7 +52,7 @@ class ImpersonationVoter extends Voter
} }
// Admin is the only role allowed to impersonate // Admin is the only role allowed to impersonate
if (false === $this->security->isGranted('ROLE_ADMIN')) { if (false === $this->security->isGranted('ROLE_TEAM_ADMIN')) {
return false; return false;
} }
+1 -1
View File
@@ -33,7 +33,7 @@ class AppRuntime implements RuntimeExtensionInterface
$user = $teamer->getUser(); $user = $teamer->getUser();
if (null !== $user) { if (null !== $user) {
if ($user->hasRole('ROLE_ADMIN')) { if ($user->hasRole('ROLE_TEAM_ADMIN')) {
$labelItems[] = 'Admin'; $labelItems[] = 'Admin';
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{{ form_start(form) }} {{ form_start(form) }}
<div class="flex flex-col space-y-4 pb-8"> <div class="flex flex-col space-y-4 pb-8">
{# only present for somebody who already holds ROLE_ADMIN, see UserType #} {# only present for somebody who already holds ROLE_TEAM_ADMIN, see UserType #}
{% if form.superAdmin is defined %} {% if form.superAdmin is defined %}
{{ form_row(form.superAdmin) }} {{ form_row(form.superAdmin) }}
{% endif %} {% endif %}
@@ -9,7 +9,7 @@
<div class="pb-8"> <div class="pb-8">
{% include '_partials/_assignment_info_compact.html.twig' %} {% include '_partials/_assignment_info_compact.html.twig' %}
</div> </div>
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_TEAM_ADMIN') %}
<div class="col-span-2"> <div class="col-span-2">
<h2 class="font-bold text-lg pb-4"> <h2 class="font-bold text-lg pb-4">
Bewerbungen Bewerbungen
@@ -52,7 +52,7 @@
<th> <th>
{{ knp_pagination_sortable(pagination, 'Busbegleitung', 'assignment.pickup') }} {{ knp_pagination_sortable(pagination, 'Busbegleitung', 'assignment.pickup') }}
</th> </th>
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_TEAM_ADMIN') %}
<th> <th>
Bewerbungen Bewerbungen
</th> </th>
@@ -131,7 +131,7 @@
- -
{% endif %} {% endif %}
</td> </td>
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_TEAM_ADMIN') %}
<td> <td>
<a href="{{ path('app_administrative_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}" class="flex items-center space-x-2"> <a href="{{ path('app_administrative_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}" class="flex items-center space-x-2">
<span>{{ assignment.applications|length }}</span> <span>{{ assignment.applications|length }}</span>
@@ -102,7 +102,7 @@
{{ knp_pagination_render(pagination) }} {{ knp_pagination_render(pagination) }}
</div> </div>
</div> </div>
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_TEAM_ADMIN') %}
<a href="{{ path('app_admin_feedback_provide') }}" class="btn" title="Feedback erfassen"> <a href="{{ path('app_admin_feedback_provide') }}" class="btn" title="Feedback erfassen">
Neu Neu
</a> </a>
@@ -26,7 +26,7 @@
Reset Reset
</a> </a>
{% endif %} {% endif %}
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_TEAM_ADMIN') %}
<a href="{{ path('app_admin_teamer_mailing', { 'r': return_url() }) }}" <a href="{{ path('app_admin_teamer_mailing', { 'r': return_url() }) }}"
class="btn btn--small" class="btn btn--small"
title="Mail an die gefilterte Liste schreiben"> title="Mail an die gefilterte Liste schreiben">
@@ -113,7 +113,7 @@
</button> </button>
{% endif %} {% endif %}
{% if teamer.deleted %} {% if teamer.deleted %}
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button" <button type="button"
role="menuitem" role="menuitem"
tabindex="-1" tabindex="-1"
@@ -125,7 +125,7 @@
</button> </button>
{% endif %} {% endif %}
{% elseif teamer.user is not null and teamer.user.disabled %} {% elseif teamer.user is not null and teamer.user.disabled %}
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button" <button type="button"
role="menuitem" role="menuitem"
tabindex="-1" tabindex="-1"
@@ -136,7 +136,7 @@
</button> </button>
{% endif %} {% endif %}
{% else %} {% else %}
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button" <button type="button"
class="text-red-500" class="text-red-500"
role="menuitem" role="menuitem"
@@ -152,7 +152,7 @@
{{ icon('mask') }} {{ icon('mask') }}
</a> </a>
{% endif %} {% endif %}
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button" <button type="button"
class="text-red-500" class="text-red-500"
role="menuitem" role="menuitem"
@@ -80,7 +80,7 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_TEAM_ADMIN') %}
<h2 class="text-lg font-bold"> <h2 class="text-lg font-bold">
Interne Anmerkungen Interne Anmerkungen
</h2> </h2>
+61 -1
View File
@@ -10,6 +10,7 @@ use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\ProfileResponse; use App\BusProNet\Model\ProfileResponse;
use App\BusProNet\Model\ProfileUpdateResponse; use App\BusProNet\Model\ProfileUpdateResponse;
use App\BusProNet\ResponseParser; use App\BusProNet\ResponseParser;
use App\BusProNet\ResponseParserException;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
class ResponseParserTest extends TestCase class ResponseParserTest extends TestCase
@@ -178,6 +179,65 @@ class ResponseParserTest extends TestCase
$this->assertEquals('Gaststätte', $hotel->getType()); $this->assertEquals('Gaststätte', $hotel->getType());
} }
/**
* A broken response used to collapse into one opaque "Unable to parse XML response",
* which is what made the production import failure undiagnosable. Each shape must now
* name itself.
*/
public function testParseEmptyResponse(): void
{
$parser = $this->getParserInstance();
$this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('empty response');
$parser->parseXmlString(ApiClient::TYPE_BASE_DATA_PICKUPS, '');
}
public function testParseTruncatedResponse(): void
{
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="STAMMZUSTIEGE"></satz><zustieg id="1"';
$parser = $this->getParserInstance();
try {
$parser->parseXmlString(ApiClient::TYPE_BASE_DATA_PICKUPS, $content);
$this->fail('Expected a ResponseParserException');
} catch (ResponseParserException $e) {
$this->assertStringContainsString('('.strlen($content).' bytes)', $e->getMessage());
$this->assertStringContainsString('line 1', $e->getMessage());
}
}
public function testParseWellFormedResponseOfAnUnknownType(): void
{
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="STAMMIRGENDWAS"></satz></ergebnis>';
$parser = $this->getParserInstance();
$this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('Unrecognised BusProNet response type "STAMMIRGENDWAS"');
$parser->parseXmlString(ApiClient::TYPE_BASE_DATA_PICKUPS, $content);
}
public function testParsingDoesNotLeakLibxmlErrorState(): void
{
$previous = libxml_use_internal_errors(false);
try {
try {
$this->getParserInstance()->parseXmlString(ApiClient::TYPE_BASE_DATA_PICKUPS, '<ergebnis');
} catch (ResponseParserException) {
}
$this->assertFalse(libxml_use_internal_errors(false));
$this->assertSame([], libxml_get_errors());
} finally {
libxml_use_internal_errors($previous);
}
}
private function loadFixture(string $filename): string private function loadFixture(string $filename): string
{ {
return file_get_contents(__DIR__.'/../Resources/'.$filename); return file_get_contents(__DIR__.'/../Resources/'.$filename);
@@ -186,7 +246,7 @@ class ResponseParserTest extends TestCase
private function getParserInstance(): ResponseParser private function getParserInstance(): ResponseParser
{ {
return new ResponseParser([ return new ResponseParser([
'bpn_crm_id_admin' => 1292, 'bpn_crm_id_team_admin' => 1292,
'bpn_crm_id_manager' => 1293, 'bpn_crm_id_manager' => 1293,
'bpn_crm_id_teamer' => 1070, 'bpn_crm_id_teamer' => 1070,
// Deliberately a small excerpt of the configured map: the fixtures only carry // Deliberately a small excerpt of the configured map: the fixtures only carry
+66 -35
View File
@@ -44,7 +44,7 @@ class UserDataHandlerTest extends TestCase
{ {
yield 'admin only yields the pending marker' => [ yield 'admin only yields the pending marker' => [
(new CrmAttributesResponse())->setAdmin(true), (new CrmAttributesResponse())->setAdmin(true),
[User::PENDING_ROLES['ROLE_ADMIN']], [User::PENDING_ROLES['ROLE_TEAM_ADMIN']],
]; ];
yield 'manager only yields the pending marker' => [ yield 'manager only yields the pending marker' => [
@@ -64,12 +64,12 @@ class UserDataHandlerTest extends TestCase
yield 'admin and teamer' => [ yield 'admin and teamer' => [
(new CrmAttributesResponse())->setAdmin(true)->setTeamer(true), (new CrmAttributesResponse())->setAdmin(true)->setTeamer(true),
[User::PENDING_ROLES['ROLE_ADMIN'], 'ROLE_TEAMER'], [User::PENDING_ROLES['ROLE_TEAM_ADMIN'], 'ROLE_TEAMER'],
]; ];
yield 'admin and manager yield both markers' => [ yield 'admin and manager yield both markers' => [
(new CrmAttributesResponse())->setAdmin(true)->setManager(true), (new CrmAttributesResponse())->setAdmin(true)->setManager(true),
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_MANAGER']], [User::PENDING_ROLES['ROLE_TEAM_ADMIN'], User::PENDING_ROLES['ROLE_MANAGER']],
]; ];
yield 'manager and house manager yield both markers, the roles stand on their own' => [ yield 'manager and house manager yield both markers, the roles stand on their own' => [
@@ -100,9 +100,9 @@ class UserDataHandlerTest extends TestCase
public static function toPendingRolesProvider(): iterable public static function toPendingRolesProvider(): iterable
{ {
yield 'admin' => [ yield 'admin' => [
['ROLE_ADMIN'], ['ROLE_TEAM_ADMIN'],
(new CrmAttributesResponse())->setAdmin(true), (new CrmAttributesResponse())->setAdmin(true),
[User::PENDING_ROLES['ROLE_ADMIN']], [User::PENDING_ROLES['ROLE_TEAM_ADMIN']],
]; ];
yield 'manager and house manager are marked independently' => [ yield 'manager and house manager are marked independently' => [
@@ -118,9 +118,9 @@ class UserDataHandlerTest extends TestCase
]; ];
yield 'admin and house manager' => [ yield 'admin and house manager' => [
['ROLE_ADMIN', 'ROLE_HOUSE_MANAGER'], ['ROLE_TEAM_ADMIN', 'ROLE_HOUSE_MANAGER'],
(new CrmAttributesResponse())->setAdmin(true)->setHouseManager(true), (new CrmAttributesResponse())->setAdmin(true)->setHouseManager(true),
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_HOUSE_MANAGER']], [User::PENDING_ROLES['ROLE_TEAM_ADMIN'], User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
]; ];
yield 'teamer has no marker' => [ yield 'teamer has no marker' => [
@@ -144,7 +144,7 @@ class UserDataHandlerTest extends TestCase
->setEmail('[email protected]') ->setEmail('[email protected]')
->setBusProAddressId(1) ->setBusProAddressId(1)
->setBusProPersonId(2) ->setBusProPersonId(2)
->setRoles(['ROLE_ADMIN']) ->setRoles(['ROLE_TEAM_ADMIN'])
->setHotelCodes(['XYZ']) ->setHotelCodes(['XYZ'])
; ;
@@ -172,7 +172,7 @@ class UserDataHandlerTest extends TestCase
$profileResponse, $profileResponse,
true, true,
['team' => ['selected' => true]], ['team' => ['selected' => true]],
['ROLE_ADMIN', 'ROLE_TEAMER'], ['ROLE_TEAM_ADMIN', 'ROLE_TEAMER'],
['DKS'], ['DKS'],
); );
@@ -182,7 +182,7 @@ class UserDataHandlerTest extends TestCase
// the CRM leads: the still claimed role survives, the houses are replaced by its own // the CRM leads: the still claimed role survives, the houses are replaced by its own
$this->assertSame(['DKS'], $user->getHotelCodes()); $this->assertSame(['DKS'], $user->getHotelCodes());
$this->assertTrue($user->hasRole('ROLE_ADMIN')); $this->assertTrue($user->hasRole('ROLE_TEAM_ADMIN'));
$this->assertSame('New', $teamer->getFirstName()); $this->assertSame('New', $teamer->getFirstName());
$this->assertSame('Lastname', $teamer->getLastName()); $this->assertSame('Lastname', $teamer->getLastName());
@@ -200,6 +200,37 @@ class UserDataHandlerTest extends TestCase
$this->assertSame(['team' => ['selected' => true]], $teamer->getCrmSelections()); $this->assertSame(['team' => ['selected' => true]], $teamer->getCrmSelections());
} }
/**
* BusPro compares addresses case-insensitively and MyE&P lowercases them on login, so a
* mixed-case address coming back from the API must land lowercased on both the user and
* the teamer - otherwise the login identity and the mailing address drift apart.
*/
public function testUpdateLocalUserNormalizesTheAddressBusProReports(): void
{
$user = (new User())
->setFirstName('Old')
->setLastName('Name')
->setEmail('[email protected]')
->setBusProAddressId(1)
->setBusProPersonId(2)
;
$teamer = (new Teamer())
->setCommunication((new Communication())->setEmail('[email protected]'))
;
$user->setTeamer($teamer);
$profileResponse = $this->createProfileResponse()
->setCommunication((new BusProCommunication())->setEmail('[email protected]'))
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $profileResponse, true, [], ['ROLE_TEAMER'], []);
$this->assertSame('[email protected]', $user->getEmail());
$this->assertSame('[email protected]', $teamer->getCommunication()?->getEmail());
}
/** /**
* @dataProvider pendingRolesProvider * @dataProvider pendingRolesProvider
*/ */
@@ -248,9 +279,9 @@ class UserDataHandlerTest extends TestCase
yield 'a claim beyond the approved role stays pending' => [ yield 'a claim beyond the approved role stays pending' => [
['ROLE_MANAGER'], ['ROLE_MANAGER'],
['ROLE_ADMIN', 'ROLE_MANAGER'], ['ROLE_TEAM_ADMIN', 'ROLE_MANAGER'],
['ROLE_MANAGER'], ['ROLE_MANAGER'],
[User::PENDING_ROLES['ROLE_ADMIN']], [User::PENDING_ROLES['ROLE_TEAM_ADMIN']],
]; ];
yield 'the claimed role changes' => [ yield 'the claimed role changes' => [
@@ -261,7 +292,7 @@ class UserDataHandlerTest extends TestCase
]; ];
yield 'a granted role is revoked once the CRM stops claiming it' => [ yield 'a granted role is revoked once the CRM stops claiming it' => [
['ROLE_ADMIN', 'ROLE_TEAMER'], ['ROLE_TEAM_ADMIN', 'ROLE_TEAMER'],
['ROLE_TEAMER'], ['ROLE_TEAMER'],
['ROLE_TEAMER'], ['ROLE_TEAMER'],
[], [],
@@ -282,14 +313,14 @@ class UserDataHandlerTest extends TestCase
->setFirstName('First') ->setFirstName('First')
->setLastName('Last') ->setLastName('Last')
->setEmail('[email protected]') ->setEmail('[email protected]')
->setRoles([User::PENDING_ROLES['ROLE_ADMIN']]) ->setRoles([User::PENDING_ROLES['ROLE_TEAM_ADMIN']])
; ;
$handler = new UserDataHandler($this->entityManager, $this->logger); $handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), true, [], ['ROLE_ADMIN', 'ROLE_TEAMER']); $handler->updateLocalUser($user, $this->createProfileResponse(), true, [], ['ROLE_TEAM_ADMIN', 'ROLE_TEAMER']);
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles()); $this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
$this->assertSame([User::PENDING_ROLES['ROLE_ADMIN']], $user->getPendingRoles()); $this->assertSame([User::PENDING_ROLES['ROLE_TEAM_ADMIN']], $user->getPendingRoles());
} }
/** /**
@@ -302,13 +333,13 @@ class UserDataHandlerTest extends TestCase
->setFirstName('First') ->setFirstName('First')
->setLastName('Last') ->setLastName('Last')
->setEmail('[email protected]') ->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN', 'ROLE_TEAMER']) ->setRoles(['ROLE_TEAM_ADMIN', 'ROLE_TEAMER'])
; ;
$handler = new UserDataHandler($this->entityManager, $this->logger); $handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), false, [], ['ROLE_ADMIN']); $handler->updateLocalUser($user, $this->createProfileResponse(), false, [], ['ROLE_TEAM_ADMIN']);
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles()); $this->assertSame(['ROLE_TEAM_ADMIN'], $user->getAssignedRoles());
} }
public function testUpdateLocalUserGrantsTheTeamerRoleOnlyOnce(): void public function testUpdateLocalUserGrantsTheTeamerRoleOnlyOnce(): void
@@ -327,8 +358,8 @@ class UserDataHandlerTest extends TestCase
} }
/** /**
* ROLE_SUPER_ADMIN is not a stored role but a flag getRoles() turns into one, so * ROLE_TEAM_SUPER_ADMIN is not a stored role but a flag getRoles() turns into one, so
* revoking ROLE_ADMIN has to take it down explicitly - otherwise the highest privilege * revoking ROLE_TEAM_ADMIN has to take it down explicitly - otherwise the highest privilege
* in the application would outlive the role it depends on. * in the application would outlive the role it depends on.
*/ */
public function testUpdateLocalUserTakesTheSuperAdminFlagDownWithRoleAdmin(): void public function testUpdateLocalUserTakesTheSuperAdminFlagDownWithRoleAdmin(): void
@@ -337,7 +368,7 @@ class UserDataHandlerTest extends TestCase
->setFirstName('First') ->setFirstName('First')
->setLastName('Last') ->setLastName('Last')
->setEmail('[email protected]') ->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN', 'ROLE_TEAMER']) ->setRoles(['ROLE_TEAM_ADMIN', 'ROLE_TEAMER'])
->setSuperAdmin(true) ->setSuperAdmin(true)
; ;
@@ -346,7 +377,7 @@ class UserDataHandlerTest extends TestCase
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles()); $this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
$this->assertFalse($user->isSuperAdmin()); $this->assertFalse($user->isSuperAdmin());
$this->assertFalse($user->hasRole('ROLE_SUPER_ADMIN')); $this->assertFalse($user->hasRole('ROLE_TEAM_SUPER_ADMIN'));
} }
public function testUpdateLocalUserKeepsTheSuperAdminFlagOfAStillClaimedAdmin(): void public function testUpdateLocalUserKeepsTheSuperAdminFlagOfAStillClaimedAdmin(): void
@@ -355,15 +386,15 @@ class UserDataHandlerTest extends TestCase
->setFirstName('First') ->setFirstName('First')
->setLastName('Last') ->setLastName('Last')
->setEmail('[email protected]') ->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN', 'ROLE_TEAMER']) ->setRoles(['ROLE_TEAM_ADMIN', 'ROLE_TEAMER'])
->setSuperAdmin(true) ->setSuperAdmin(true)
; ;
$handler = new UserDataHandler($this->entityManager, $this->logger); $handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), false, [], ['ROLE_ADMIN']); $handler->updateLocalUser($user, $this->createProfileResponse(), false, [], ['ROLE_TEAM_ADMIN']);
$this->assertTrue($user->isSuperAdmin()); $this->assertTrue($user->isSuperAdmin());
$this->assertTrue($user->hasRole('ROLE_SUPER_ADMIN')); $this->assertTrue($user->hasRole('ROLE_TEAM_SUPER_ADMIN'));
} }
public function testUpdateLocalUserReplacesTheHotelCodesWithTheOnesTheCrmReports(): void public function testUpdateLocalUserReplacesTheHotelCodesWithTheOnesTheCrmReports(): void
@@ -394,7 +425,7 @@ class UserDataHandlerTest extends TestCase
->setEmail('[email protected]') ->setEmail('[email protected]')
->setRoles([ ->setRoles([
'ROLE_TEAMER', 'ROLE_TEAMER',
User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_TEAM_ADMIN'],
User::PENDING_ROLES['ROLE_HOUSE_MANAGER'], User::PENDING_ROLES['ROLE_HOUSE_MANAGER'],
]) ])
; ;
@@ -403,8 +434,8 @@ class UserDataHandlerTest extends TestCase
$handler = new UserDataHandler($this->entityManager, $this->logger); $handler = new UserDataHandler($this->entityManager, $this->logger);
$this->assertTrue($handler->approveRole($user, 'ROLE_ADMIN')); $this->assertTrue($handler->approveRole($user, 'ROLE_TEAM_ADMIN'));
$this->assertSame(['ROLE_TEAMER', 'ROLE_ADMIN'], $user->getAssignedRoles()); $this->assertSame(['ROLE_TEAMER', 'ROLE_TEAM_ADMIN'], $user->getAssignedRoles());
// the other nomination is untouched: one decision at a time // the other nomination is untouched: one decision at a time
$this->assertSame([User::PENDING_ROLES['ROLE_HOUSE_MANAGER']], $user->getPendingRoles()); $this->assertSame([User::PENDING_ROLES['ROLE_HOUSE_MANAGER']], $user->getPendingRoles());
@@ -437,13 +468,13 @@ class UserDataHandlerTest extends TestCase
{ {
yield 'the CRM never claimed it' => [['ROLE_TEAMER'], 'ROLE_MANAGER']; yield 'the CRM never claimed it' => [['ROLE_TEAMER'], 'ROLE_MANAGER'];
yield 'a different role is nominated' => [[User::PENDING_ROLES['ROLE_MANAGER']], 'ROLE_ADMIN']; yield 'a different role is nominated' => [[User::PENDING_ROLES['ROLE_MANAGER']], 'ROLE_TEAM_ADMIN'];
yield 'already granted, so there is no marker left' => [['ROLE_ADMIN'], 'ROLE_ADMIN']; yield 'already granted, so there is no marker left' => [['ROLE_TEAM_ADMIN'], 'ROLE_TEAM_ADMIN'];
yield 'teamer has no nomination to approve' => [['ROLE_TEAMER'], 'ROLE_TEAMER']; yield 'teamer has no nomination to approve' => [['ROLE_TEAMER'], 'ROLE_TEAMER'];
yield 'not a role at all' => [[User::PENDING_ROLES['ROLE_ADMIN']], 'ROLE_SUPER_ADMIN']; yield 'not a role at all' => [[User::PENDING_ROLES['ROLE_TEAM_ADMIN']], 'ROLE_TEAM_SUPER_ADMIN'];
} }
public function testDisableForRevokedCrmRolesBlocksTheUserAndDropsThePendingMarkers(): void public function testDisableForRevokedCrmRolesBlocksTheUserAndDropsThePendingMarkers(): void
@@ -452,7 +483,7 @@ class UserDataHandlerTest extends TestCase
->setFirstName('First') ->setFirstName('First')
->setLastName('Last') ->setLastName('Last')
->setEmail('[email protected]') ->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN', User::PENDING_ROLES['ROLE_MANAGER']]) ->setRoles(['ROLE_TEAM_ADMIN', User::PENDING_ROLES['ROLE_MANAGER']])
; ;
$this->entityManager $this->entityManager
@@ -468,7 +499,7 @@ class UserDataHandlerTest extends TestCase
$this->assertSame('Automatisch gesperrt: keine Rollen in BusPro.', $user->getDisabledReasonInternal()); $this->assertSame('Automatisch gesperrt: keine Rollen in BusPro.', $user->getDisabledReasonInternal());
// the granted role is kept so the user stays reviewable, the marker is not // the granted role is kept so the user stays reviewable, the marker is not
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles()); $this->assertSame(['ROLE_TEAM_ADMIN'], $user->getAssignedRoles());
$this->assertSame([], $user->getPendingRoles()); $this->assertSame([], $user->getPendingRoles());
} }
@@ -480,7 +511,7 @@ class UserDataHandlerTest extends TestCase
->setFirstName('First') ->setFirstName('First')
->setLastName('Last') ->setLastName('Last')
->setEmail('[email protected]') ->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN']) ->setRoles(['ROLE_TEAM_ADMIN'])
->setDisabledAt($disabledAt) ->setDisabledAt($disabledAt)
->setDisabledReason('Wegen Fehlverhaltens gesperrt.') ->setDisabledReason('Wegen Fehlverhaltens gesperrt.')
->setDisabledReasonInternal('Siehe Vorgang 4711.') ->setDisabledReasonInternal('Siehe Vorgang 4711.')
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity;
use App\Entity\Contact;
use PHPUnit\Framework\TestCase;
class ContactTest extends TestCase
{
/**
* Office contacts are normalized the same way User::setEmail() normalizes, so that every
* stored address has one shape regardless of which form wrote it.
*/
public function testSetEmailNormalizesCaseAndSurroundingWhitespace(): void
{
$contact = (new Contact())->setEmail(' [email protected] ');
$this->assertSame('[email protected]', $contact->getEmail());
}
}

Some files were not shown because too many files have changed in this diff Show More