High severityNVD Advisory· Published Oct 31, 2023· Updated Aug 2, 2024
Kimai (Authenticated) SSTI to RCE by Uploading a Malicious Twig File
CVE-2023-46245
Description
Kimai is a web-based multi-user time-tracking application. Versions prior to 2.1.0 are vulnerable to a Server-Side Template Injection (SSTI) which can be escalated to Remote Code Execution (RCE). The vulnerability arises when a malicious user uploads a specially crafted Twig file, exploiting the software's PDF and HTML rendering functionalities. Version 2.1.0 enables security measures for custom Twig templates.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
kimai/kimaiPackagist | < 2.1.0 | 2.1.0 |
Affected products
1Patches
1210 files changed · +1783 −1146
composer.json+2 −1 modified@@ -35,7 +35,7 @@ "friendsofsymfony/rest-bundle": "^3.0", "gedmo/doctrine-extensions": "^3.6", "jms/serializer-bundle": "^5.0", - "kevinpapst/tabler-bundle": "^0.22", + "kevinpapst/tabler-bundle": "^1.0", "league/csv": "^9.4", "mpdf/mpdf": "^8.0", "nelmio/api-doc-bundle": "^4.0", @@ -129,6 +129,7 @@ } }, "replace": { + "symfony/polyfill-ctype": "*", "symfony/polyfill-mbstring": "*", "symfony/polyfill-intl": "*", "symfony/polyfill-iconv": "*",
composer.lock+315 −400 modified@@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9159cc3cf1d749c412d1353e921de6fc", + "content-hash": "28da6d01423fca5a17395df418603aa5", "packages": [ { "name": "azuyalabs/yasumi", @@ -485,24 +485,24 @@ }, { "name": "doctrine/collections", - "version": "2.1.3", + "version": "2.1.4", "source": { "type": "git", "url": "https://github.com/doctrine/collections.git", - "reference": "3023e150f90a38843856147b58190aa8b46cc155" + "reference": "72328a11443a0de79967104ad36ba7b30bded134" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/collections/zipball/3023e150f90a38843856147b58190aa8b46cc155", - "reference": "3023e150f90a38843856147b58190aa8b46cc155", + "url": "https://api.github.com/repos/doctrine/collections/zipball/72328a11443a0de79967104ad36ba7b30bded134", + "reference": "72328a11443a0de79967104ad36ba7b30bded134", "shasum": "" }, "require": { "doctrine/deprecations": "^1", "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^10.0", + "doctrine/coding-standard": "^12", "ext-json": "*", "phpstan/phpstan": "^1.8", "phpstan/phpstan-phpunit": "^1.0", @@ -551,7 +551,7 @@ ], "support": { "issues": "https://github.com/doctrine/collections/issues", - "source": "https://github.com/doctrine/collections/tree/2.1.3" + "source": "https://github.com/doctrine/collections/tree/2.1.4" }, "funding": [ { @@ -567,7 +567,7 @@ "type": "tidelift" } ], - "time": "2023-07-06T15:15:36+00:00" + "time": "2023-10-03T09:22:33+00:00" }, { "name": "doctrine/common", @@ -662,16 +662,16 @@ }, { "name": "doctrine/dbal", - "version": "3.6.6", + "version": "3.7.1", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "63646ffd71d1676d2f747f871be31b7e921c7864" + "reference": "5b7bd66c9ff58c04c5474ab85edce442f8081cb2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/63646ffd71d1676d2f747f871be31b7e921c7864", - "reference": "63646ffd71d1676d2f747f871be31b7e921c7864", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/5b7bd66c9ff58c04c5474ab85edce442f8081cb2", + "reference": "5b7bd66c9ff58c04c5474ab85edce442f8081cb2", "shasum": "" }, "require": { @@ -687,9 +687,9 @@ "doctrine/coding-standard": "12.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "1.10.29", + "phpstan/phpstan": "1.10.35", "phpstan/phpstan-strict-rules": "^1.5", - "phpunit/phpunit": "9.6.9", + "phpunit/phpunit": "9.6.13", "psalm/plugin-phpunit": "0.18.4", "slevomat/coding-standard": "8.13.1", "squizlabs/php_codesniffer": "3.7.2", @@ -755,7 +755,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.6.6" + "source": "https://github.com/doctrine/dbal/tree/3.7.1" }, "funding": [ { @@ -771,20 +771,20 @@ "type": "tidelift" } ], - "time": "2023-08-17T05:38:17+00:00" + "time": "2023-10-06T05:06:20+00:00" }, { "name": "doctrine/deprecations", - "version": "v1.1.1", + "version": "1.1.2", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "612a3ee5ab0d5dd97b7cf3874a6efe24325efac3" + "reference": "4f2d4f2836e7ec4e7a8625e75c6aa916004db931" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/612a3ee5ab0d5dd97b7cf3874a6efe24325efac3", - "reference": "612a3ee5ab0d5dd97b7cf3874a6efe24325efac3", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/4f2d4f2836e7ec4e7a8625e75c6aa916004db931", + "reference": "4f2d4f2836e7ec4e7a8625e75c6aa916004db931", "shasum": "" }, "require": { @@ -816,9 +816,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/v1.1.1" + "source": "https://github.com/doctrine/deprecations/tree/1.1.2" }, - "time": "2023-06-03T09:27:29+00:00" + "time": "2023-09-27T20:04:15+00:00" }, { "name": "doctrine/doctrine-bundle", @@ -1706,16 +1706,16 @@ }, { "name": "egulias/email-validator", - "version": "4.0.1", + "version": "4.0.2", "source": { "type": "git", "url": "https://github.com/egulias/EmailValidator.git", - "reference": "3a85486b709bc384dae8eb78fb2eec649bdb64ff" + "reference": "ebaaf5be6c0286928352e054f2d5125608e5405e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/3a85486b709bc384dae8eb78fb2eec649bdb64ff", - "reference": "3a85486b709bc384dae8eb78fb2eec649bdb64ff", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/ebaaf5be6c0286928352e054f2d5125608e5405e", + "reference": "ebaaf5be6c0286928352e054f2d5125608e5405e", "shasum": "" }, "require": { @@ -1724,8 +1724,8 @@ "symfony/polyfill-intl-idn": "^1.26" }, "require-dev": { - "phpunit/phpunit": "^9.5.27", - "vimeo/psalm": "^4.30" + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" }, "suggest": { "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" @@ -1761,28 +1761,28 @@ ], "support": { "issues": "https://github.com/egulias/EmailValidator/issues", - "source": "https://github.com/egulias/EmailValidator/tree/4.0.1" + "source": "https://github.com/egulias/EmailValidator/tree/4.0.2" }, "funding": [ { "url": "https://github.com/egulias", "type": "github" } ], - "time": "2023-01-14T14:17:03+00:00" + "time": "2023-10-06T06:47:41+00:00" }, { "name": "endroid/qr-code", - "version": "4.8.4", + "version": "4.8.5", "source": { "type": "git", "url": "https://github.com/endroid/qr-code.git", - "reference": "a122b85d4a5a3257d471257a43ac3e5676a27ffe" + "reference": "0db25b506a8411a5e1644ebaa67123a6eb7b6a77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/endroid/qr-code/zipball/a122b85d4a5a3257d471257a43ac3e5676a27ffe", - "reference": "a122b85d4a5a3257d471257a43ac3e5676a27ffe", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/0db25b506a8411a5e1644ebaa67123a6eb7b6a77", + "reference": "0db25b506a8411a5e1644ebaa67123a6eb7b6a77", "shasum": "" }, "require": { @@ -1836,15 +1836,15 @@ ], "support": { "issues": "https://github.com/endroid/qr-code/issues", - "source": "https://github.com/endroid/qr-code/tree/4.8.4" + "source": "https://github.com/endroid/qr-code/tree/4.8.5" }, "funding": [ { "url": "https://github.com/endroid", "type": "github" } ], - "time": "2023-08-28T18:12:07+00:00" + "time": "2023-09-29T14:03:20+00:00" }, { "name": "erusev/parsedown", @@ -1959,59 +1959,58 @@ }, { "name": "friendsofsymfony/rest-bundle", - "version": "3.5.0", + "version": "3.6.0", "source": { "type": "git", "url": "https://github.com/FriendsOfSymfony/FOSRestBundle.git", - "reference": "893f3a01e4d88789abc399c6f1b3cfff79238734" + "reference": "e01be8113d4451adb3cbb29d7d2cc96bbc698179" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfSymfony/FOSRestBundle/zipball/893f3a01e4d88789abc399c6f1b3cfff79238734", - "reference": "893f3a01e4d88789abc399c6f1b3cfff79238734", + "url": "https://api.github.com/repos/FriendsOfSymfony/FOSRestBundle/zipball/e01be8113d4451adb3cbb29d7d2cc96bbc698179", + "reference": "e01be8113d4451adb3cbb29d7d2cc96bbc698179", "shasum": "" }, "require": { "php": "^7.2|^8.0", - "symfony/config": "^4.4|^5.3|^6.0", - "symfony/dependency-injection": "^4.4|^5.3|^6.0", - "symfony/event-dispatcher": "^4.4|^5.3|^6.0", + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/event-dispatcher": "^5.4|^6.0", "symfony/framework-bundle": "^4.4.1|^5.0|^6.0", - "symfony/http-foundation": "^4.4|^5.3|^6.0", - "symfony/http-kernel": "^4.4|^5.3|^6.0", - "symfony/routing": "^4.4|^5.3|^6.0", - "symfony/security-core": "^4.4|^5.3|^6.0", + "symfony/http-foundation": "^5.4|^6.0", + "symfony/http-kernel": "^5.4|^6.0", + "symfony/routing": "^5.4|^6.0", + "symfony/security-core": "^5.4|^6.0", "willdurand/jsonp-callback-validator": "^1.0|^2.0", "willdurand/negotiation": "^2.0|^3.0" }, "conflict": { "doctrine/annotations": "<1.12", "jms/serializer": "<1.13.0", "jms/serializer-bundle": "<2.4.3|3.0.0", - "sensio/framework-extra-bundle": "<6.1", - "symfony/error-handler": "<4.4.1" + "sensio/framework-extra-bundle": "<6.1" }, "require-dev": { - "doctrine/annotations": "^1.13.2", + "doctrine/annotations": "^1.13.2|^2.0 ", "friendsofphp/php-cs-fixer": "^3.0", "jms/serializer": "^1.13|^2.0|^3.0", "jms/serializer-bundle": "^2.4.3|^3.0.1|^4.0|^5.0", "psr/http-message": "^1.0", "psr/log": "^1.0|^2.0|^3.0", "sensio/framework-extra-bundle": "^6.1", - "symfony/asset": "^4.4|^5.3|^6.0", - "symfony/browser-kit": "^4.4|^5.3|^6.0", - "symfony/css-selector": "^4.4|^5.3|^6.0", - "symfony/expression-language": "^4.4|^5.3|^6.0", - "symfony/form": "^4.4|^5.3|^6.0", - "symfony/mime": "^4.4|^5.3|^6.0", - "symfony/phpunit-bridge": "^5.3|^6.0", - "symfony/security-bundle": "^4.4|^5.3|^6.0", - "symfony/serializer": "^4.4|^5.3|^6.0", - "symfony/twig-bundle": "^4.4|^5.3|^6.0", - "symfony/validator": "^4.4|^5.3|^6.0", - "symfony/web-profiler-bundle": "^4.4|^5.3|^6.0", - "symfony/yaml": "^4.4|^5.3|^6.0" + "symfony/asset": "^5.4|^6.0", + "symfony/browser-kit": "^5.4|^6.0", + "symfony/css-selector": "^5.4|^6.0", + "symfony/expression-language": "^5.4|^6.0", + "symfony/form": "^5.4|^6.0", + "symfony/mime": "^5.4|^6.0", + "symfony/phpunit-bridge": "^5.4|^6.0", + "symfony/security-bundle": "^5.4|^6.0", + "symfony/serializer": "^5.4|^6.0", + "symfony/twig-bundle": "^5.4|^6.0", + "symfony/validator": "^5.4|^6.0", + "symfony/web-profiler-bundle": "^5.4|^6.0", + "symfony/yaml": "^5.4|^6.0" }, "suggest": { "jms/serializer-bundle": "Add support for advanced serialization capabilities, recommended, requires ^2.0|^3.0", @@ -2059,9 +2058,9 @@ ], "support": { "issues": "https://github.com/FriendsOfSymfony/FOSRestBundle/issues", - "source": "https://github.com/FriendsOfSymfony/FOSRestBundle/tree/3.5.0" + "source": "https://github.com/FriendsOfSymfony/FOSRestBundle/tree/3.6.0" }, - "time": "2023-01-06T15:33:47+00:00" + "time": "2023-09-27T11:41:02+00:00" }, { "name": "gedmo/doctrine-extensions", @@ -2439,41 +2438,40 @@ }, { "name": "kevinpapst/tabler-bundle", - "version": "0.22", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/kevinpapst/TablerBundle.git", - "reference": "fdc3c182f13dec439ce5110dededba2895518f8f" + "reference": "9554efc6f96d757decbc4b52402eb964e9c90786" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/kevinpapst/TablerBundle/zipball/fdc3c182f13dec439ce5110dededba2895518f8f", - "reference": "fdc3c182f13dec439ce5110dededba2895518f8f", + "url": "https://api.github.com/repos/kevinpapst/TablerBundle/zipball/9554efc6f96d757decbc4b52402eb964e9c90786", + "reference": "9554efc6f96d757decbc4b52402eb964e9c90786", "shasum": "" }, "require": { - "php": ">=7.4", - "symfony/asset": "^4.4 || ^5.4 || ^6.0", - "symfony/config": "^4.4 || ^5.4 || ^6.0", - "symfony/dependency-injection": "^4.4 || ^5.4 || ^6.0", - "symfony/event-dispatcher": "^4.4 || ^5.4 || ^6.0", - "symfony/http-foundation": "^4.4 || ^5.4 || ^6.0", - "symfony/http-kernel": "^4.4 || ^5.4 || ^6.0", - "symfony/options-resolver": "^4.4 || ^5.4 || ^6.0", - "symfony/security-core": "^4.4 || ^5.4 || ^6.0", - "symfony/translation": "^4.4 || ^5.4 || ^6.0", - "symfony/twig-bridge": "^4.4 || ^5.4 || ^6.0", + "php": "8.1.*||8.2.*", + "symfony/asset": "^6.0", + "symfony/config": "^6.0", + "symfony/dependency-injection": "^6.0", + "symfony/event-dispatcher": "^6.0", + "symfony/http-foundation": "^6.0", + "symfony/http-kernel": "^6.0", + "symfony/options-resolver": "^6.0", + "symfony/security-core": "^6.0", + "symfony/translation": "^6.0", + "symfony/twig-bridge": "^6.0", "twig/twig": "^3.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", "knplabs/knp-menu-bundle": "^3.0", - "phpspec/prophecy": "^1.6", "phpstan/phpstan": "^1.0", "phpstan/phpstan-phpunit": "^1.0", "phpstan/phpstan-symfony": "^1.0", "phpunit/phpunit": "^9.0", - "symfony/framework-bundle": "^4.4 || ^5.4 || ^6.0" + "symfony/framework-bundle": "^6.0" }, "suggest": { "knplabs/knp-menu-bundle": "Allows easy menu integration" @@ -2497,7 +2495,7 @@ "description": "Admin/Backend theme bundle for Symfony based on Tabler.io", "support": { "issues": "https://github.com/kevinpapst/TablerBundle/issues", - "source": "https://github.com/kevinpapst/TablerBundle/tree/0.22" + "source": "https://github.com/kevinpapst/TablerBundle/tree/1.0.0" }, "funding": [ { @@ -2509,37 +2507,37 @@ "type": "github" } ], - "time": "2023-06-15T06:00:11+00:00" + "time": "2023-09-27T13:27:36+00:00" }, { "name": "laminas/laminas-escaper", - "version": "2.12.0", + "version": "2.13.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-escaper.git", - "reference": "ee7a4c37bf3d0e8c03635d5bddb5bb3184ead490" + "reference": "af459883f4018d0f8a0c69c7a209daef3bf973ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/ee7a4c37bf3d0e8c03635d5bddb5bb3184ead490", - "reference": "ee7a4c37bf3d0e8c03635d5bddb5bb3184ead490", + "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/af459883f4018d0f8a0c69c7a209daef3bf973ba", + "reference": "af459883f4018d0f8a0c69c7a209daef3bf973ba", "shasum": "" }, "require": { "ext-ctype": "*", "ext-mbstring": "*", - "php": "^7.4 || ~8.0.0 || ~8.1.0 || ~8.2.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0" }, "conflict": { "zendframework/zend-escaper": "*" }, "require-dev": { - "infection/infection": "^0.26.6", - "laminas/laminas-coding-standard": "~2.4.0", + "infection/infection": "^0.27.0", + "laminas/laminas-coding-standard": "~2.5.0", "maglnet/composer-require-checker": "^3.8.0", - "phpunit/phpunit": "^9.5.18", - "psalm/plugin-phpunit": "^0.17.0", - "vimeo/psalm": "^4.22.0" + "phpunit/phpunit": "^9.6.7", + "psalm/plugin-phpunit": "^0.18.4", + "vimeo/psalm": "^5.9" }, "type": "library", "autoload": { @@ -2571,20 +2569,20 @@ "type": "community_bridge" } ], - "time": "2022-10-10T10:11:09+00:00" + "time": "2023-10-10T08:35:13+00:00" }, { "name": "league/csv", - "version": "9.10.0", + "version": "9.11.0", "source": { "type": "git", "url": "https://github.com/thephpleague/csv.git", - "reference": "d24b0d484812313b07ab74b0fe4db9661606df6c" + "reference": "33149c4bea4949aa4fa3d03fb11ed28682168b39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/csv/zipball/d24b0d484812313b07ab74b0fe4db9661606df6c", - "reference": "d24b0d484812313b07ab74b0fe4db9661606df6c", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/33149c4bea4949aa4fa3d03fb11ed28682168b39", + "reference": "33149c4bea4949aa4fa3d03fb11ed28682168b39", "shasum": "" }, "require": { @@ -2659,7 +2657,7 @@ "type": "github" } ], - "time": "2023-08-04T15:12:48+00:00" + "time": "2023-09-23T10:09:54+00:00" }, { "name": "lorenzo/pinky", @@ -3086,16 +3084,16 @@ }, { "name": "mpdf/psr-http-message-shim", - "version": "2.0.0", + "version": "v2.0.1", "source": { "type": "git", "url": "https://github.com/mpdf/psr-http-message-shim.git", - "reference": "1cf4c0b68b8461cea27411ff961482ce7687e34f" + "reference": "f25a0153d645e234f9db42e5433b16d9b113920f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mpdf/psr-http-message-shim/zipball/1cf4c0b68b8461cea27411ff961482ce7687e34f", - "reference": "1cf4c0b68b8461cea27411ff961482ce7687e34f", + "url": "https://api.github.com/repos/mpdf/psr-http-message-shim/zipball/f25a0153d645e234f9db42e5433b16d9b113920f", + "reference": "f25a0153d645e234f9db42e5433b16d9b113920f", "shasum": "" }, "require": { @@ -3128,9 +3126,9 @@ "description": "Shim to allow support of different psr/message versions.", "support": { "issues": "https://github.com/mpdf/psr-http-message-shim/issues", - "source": "https://github.com/mpdf/psr-http-message-shim/tree/2.0.0" + "source": "https://github.com/mpdf/psr-http-message-shim/tree/v2.0.1" }, - "time": "2023-09-01T06:08:18+00:00" + "time": "2023-10-02T14:34:03+00:00" }, { "name": "mpdf/psr-log-aware-trait", @@ -4068,16 +4066,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.24.1", + "version": "1.24.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "9f854d275c2dbf84915a5c0ec9a2d17d2cd86b01" + "reference": "bcad8d995980440892759db0c32acae7c8e79442" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9f854d275c2dbf84915a5c0ec9a2d17d2cd86b01", - "reference": "9f854d275c2dbf84915a5c0ec9a2d17d2cd86b01", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/bcad8d995980440892759db0c32acae7c8e79442", + "reference": "bcad8d995980440892759db0c32acae7c8e79442", "shasum": "" }, "require": { @@ -4109,9 +4107,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.24.1" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.24.2" }, - "time": "2023-09-18T12:18:02+00:00" + "time": "2023-09-26T12:28:12+00:00" }, { "name": "psr/cache", @@ -4315,16 +4313,16 @@ }, { "name": "psr/http-client", - "version": "1.0.2", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/php-fig/http-client.git", - "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31" + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/0955afe48220520692d2d09f7ab7e0f93ffd6a31", - "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", "shasum": "" }, "require": { @@ -4361,9 +4359,9 @@ "psr-18" ], "support": { - "source": "https://github.com/php-fig/http-client/tree/1.0.2" + "source": "https://github.com/php-fig/http-client" }, - "time": "2023-04-10T20:12:12+00:00" + "time": "2023-09-23T14:17:50+00:00" }, { "name": "psr/http-factory", @@ -4785,16 +4783,16 @@ }, { "name": "setasign/fpdi", - "version": "v2.4.1", + "version": "v2.5.0", "source": { "type": "git", "url": "https://github.com/Setasign/FPDI.git", - "reference": "f4ba73e5bc053ccc90b81717c5df1cb2ea7bae7b" + "reference": "ecf0459643ec963febfb9a5d529dcd93656006a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Setasign/FPDI/zipball/f4ba73e5bc053ccc90b81717c5df1cb2ea7bae7b", - "reference": "f4ba73e5bc053ccc90b81717c5df1cb2ea7bae7b", + "url": "https://api.github.com/repos/Setasign/FPDI/zipball/ecf0459643ec963febfb9a5d529dcd93656006a4", + "reference": "ecf0459643ec963febfb9a5d529dcd93656006a4", "shasum": "" }, "require": { @@ -4845,15 +4843,15 @@ ], "support": { "issues": "https://github.com/Setasign/FPDI/issues", - "source": "https://github.com/Setasign/FPDI/tree/v2.4.1" + "source": "https://github.com/Setasign/FPDI/tree/v2.5.0" }, "funding": [ { "url": "https://tidelift.com/funding/github/packagist/setasign/fpdi", "type": "tidelift" } ], - "time": "2023-07-27T08:12:09+00:00" + "time": "2023-09-28T10:46:27+00:00" }, { "name": "spomky-labs/otphp", @@ -5006,16 +5004,16 @@ }, { "name": "symfony/cache", - "version": "v6.3.4", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "e60d00b4f633efa4c1ef54e77c12762d9073e7b3" + "reference": "6c1a3ea078c4d88ee892530945df63a87981b2da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/e60d00b4f633efa4c1ef54e77c12762d9073e7b3", - "reference": "e60d00b4f633efa4c1ef54e77c12762d9073e7b3", + "url": "https://api.github.com/repos/symfony/cache/zipball/6c1a3ea078c4d88ee892530945df63a87981b2da", + "reference": "6c1a3ea078c4d88ee892530945df63a87981b2da", "shasum": "" }, "require": { @@ -5082,7 +5080,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.3.4" + "source": "https://github.com/symfony/cache/tree/v6.3.5" }, "funding": [ { @@ -5098,7 +5096,7 @@ "type": "tidelift" } ], - "time": "2023-08-05T09:10:27+00:00" + "time": "2023-09-26T15:48:55+00:00" }, { "name": "symfony/cache-contracts", @@ -5481,16 +5479,16 @@ }, { "name": "symfony/dependency-injection", - "version": "v6.3.4", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "68a5a9570806a087982f383f6109c5e925892a49" + "reference": "2ed62b3bf98346e1f45529a7b6be2196739bb993" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/68a5a9570806a087982f383f6109c5e925892a49", - "reference": "68a5a9570806a087982f383f6109c5e925892a49", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/2ed62b3bf98346e1f45529a7b6be2196739bb993", + "reference": "2ed62b3bf98346e1f45529a7b6be2196739bb993", "shasum": "" }, "require": { @@ -5542,7 +5540,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.3.4" + "source": "https://github.com/symfony/dependency-injection/tree/v6.3.5" }, "funding": [ { @@ -5558,7 +5556,7 @@ "type": "tidelift" } ], - "time": "2023-08-16T17:55:17+00:00" + "time": "2023-09-25T16:46:40+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5629,16 +5627,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v6.3.4", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "589eeeb93669739ec1d8bd4593e4972d94e0981d" + "reference": "9977eb1adf999ceded213e88c1ac6dff7a1a0306" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/589eeeb93669739ec1d8bd4593e4972d94e0981d", - "reference": "589eeeb93669739ec1d8bd4593e4972d94e0981d", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/9977eb1adf999ceded213e88c1ac6dff7a1a0306", + "reference": "9977eb1adf999ceded213e88c1ac6dff7a1a0306", "shasum": "" }, "require": { @@ -5719,7 +5717,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v6.3.4" + "source": "https://github.com/symfony/doctrine-bridge/tree/v6.3.5" }, "funding": [ { @@ -5735,7 +5733,7 @@ "type": "tidelift" } ], - "time": "2023-08-08T10:40:25+00:00" + "time": "2023-09-29T16:16:03+00:00" }, { "name": "symfony/dotenv", @@ -5813,16 +5811,16 @@ }, { "name": "symfony/error-handler", - "version": "v6.3.2", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "85fd65ed295c4078367c784e8a5a6cee30348b7a" + "reference": "1f69476b64fb47105c06beef757766c376b548c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/85fd65ed295c4078367c784e8a5a6cee30348b7a", - "reference": "85fd65ed295c4078367c784e8a5a6cee30348b7a", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/1f69476b64fb47105c06beef757766c376b548c4", + "reference": "1f69476b64fb47105c06beef757766c376b548c4", "shasum": "" }, "require": { @@ -5867,7 +5865,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.3.2" + "source": "https://github.com/symfony/error-handler/tree/v6.3.5" }, "funding": [ { @@ -5883,7 +5881,7 @@ "type": "tidelift" } ], - "time": "2023-07-16T17:05:46+00:00" + "time": "2023-09-12T06:57:20+00:00" }, { "name": "symfony/event-dispatcher", @@ -6170,16 +6168,16 @@ }, { "name": "symfony/finder", - "version": "v6.3.3", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "9915db259f67d21eefee768c1abcf1cc61b1fc9e" + "reference": "a1b31d88c0e998168ca7792f222cbecee47428c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/9915db259f67d21eefee768c1abcf1cc61b1fc9e", - "reference": "9915db259f67d21eefee768c1abcf1cc61b1fc9e", + "url": "https://api.github.com/repos/symfony/finder/zipball/a1b31d88c0e998168ca7792f222cbecee47428c4", + "reference": "a1b31d88c0e998168ca7792f222cbecee47428c4", "shasum": "" }, "require": { @@ -6214,7 +6212,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.3.3" + "source": "https://github.com/symfony/finder/tree/v6.3.5" }, "funding": [ { @@ -6230,7 +6228,7 @@ "type": "tidelift" } ], - "time": "2023-07-31T08:31:44+00:00" + "time": "2023-09-26T12:56:25+00:00" }, { "name": "symfony/flex", @@ -6299,16 +6297,16 @@ }, { "name": "symfony/form", - "version": "v6.3.2", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "afdadf511e08bc6d4752afb869ce084276aca4e2" + "reference": "0f9ad8600c1021983d096512066ee54332aa3139" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/afdadf511e08bc6d4752afb869ce084276aca4e2", - "reference": "afdadf511e08bc6d4752afb869ce084276aca4e2", + "url": "https://api.github.com/repos/symfony/form/zipball/0f9ad8600c1021983d096512066ee54332aa3139", + "reference": "0f9ad8600c1021983d096512066ee54332aa3139", "shasum": "" }, "require": { @@ -6376,7 +6374,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v6.3.2" + "source": "https://github.com/symfony/form/tree/v6.3.5" }, "funding": [ { @@ -6392,20 +6390,20 @@ "type": "tidelift" } ], - "time": "2023-07-26T17:39:03+00:00" + "time": "2023-09-10T17:47:23+00:00" }, { "name": "symfony/framework-bundle", - "version": "v6.3.4", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "f822f54ff05cd88878910b4559f66c12176d952c" + "reference": "567cafcfc08e3076b47290a7558b0ca17a98b0ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/f822f54ff05cd88878910b4559f66c12176d952c", - "reference": "f822f54ff05cd88878910b4559f66c12176d952c", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/567cafcfc08e3076b47290a7558b0ca17a98b0ce", + "reference": "567cafcfc08e3076b47290a7558b0ca17a98b0ce", "shasum": "" }, "require": { @@ -6520,7 +6518,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v6.3.4" + "source": "https://github.com/symfony/framework-bundle/tree/v6.3.5" }, "funding": [ { @@ -6536,20 +6534,20 @@ "type": "tidelift" } ], - "time": "2023-08-16T18:04:38+00:00" + "time": "2023-09-29T10:45:15+00:00" }, { "name": "symfony/http-client", - "version": "v6.3.2", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "15f9f4bad62bfcbe48b5dedd866f04a08fc7ff00" + "reference": "213e564da4cbf61acc9728d97e666bcdb868c10d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/15f9f4bad62bfcbe48b5dedd866f04a08fc7ff00", - "reference": "15f9f4bad62bfcbe48b5dedd866f04a08fc7ff00", + "url": "https://api.github.com/repos/symfony/http-client/zipball/213e564da4cbf61acc9728d97e666bcdb868c10d", + "reference": "213e564da4cbf61acc9728d97e666bcdb868c10d", "shasum": "" }, "require": { @@ -6612,7 +6610,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.3.2" + "source": "https://github.com/symfony/http-client/tree/v6.3.5" }, "funding": [ { @@ -6628,7 +6626,7 @@ "type": "tidelift" } ], - "time": "2023-07-05T08:41:27+00:00" + "time": "2023-09-29T15:57:12+00:00" }, { "name": "symfony/http-client-contracts", @@ -6710,16 +6708,16 @@ }, { "name": "symfony/http-foundation", - "version": "v6.3.4", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "cac1556fdfdf6719668181974104e6fcfa60e844" + "reference": "b50f5e281d722cb0f4c296f908bacc3e2b721957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/cac1556fdfdf6719668181974104e6fcfa60e844", - "reference": "cac1556fdfdf6719668181974104e6fcfa60e844", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/b50f5e281d722cb0f4c296f908bacc3e2b721957", + "reference": "b50f5e281d722cb0f4c296f908bacc3e2b721957", "shasum": "" }, "require": { @@ -6767,7 +6765,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.3.4" + "source": "https://github.com/symfony/http-foundation/tree/v6.3.5" }, "funding": [ { @@ -6783,20 +6781,20 @@ "type": "tidelift" } ], - "time": "2023-08-22T08:20:46+00:00" + "time": "2023-09-04T21:33:54+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.3.4", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "36abb425b4af863ae1fe54d8a8b8b4c76a2bccdb" + "reference": "9f991a964368bee8d883e8d57ced4fe9fff04dfc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/36abb425b4af863ae1fe54d8a8b8b4c76a2bccdb", - "reference": "36abb425b4af863ae1fe54d8a8b8b4c76a2bccdb", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9f991a964368bee8d883e8d57ced4fe9fff04dfc", + "reference": "9f991a964368bee8d883e8d57ced4fe9fff04dfc", "shasum": "" }, "require": { @@ -6880,7 +6878,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.3.4" + "source": "https://github.com/symfony/http-kernel/tree/v6.3.5" }, "funding": [ { @@ -6896,7 +6894,7 @@ "type": "tidelift" } ], - "time": "2023-08-26T13:54:49+00:00" + "time": "2023-09-30T06:37:04+00:00" }, { "name": "symfony/intl", @@ -6982,16 +6980,16 @@ }, { "name": "symfony/mailer", - "version": "v6.3.0", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "7b03d9be1dea29bfec0a6c7b603f5072a4c97435" + "reference": "d89611a7830d51b5e118bca38e390dea92f9ea06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/7b03d9be1dea29bfec0a6c7b603f5072a4c97435", - "reference": "7b03d9be1dea29bfec0a6c7b603f5072a4c97435", + "url": "https://api.github.com/repos/symfony/mailer/zipball/d89611a7830d51b5e118bca38e390dea92f9ea06", + "reference": "d89611a7830d51b5e118bca38e390dea92f9ea06", "shasum": "" }, "require": { @@ -7042,7 +7040,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v6.3.0" + "source": "https://github.com/symfony/mailer/tree/v6.3.5" }, "funding": [ { @@ -7058,20 +7056,20 @@ "type": "tidelift" } ], - "time": "2023-05-29T12:49:39+00:00" + "time": "2023-09-06T09:47:15+00:00" }, { "name": "symfony/mime", - "version": "v6.3.3", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "9a0cbd52baa5ba5a5b1f0cacc59466f194730f98" + "reference": "d5179eedf1cb2946dbd760475ebf05c251ef6a6e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/9a0cbd52baa5ba5a5b1f0cacc59466f194730f98", - "reference": "9a0cbd52baa5ba5a5b1f0cacc59466f194730f98", + "url": "https://api.github.com/repos/symfony/mime/zipball/d5179eedf1cb2946dbd760475ebf05c251ef6a6e", + "reference": "d5179eedf1cb2946dbd760475ebf05c251ef6a6e", "shasum": "" }, "require": { @@ -7126,7 +7124,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.3.3" + "source": "https://github.com/symfony/mime/tree/v6.3.5" }, "funding": [ { @@ -7142,7 +7140,7 @@ "type": "tidelift" } ], - "time": "2023-07-31T07:08:24+00:00" + "time": "2023-09-29T06:59:36+00:00" }, { "name": "symfony/monolog-bridge", @@ -7372,16 +7370,16 @@ }, { "name": "symfony/password-hasher", - "version": "v6.3.0", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/password-hasher.git", - "reference": "d23ad221989e6b8278d050cabfd7b569eee84590" + "reference": "278d3a49715073879f75e372ad80b8cfeca949d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/password-hasher/zipball/d23ad221989e6b8278d050cabfd7b569eee84590", - "reference": "d23ad221989e6b8278d050cabfd7b569eee84590", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/278d3a49715073879f75e372ad80b8cfeca949d3", + "reference": "278d3a49715073879f75e372ad80b8cfeca949d3", "shasum": "" }, "require": { @@ -7424,7 +7422,7 @@ "password" ], "support": { - "source": "https://github.com/symfony/password-hasher/tree/v6.3.0" + "source": "https://github.com/symfony/password-hasher/tree/v6.3.5" }, "funding": [ { @@ -7440,89 +7438,7 @@ "type": "tidelift" } ], - "time": "2023-02-14T09:04:20+00:00" - }, - { - "name": "symfony/polyfill-ctype", - "version": "v1.28.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", - "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "provide": { - "ext-ctype": "*" - }, - "suggest": { - "ext-ctype": "For best performance" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for ctype functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" - ], - "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.28.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2023-09-25T17:05:16+00:00" }, { "name": "symfony/polyfill-intl-grapheme", @@ -8175,16 +8091,16 @@ }, { "name": "symfony/routing", - "version": "v6.3.3", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "e7243039ab663822ff134fbc46099b5fdfa16f6a" + "reference": "82616e59acd3e3d9c916bba798326cb7796d7d31" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/e7243039ab663822ff134fbc46099b5fdfa16f6a", - "reference": "e7243039ab663822ff134fbc46099b5fdfa16f6a", + "url": "https://api.github.com/repos/symfony/routing/zipball/82616e59acd3e3d9c916bba798326cb7796d7d31", + "reference": "82616e59acd3e3d9c916bba798326cb7796d7d31", "shasum": "" }, "require": { @@ -8238,7 +8154,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.3.3" + "source": "https://github.com/symfony/routing/tree/v6.3.5" }, "funding": [ { @@ -8254,7 +8170,7 @@ "type": "tidelift" } ], - "time": "2023-07-31T07:08:24+00:00" + "time": "2023-09-20T16:05:51+00:00" }, { "name": "symfony/runtime", @@ -8337,16 +8253,16 @@ }, { "name": "symfony/security-bundle", - "version": "v6.3.4", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "31957477b289220a47880ead3727bf5cc059fa08" + "reference": "2df460eacceb11b9287cfafddda4d27023dd9001" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/31957477b289220a47880ead3727bf5cc059fa08", - "reference": "31957477b289220a47880ead3727bf5cc059fa08", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/2df460eacceb11b9287cfafddda4d27023dd9001", + "reference": "2df460eacceb11b9287cfafddda4d27023dd9001", "shasum": "" }, "require": { @@ -8363,7 +8279,7 @@ "symfony/password-hasher": "^5.4|^6.0", "symfony/security-core": "^6.2", "symfony/security-csrf": "^5.4|^6.0", - "symfony/security-http": "^6.3" + "symfony/security-http": "^6.3.4" }, "conflict": { "symfony/browser-kit": "<5.4", @@ -8427,7 +8343,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v6.3.4" + "source": "https://github.com/symfony/security-bundle/tree/v6.3.5" }, "funding": [ { @@ -8443,20 +8359,20 @@ "type": "tidelift" } ], - "time": "2023-08-25T08:46:23+00:00" + "time": "2023-09-25T17:05:55+00:00" }, { "name": "symfony/security-core", - "version": "v6.3.3", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "b86ce012cc9a62a15ed43af5037eebc3e6de4d7f" + "reference": "ec8f24dc1195f46483510892271d01a5202bba70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/b86ce012cc9a62a15ed43af5037eebc3e6de4d7f", - "reference": "b86ce012cc9a62a15ed43af5037eebc3e6de4d7f", + "url": "https://api.github.com/repos/symfony/security-core/zipball/ec8f24dc1195f46483510892271d01a5202bba70", + "reference": "ec8f24dc1195f46483510892271d01a5202bba70", "shasum": "" }, "require": { @@ -8512,7 +8428,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v6.3.3" + "source": "https://github.com/symfony/security-core/tree/v6.3.5" }, "funding": [ { @@ -8528,7 +8444,7 @@ "type": "tidelift" } ], - "time": "2023-07-31T07:08:24+00:00" + "time": "2023-09-10T17:47:23+00:00" }, { "name": "symfony/security-csrf", @@ -8600,16 +8516,16 @@ }, { "name": "symfony/security-http", - "version": "v6.3.4", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "0afb37c1120c1c46219bdbd1dd912fb4d48eaf7d" + "reference": "47058ea557a4c64ba86e9249651222842bd52e2a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/0afb37c1120c1c46219bdbd1dd912fb4d48eaf7d", - "reference": "0afb37c1120c1c46219bdbd1dd912fb4d48eaf7d", + "url": "https://api.github.com/repos/symfony/security-http/zipball/47058ea557a4c64ba86e9249651222842bd52e2a", + "reference": "47058ea557a4c64ba86e9249651222842bd52e2a", "shasum": "" }, "require": { @@ -8667,7 +8583,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v6.3.4" + "source": "https://github.com/symfony/security-http/tree/v6.3.5" }, "funding": [ { @@ -8683,20 +8599,20 @@ "type": "tidelift" } ], - "time": "2023-08-25T19:43:09+00:00" + "time": "2023-08-30T06:30:46+00:00" }, { "name": "symfony/serializer", - "version": "v6.3.4", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "96d28a58d5a128bf77c54534b380eb7c92c8f846" + "reference": "855fc058c8bdbb69f53834f2fdb3876c9bc0ab7c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/96d28a58d5a128bf77c54534b380eb7c92c8f846", - "reference": "96d28a58d5a128bf77c54534b380eb7c92c8f846", + "url": "https://api.github.com/repos/symfony/serializer/zipball/855fc058c8bdbb69f53834f2fdb3876c9bc0ab7c", + "reference": "855fc058c8bdbb69f53834f2fdb3876c9bc0ab7c", "shasum": "" }, "require": { @@ -8761,7 +8677,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v6.3.4" + "source": "https://github.com/symfony/serializer/tree/v6.3.5" }, "funding": [ { @@ -8777,7 +8693,7 @@ "type": "tidelift" } ], - "time": "2023-08-24T14:35:28+00:00" + "time": "2023-09-29T16:18:53+00:00" }, { "name": "symfony/service-contracts", @@ -8925,16 +8841,16 @@ }, { "name": "symfony/string", - "version": "v6.3.2", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "53d1a83225002635bca3482fcbf963001313fb68" + "reference": "13d76d0fb049051ed12a04bef4f9de8715bea339" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/53d1a83225002635bca3482fcbf963001313fb68", - "reference": "53d1a83225002635bca3482fcbf963001313fb68", + "url": "https://api.github.com/repos/symfony/string/zipball/13d76d0fb049051ed12a04bef4f9de8715bea339", + "reference": "13d76d0fb049051ed12a04bef4f9de8715bea339", "shasum": "" }, "require": { @@ -8991,7 +8907,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.3.2" + "source": "https://github.com/symfony/string/tree/v6.3.5" }, "funding": [ { @@ -9007,7 +8923,7 @@ "type": "tidelift" } ], - "time": "2023-07-05T08:41:27+00:00" + "time": "2023-09-18T10:38:32+00:00" }, { "name": "symfony/translation", @@ -9184,16 +9100,16 @@ }, { "name": "symfony/twig-bridge", - "version": "v6.3.2", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "6f8435db76a2d79917489a19a82679276c1b4e32" + "reference": "18f2cbe1d46ad43c4d3bd45e5e6279172068e064" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/6f8435db76a2d79917489a19a82679276c1b4e32", - "reference": "6f8435db76a2d79917489a19a82679276c1b4e32", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/18f2cbe1d46ad43c4d3bd45e5e6279172068e064", + "reference": "18f2cbe1d46ad43c4d3bd45e5e6279172068e064", "shasum": "" }, "require": { @@ -9272,7 +9188,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v6.3.2" + "source": "https://github.com/symfony/twig-bridge/tree/v6.3.5" }, "funding": [ { @@ -9288,7 +9204,7 @@ "type": "tidelift" } ], - "time": "2023-07-20T16:42:33+00:00" + "time": "2023-09-12T06:57:20+00:00" }, { "name": "symfony/twig-bundle", @@ -9377,16 +9293,16 @@ }, { "name": "symfony/validator", - "version": "v6.3.4", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "0c8435154920b9bbe93bece675234c244cadf73b" + "reference": "48e815ba3b5eb72e632588dbf7ea2dc4e608ee47" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/0c8435154920b9bbe93bece675234c244cadf73b", - "reference": "0c8435154920b9bbe93bece675234c244cadf73b", + "url": "https://api.github.com/repos/symfony/validator/zipball/48e815ba3b5eb72e632588dbf7ea2dc4e608ee47", + "reference": "48e815ba3b5eb72e632588dbf7ea2dc4e608ee47", "shasum": "" }, "require": { @@ -9453,7 +9369,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v6.3.4" + "source": "https://github.com/symfony/validator/tree/v6.3.5" }, "funding": [ { @@ -9469,20 +9385,20 @@ "type": "tidelift" } ], - "time": "2023-08-17T15:49:05+00:00" + "time": "2023-09-29T07:41:15+00:00" }, { "name": "symfony/var-dumper", - "version": "v6.3.4", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "2027be14f8ae8eae999ceadebcda5b4909b81d45" + "reference": "3d9999376be5fea8de47752837a3e1d1c5f69ef5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/2027be14f8ae8eae999ceadebcda5b4909b81d45", - "reference": "2027be14f8ae8eae999ceadebcda5b4909b81d45", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/3d9999376be5fea8de47752837a3e1d1c5f69ef5", + "reference": "3d9999376be5fea8de47752837a3e1d1c5f69ef5", "shasum": "" }, "require": { @@ -9537,7 +9453,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.3.4" + "source": "https://github.com/symfony/var-dumper/tree/v6.3.5" }, "funding": [ { @@ -9553,7 +9469,7 @@ "type": "tidelift" } ], - "time": "2023-08-24T14:51:05+00:00" + "time": "2023-09-12T10:11:35+00:00" }, { "name": "symf ... [truncated]
config/packages/doctrine.yaml+2 −0 modified@@ -18,6 +18,7 @@ doctrine: default_table_options: charset: utf8mb4 collate: utf8mb4_unicode_ci + schema_manager_factory: doctrine.dbal.default_schema_manager_factory types: datetime: App\Doctrine\UTCDateTimeType @@ -26,6 +27,7 @@ doctrine: default_entity_manager: default entity_managers: default: + report_fields_where_declared: true connection: default naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware auto_mapping: true
config/packages/framework.yaml+0 −2 modified@@ -17,8 +17,6 @@ framework: cookie_httponly: true storage_factory_id: session.storage.factory.native - #esi: ~ - #fragments: ~ php_errors: log: true
config/packages/kimai.yaml+7 −5 modified@@ -151,11 +151,13 @@ kimai: # -------------------------------------------------------------------------------- # INVOICES # -------------------------------------------------------------------------------- -# invoice: -# # all files in these directories will be used as invoice documents (if supported by a renderer) -# documents: -# - 'var/invoices/' -# - 'templates/invoice/renderer/' +# invoice: +# # all files in these directories will be used as invoice documents (if supported by a renderer) +# documents: +# - 'var/invoices/' +# - 'templates/invoice/renderer/' +# # allow to upload twig templates as invoice documents +# upload_twig: true # --------------------------------------------------------------------------------
config/packages/security.yaml+6 −0 modified@@ -65,6 +65,12 @@ security: max_attempts: 5 interval: '5 minutes' + login_link: + check_route: link_login_check + signature_properties: ['id'] + lifetime: 300 + max_uses: 1 + access_decision_manager: strategy: unanimous allow_if_all_abstain: false
config/routes.yaml+5 −5 modified@@ -1,6 +1,6 @@ controllers: resource: ../src/Controller/ - type: annotation + type: attribute prefix: /{_locale} requirements: _locale: '%app_locales%' @@ -19,17 +19,17 @@ api.swagger: api: resource: ../src/API/ - type: annotation + type: attribute prefix: /api auth: resource: ../src/Controller/Auth/ - type: annotation + type: attribute prefix: /auth security: resource: ../src/Controller/Security/ - type: annotation + type: attribute prefix: /{_locale} requirements: _locale: '%app_locales%' @@ -38,7 +38,7 @@ security: kernel: resource: ../src/Kernel.php - type: annotation + type: attribute home: path: /
config/services.yaml+2 −8 modified@@ -91,16 +91,10 @@ services: App\Doctrine\TimesheetSubscriber: class: App\Doctrine\TimesheetSubscriber arguments: [!tagged timesheet.calculator] - tags: - - { name: doctrine.event_subscriber, priority: 50 } # updates timestampable columns (higher priority, so the TimesheetSubscriber will be executed later) - Gedmo\Timestampable\TimestampableListener: - class: Gedmo\Timestampable\TimestampableListener - tags: - - { name: doctrine.event_subscriber, priority: 60 } - calls: - - [ setAnnotationReader, [ "@annotation_reader" ] ] + App\Doctrine\ModifiedSubscriber: + class: App\Doctrine\ModifiedSubscriber # ================================================================================ # TIMESHEET RECORD CALCULATOR
.gitignore+13 −24 modified@@ -1,45 +1,34 @@ # some hosters require a htaccess to change the PHP version -.htaccess -.env-* -.idea/ -.DS_Store -rector.php -phpstan.sh +/public/.htaccess +/.env-* +/.idea/ +/.DS_Store +/phpstan.sh # custom apache rules e.g. to deactivate ioncube loader -public/.user.ini +/public/.user.ini # for symfony local webserver -php.ini -.php-version +/php.ini +/.php-version # YARN 2 -.yarnrc.yml -.yarn +/.yarnrc.yml +/.yarn # for keeping empty directories -!.gitkeep - -/bin/* -!/bin/console -*local.yaml +/config/packages/local.yaml /config/bundles-local.php - -/templates/invoice/renderer/.~lock* -/translations/branding.*.xlf - /var/data/* -/var/coverage/ /var/cache/* - -/var/invoices/* +/var/invoices* /var/export/* /var/log/* /var/sessions/* /var/packages/* /var/plugins/* /var/plugins_old/ -*.disabled +/var/plugins/*/*.disabled ###> symfony/framework-bundle ### /.env.local
phpstan.neon+7 −12 modified@@ -928,7 +928,7 @@ parameters: - message: "#^Cannot access offset 'user' on mixed\\.$#" - count: 2 + count: 1 path: src/Controller/CalendarController.php - @@ -2578,12 +2578,12 @@ parameters: - message: "#^Cannot access offset 'customer' on mixed\\.$#" - count: 3 + count: 1 path: src/Form/MultiUpdate/TimesheetMultiUpdate.php - message: "#^Cannot access offset 'project' on mixed\\.$#" - count: 6 + count: 2 path: src/Form/MultiUpdate/TimesheetMultiUpdate.php - @@ -3603,12 +3603,12 @@ parameters: - message: "#^Cannot access offset 'activity' on mixed\\.$#" - count: 3 + count: 1 path: src/Form/Type/QuickEntryWeekType.php - message: "#^Cannot access offset 'project' on mixed\\.$#" - count: 3 + count: 1 path: src/Form/Type/QuickEntryWeekType.php - @@ -4293,7 +4293,7 @@ parameters: - message: "#^Cannot access offset 'lastRecord' on mixed\\.$#" - count: 2 + count: 1 path: src/Project/ProjectStatisticService.php - @@ -4438,7 +4438,7 @@ parameters: - message: "#^Cannot access offset 'project' on mixed\\.$#" - count: 3 + count: 1 path: src/Reporting/ProjectDetails/ProjectDetailsForm.php - @@ -5716,11 +5716,6 @@ parameters: count: 1 path: src/Utils/MenuItemModel.php - - - message: "#^Method App\\\\Utils\\\\MenuItemModel\\:\\:getRouteArgs\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Utils/MenuItemModel.php - - message: "#^Method App\\\\Utils\\\\MenuItemModel\\:\\:setChildRoutes\\(\\) has parameter \\$routes with no value type specified in iterable type array\\.$#" count: 1
src/Activity/ActivityService.php+1 −1 modified@@ -20,7 +20,7 @@ use App\Repository\ActivityRepository; use App\Validator\ValidationFailedException; use InvalidArgumentException; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; /**
src/API/ActionsController.php+1 −1 modified@@ -21,10 +21,10 @@ use Nelmio\ApiDocBundle\Annotation\Model; use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity; use OpenApi\Attributes as OA; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\Translation\TranslatorInterface; #[Route(path: '/actions')]
src/API/ActivityController.php+1 −1 modified@@ -26,8 +26,8 @@ use FOS\RestBundle\View\ViewHandlerInterface; use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity; use OpenApi\Attributes as OA; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Bridge\Doctrine\Attribute\MapEntity; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route;
src/API/BaseApiController.php+5 −5 modified@@ -10,12 +10,12 @@ namespace App\API; use App\Entity\User; +use App\Repository\Query\BaseQuery; use App\Timesheet\DateTimeFactory; use App\Utils\Pagination; use FOS\RestBundle\View\View; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; -use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormTypeInterface; @@ -30,13 +30,13 @@ abstract class BaseApiController extends AbstractController /** * @template TFormType of FormTypeInterface<TData> - * @template TData of mixed + * @template TData of BaseQuery * @param class-string<TFormType> $type - * @param TData|null $data + * @param TData $data * @param array<mixed> $options - * @return FormInterface<TData|null> + * @return FormInterface<BaseQuery> */ - protected function createSearchForm(string $type = FormType::class, mixed $data = null, array $options = []): FormInterface + protected function createSearchForm(string $type, BaseQuery $data, array $options = []): FormInterface { return $this->container ->get('form.factory')
src/API/CustomerController.php+1 −1 modified@@ -26,8 +26,8 @@ use FOS\RestBundle\View\ViewHandlerInterface; use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity; use OpenApi\Attributes as OA; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Bridge\Doctrine\Attribute\MapEntity; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route;
src/API/ProjectController.php+1 −1 modified@@ -27,8 +27,8 @@ use FOS\RestBundle\View\ViewHandlerInterface; use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity; use OpenApi\Attributes as OA; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Bridge\Doctrine\Attribute\MapEntity; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route;
src/API/TimesheetController.php+4 −3 modified@@ -33,14 +33,14 @@ use FOS\RestBundle\View\ViewHandlerInterface; use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity; use OpenApi\Attributes as OA; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\Form\FormError; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Validator\Constraints; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; #[Route(path: '/timesheets')] #[IsGranted('IS_AUTHENTICATED_REMEMBERED')] @@ -93,7 +93,7 @@ protected function getTrackingMode(): TrackingModeInterface #[Rest\QueryParam(name: 'exported', requirements: '0|1', strict: true, nullable: true, description: 'Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)')] #[Rest\QueryParam(name: 'active', requirements: '0|1', strict: true, nullable: true, description: 'Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)')] #[Rest\QueryParam(name: 'billable', requirements: '0|1', strict: true, nullable: true, description: 'Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)')] - #[Rest\QueryParam(name: 'full', strict: true, nullable: true, description: 'Allows to fetch fully serialized objects including subresources. Allowed values: true (default: false)')] + #[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')] #[Rest\QueryParam(name: 'term', description: 'Free search term')] #[Rest\QueryParam(name: 'modified_after', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records changed after this date will be included (format: HTML5). Available since Kimai 1.10 and works only for records that were created/updated since then.')] public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response @@ -257,7 +257,8 @@ public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerReposito $view = new View($results, 200); $this->addPagination($view, $data); - if (null !== $paramFetcher->get('full')) { + $full = $paramFetcher->get('full'); + if ($full === '1' || $full === 'true') { $view->getContext()->setGroups(self::GROUPS_COLLECTION_FULL); } else { $view->getContext()->setGroups(self::GROUPS_COLLECTION);
src/API/UserController.php+10 −2 modified@@ -23,7 +23,7 @@ use FOS\RestBundle\View\ViewHandlerInterface; use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity; use OpenApi\Attributes as OA; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -39,6 +39,7 @@ final class UserController extends BaseApiController public const GROUPS_ENTITY = ['Default', 'Entity', 'User', 'User_Entity']; public const GROUPS_FORM = ['Default', 'Entity', 'User', 'User_Entity']; public const GROUPS_COLLECTION = ['Default', 'Collection', 'User']; + public const GROUPS_COLLECTION_FULL = ['Default', 'Collection', 'User', 'User_Entity']; public function __construct( private ViewHandlerInterface $viewHandler, @@ -60,6 +61,7 @@ public function __construct( #[Rest\QueryParam(name: 'orderBy', requirements: 'id|username|alias|email', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, username, alias, email (default: username)')] #[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')] #[Rest\QueryParam(name: 'term', description: 'Free search term')] + #[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')] public function cgetAction(ParamFetcherInterface $paramFetcher): Response { $query = new UserQuery(); @@ -88,7 +90,13 @@ public function cgetAction(ParamFetcherInterface $paramFetcher): Response $query->setIsApiCall(true); $data = $this->repository->getUsersForQuery($query); $view = new View($data, 200); - $view->getContext()->setGroups(self::GROUPS_COLLECTION); + + $full = $paramFetcher->get('full'); + if ($full === '1' || $full === 'true') { + $view->getContext()->setGroups(self::GROUPS_COLLECTION_FULL); + } else { + $view->getContext()->setGroups(self::GROUPS_COLLECTION); + } return $this->viewHandler->handle($view); }
src/Calendar/CalendarService.php+1 −1 modified@@ -18,7 +18,7 @@ use App\Event\RecentActivityEvent; use App\Repository\TimesheetRepository; use App\Utils\Color; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; final class CalendarService {
src/Command/DemoteUserCommand.php+0 −3 modified@@ -33,9 +33,6 @@ protected function configure(): void ); } - /** - * {@inheritdoc} - */ protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role): void { $username = $user->getUserIdentifier();
src/Command/InvoiceCreateCommand.php+1 −1 modified@@ -22,6 +22,7 @@ use App\Repository\UserRepository; use App\Timesheet\DateTimeFactory; use App\Utils\SearchTerm; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\Table; @@ -32,7 +33,6 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Response; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; #[AsCommand(name: 'kimai:invoice:create')] final class InvoiceCreateCommand extends Command
src/Command/UserLoginLinkCommand.php+89 −0 added@@ -0,0 +1,89 @@ +<?php + +/* + * This file is part of the Kimai time-tracking app. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Command; + +use App\Repository\UserRepository; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface; + +#[AsCommand(name: 'kimai:user:login-link', description: 'Create a URL that can be used to login as that user', hidden: true)] +/** + * @CloudRequired + */ +final class UserLoginLinkCommand extends Command +{ + public function __construct( + private LoginLinkHandlerInterface $loginLink, + private UserRepository $userRepository, + private RequestStack $requestStack + ) + { + parent::__construct(); + $this->addArgument('email', InputArgument::REQUIRED, 'The email of the user'); + $this->addOption('password-reset', null, InputOption::VALUE_NONE, 'Whether the user needs to reset the password afterwards'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $email = $input->getArgument('email'); + if ($email === null || $email === '') { + $io->error('Need email to create login URL'); + + return Command::FAILURE; + } + + $user = $this->userRepository->findOneBy(['email' => $email]); + + if ($user === null) { + $io->error('Need username to create login URL'); + + return Command::FAILURE; + } + + if (!$user->isEnabled()) { + $io->error('User is not enabled'); + + return Command::FAILURE; + } + + if (!$user->isInternalUser()) { + $io->error('User does not use internal login'); + + return Command::FAILURE; + } + + $request = new Request(); + $request->setLocale($user->getLocale()); + $this->requestStack->push($request); + + $loginLinkDetails = $this->loginLink->createLoginLink($user, $request); + $loginLink = $loginLinkDetails->getUrl(); + + if ($input->getOption('password-reset') === true) { + $user->setPasswordRequestedAt(new \DateTime()); + $user->setRequiresPasswordReset(true); + $this->userRepository->saveUser($user); + } + + $output->writeln($loginLink); + + return Command::SUCCESS; + } +}
src/Constants.php+3 −3 modified@@ -17,11 +17,11 @@ class Constants /** * The current release version */ - public const VERSION = '2.0.35'; + public const VERSION = '2.1.0'; /** * The current release: major * 10000 + minor * 100 + patch */ - public const VERSION_ID = 20035; + public const VERSION_ID = 20100; /** * The software name */ @@ -31,7 +31,7 @@ class Constants */ public const GITHUB = 'https://github.com/kimai/kimai/'; /** - * The Github repository name + * The GitHub repository name */ public const GITHUB_REPO = 'kimai/kimai'; /**
src/Controller/AbstractController.php+4 −5 modified@@ -17,7 +17,6 @@ use App\Validator\ValidationFailedException; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController; -use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormTypeInterface; @@ -76,13 +75,13 @@ protected function createFormForGetRequest(string $type, mixed $data, array $opt /** * @template TFormType of FormTypeInterface<TData> - * @template TData of mixed + * @template TData of array|object * @param class-string<TFormType> $type - * @param TData|null $data + * @param TData $data * @param array<mixed> $options - * @return FormInterface<TData|null> + * @return FormInterface<TData> */ - protected function createFormWithName(string $name, string $type = FormType::class, mixed $data = null, array $options = []): FormInterface + protected function createFormWithName(string $name, string $type, mixed $data, array $options = []): FormInterface { return $this->container->get('form.factory')->createNamed($name, $type, $data, $options); }
src/Controller/ActivityController.php+1 −1 modified@@ -35,7 +35,7 @@ use App\Utils\DataTable; use App\Utils\PageSetup; use Exception; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request;
src/Controller/ContractController.php+1 −1 modified@@ -16,10 +16,10 @@ use App\Utils\PageSetup; use App\WorkingTime\Model\BoxConfiguration; use App\WorkingTime\WorkingTimeService; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** * Users can control their working time statistics
src/Controller/CustomerController.php+1 −1 modified@@ -36,7 +36,7 @@ use App\Repository\TeamRepository; use App\Utils\DataTable; use App\Utils\PageSetup; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request;
src/Controller/DashboardController.php+1 −1 modified@@ -16,7 +16,7 @@ use App\Utils\PageSetup; use App\Widget\WidgetInterface; use App\Widget\WidgetService; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request;
src/Controller/HomepageController.php+1 −1 modified@@ -13,7 +13,7 @@ use App\Entity\User; use App\Event\ConfigureMainMenuEvent; use App\Repository\UserRepository; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route;
src/Controller/InvoiceController.php+1 −31 modified@@ -38,6 +38,7 @@ use App\Utils\DataTable; use App\Utils\PageSetup; use Exception; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\FormInterface; @@ -48,7 +49,6 @@ use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Security\Http\Attribute\IsGranted; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Twig\Environment; /** @@ -468,36 +468,6 @@ public function downloadDocument(string $document, Environment $twig): Response throw $this->createNotFoundException('Unknown document: ' . $document); } - #[Route(path: '/document_reload/{document}', name: 'admin_invoice_document_reload', methods: ['GET', 'POST'])] - #[IsGranted('upload_invoice_template')] - public function reloadDocument(string $document, Environment $twig): Response - { - $event = new InvoiceDocumentsEvent($this->service->getDocuments(true)); - $this->dispatcher->dispatch($event); - - $reloaded = false; - - foreach ($event->getInvoiceDocuments() as $doc) { - if ($document === $doc->getId() && $doc->isTwig()) { - $reloaded = true; - try { - $twig->enableAutoReload(); - $twig->load('@invoice/' . basename($doc->getFilename())); - $twig->disableAutoReload(); - $this->flashSuccess('Reloaded template'); - } catch (Exception $ex) { - $this->flashException($ex, 'Failed to reload template: ' . $ex->getMessage()); - } - } - } - - if (!$reloaded) { - throw $this->createNotFoundException('Unknown document: ' . $document); - } - - return $this->redirectToRoute('admin_invoice_document_upload'); - } - #[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])] #[IsGranted('upload_invoice_template')] public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration): Response
src/Controller/PermissionController.php+1 −1 modified@@ -22,7 +22,7 @@ use App\Security\RoleService; use App\User\PermissionService; use App\Utils\PageSetup; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
src/Controller/ProfileController.php+1 −1 modified@@ -32,8 +32,8 @@ use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh; use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeMargin; use Endroid\QrCode\Writer\PngWriter; +use Psr\EventDispatcher\EventDispatcherInterface; use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request;
src/Controller/ProjectController.php+1 −1 modified@@ -40,7 +40,7 @@ use App\Utils\Context; use App\Utils\DataTable; use App\Utils\PageSetup; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request;
src/Controller/Reporting/ReportUsersMonthController.php+1 −1 modified@@ -42,7 +42,7 @@ public function export(Request $request, TimesheetStatisticService $statisticSer { $data = $this->getData($request, $statisticService, $userRepository); - $content = $this->container->get('twig')->render('reporting/report_user_list_export.html.twig', $data); + $content = $this->renderView('reporting/report_user_list_export.html.twig', $data); $reader = new Html(); $spreadsheet = $reader->loadFromString($content);
src/Controller/Reporting/ReportUsersWeekController.php+1 −1 modified@@ -42,7 +42,7 @@ public function export(Request $request, TimesheetStatisticService $statisticSer { $data = $this->getData($request, $statisticService, $userRepository); - $content = $this->container->get('twig')->render('reporting/report_user_list_export.html.twig', $data); + $content = $this->renderView('reporting/report_user_list_export.html.twig', $data); $reader = new Html(); $spreadsheet = $reader->loadFromString($content);
src/Controller/Reporting/ReportUsersYearController.php+1 −12 modified@@ -19,7 +19,6 @@ use App\Repository\Query\UserQuery; use App\Repository\UserRepository; use App\Timesheet\TimesheetStatisticService; -use Exception; use PhpOffice\PhpSpreadsheet\Reader\Html; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -30,11 +29,6 @@ #[IsGranted('report:other')] final class ReportUsersYearController extends AbstractController { - /** - * @param Request $request - * @return Response - * @throws Exception - */ #[Route(path: '/year', name: 'report_yearly_users', methods: ['GET', 'POST'])] public function report(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response { @@ -44,17 +38,12 @@ public function report(Request $request, SystemConfiguration $systemConfiguratio ); } - /** - * @param Request $request - * @return Response - * @throws Exception - */ #[Route(path: '/year_export', name: 'report_yearly_users_export', methods: ['GET', 'POST'])] public function export(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response { $data = $this->getData($request, $systemConfiguration, $statisticService, $userRepository); - $content = $this->container->get('twig')->render('reporting/report_user_list_monthly_export.html.twig', $data); + $content = $this->renderView('reporting/report_user_list_monthly_export.html.twig', $data); $reader = new Html(); $spreadsheet = $reader->loadFromString($content);
src/Controller/Reporting/UserMonthController.php+19 −0 modified@@ -10,10 +10,13 @@ namespace App\Controller\Reporting; use App\Entity\User; +use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter; +use App\Export\Spreadsheet\Writer\XlsxWriter; use App\Model\DailyStatistic; use App\Reporting\MonthByUser\MonthByUser; use App\Reporting\MonthByUser\MonthByUserForm; use Exception; +use PhpOffice\PhpSpreadsheet\Reader\Html; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; @@ -35,6 +38,21 @@ public function monthByUser(Request $request): Response return $this->render('reporting/report_by_user.html.twig', $this->getData($request)); } + #[Route(path: '/month_export', name: 'report_user_month_export', methods: ['GET', 'POST'])] + public function export(Request $request): Response + { + $data = $this->getData($request); + + $content = $this->renderView('reporting/report_by_user_data.html.twig', $data); + + $reader = new Html(); + $spreadsheet = $reader->loadFromString($content); + + $writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-user-monthly'); + + return $writer->getFileResponse($spreadsheet); + } + private function getData(Request $request): array { $currentUser = $this->getUser(); @@ -95,6 +113,7 @@ private function getData(Request $request): array 'current' => $start, 'next' => $nextMonth, 'previous' => $previousMonth, + 'export_route' => 'report_user_month_export', ]; } }
src/Controller/Reporting/UserWeekController.php+19 −0 modified@@ -9,10 +9,13 @@ namespace App\Controller\Reporting; +use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter; +use App\Export\Spreadsheet\Writer\XlsxWriter; use App\Model\DailyStatistic; use App\Reporting\WeekByUser\WeekByUser; use App\Reporting\WeekByUser\WeekByUserForm; use Exception; +use PhpOffice\PhpSpreadsheet\Reader\Html; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; @@ -34,6 +37,21 @@ public function weekByUser(Request $request): Response return $this->render('reporting/report_by_user.html.twig', $this->getData($request)); } + #[Route(path: '/week_export', name: 'report_user_week_export', methods: ['GET', 'POST'])] + public function export(Request $request): Response + { + $data = $this->getData($request); + + $content = $this->renderView('reporting/report_by_user_data.html.twig', $data); + + $reader = new Html(); + $spreadsheet = $reader->loadFromString($content); + + $writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-user-weekly'); + + return $writer->getFileResponse($spreadsheet); + } + private function getData(Request $request): array { $currentUser = $this->getUser(); @@ -88,6 +106,7 @@ private function getData(Request $request): array 'current' => $start, 'next' => $next, 'previous' => $previous, + 'export_route' => 'report_user_week_export', ]; } }
src/Controller/Reporting/UserYearController.php+19 −0 modified@@ -11,13 +11,16 @@ use App\Configuration\SystemConfiguration; use App\Entity\User; +use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter; +use App\Export\Spreadsheet\Writer\XlsxWriter; use App\Model\DateStatisticInterface; use App\Model\MonthlyStatistic; use App\Reporting\YearByUser\YearByUser; use App\Reporting\YearByUser\YearByUserForm; use DateTime; use DateTimeInterface; use Exception; +use PhpOffice\PhpSpreadsheet\Reader\Html; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; @@ -39,6 +42,21 @@ public function yearByUser(Request $request, SystemConfiguration $systemConfigur return $this->render('reporting/report_by_user_year.html.twig', $this->getData($request, $systemConfiguration)); } + #[Route(path: '/year_export', name: 'report_user_year_export', methods: ['GET', 'POST'])] + public function export(Request $request, SystemConfiguration $systemConfiguration): Response + { + $data = $this->getData($request, $systemConfiguration); + + $content = $this->renderView('reporting/report_by_user_year_export.html.twig', $data); + + $reader = new Html(); + $spreadsheet = $reader->loadFromString($content); + + $writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-user-yearly'); + + return $writer->getFileResponse($spreadsheet); + } + private function getData(Request $request, SystemConfiguration $systemConfiguration): array { $currentUser = $this->getUser(); @@ -104,6 +122,7 @@ private function getData(Request $request, SystemConfiguration $systemConfigurat 'current' => $start, 'next' => $next, 'previous' => $previous, + 'export_route' => 'report_user_year_export', ]; }
src/Controller/Security/LoginLinkController.php+27 −0 added@@ -0,0 +1,27 @@ +<?php + +/* + * This file is part of the Kimai time-tracking app. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Controller\Security; + +use App\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; + +#[Route(path: '/auth/link')] +/** + * @CloudRequired + */ +final class LoginLinkController extends AbstractController +{ + #[Route(path: '/check', name: 'link_login_check', methods: ['GET'])] + public function check(): Response + { + return new Response(); + } +}
src/Controller/Security/PasswordResetController.php+2 −2 modified@@ -18,6 +18,7 @@ use App\User\LoginManager; use App\User\UserService; use DateTime; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; @@ -26,7 +27,6 @@ use Symfony\Component\Mime\Email; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\Translation\TranslatorInterface; #[Route(path: '/resetting')] @@ -108,7 +108,7 @@ public function checkEmailAction(Request $request): Response } return $this->render('security/password-reset/check_email.html.twig', [ - 'tokenLifetime' => ceil($this->configuration->getPasswordResetRetryLifetime() / 3600), + 'tokenLifetime' => $this->configuration->getPasswordResetRetryLifetime(), ]); }
src/Controller/Security/SelfRegistrationController.php+1 −1 modified@@ -17,6 +17,7 @@ use App\Form\SelfRegistrationForm; use App\User\LoginManager; use App\User\UserService; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; @@ -27,7 +28,6 @@ use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\Translation\TranslatorInterface; #[Route(path: '/register')]
src/Controller/SystemConfigurationController.php+1 −1 modified@@ -36,7 +36,7 @@ use App\Validator\Constraints\ColorChoices; use App\Validator\Constraints\DateTimeFormat; use App\Validator\Constraints\TimeFormat; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Form\Extension\Core\Type\CountryType; use Symfony\Component\Form\Extension\Core\Type\CurrencyType; use Symfony\Component\Form\Extension\Core\Type\IntegerType;
src/Controller/TimesheetAbstractController.php+8 −1 modified@@ -33,7 +33,7 @@ use App\Timesheet\TrackingMode\TrackingModeInterface; use App\Utils\DataTable; use App\Utils\PageSetup; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; @@ -334,6 +334,13 @@ protected function multiUpdate(Request $request): Response $execute = false; /** @var Timesheet $timesheet */ foreach ($timesheets as $timesheet) { + if ($dto->isReplaceDescription()) { + $timesheet->setDescription($dto->getDescription()); + $execute = true; + } elseif($dto->getDescription() !== null && $dto->getDescription() !== '') { + $timesheet->setDescription($timesheet->getDescription() . PHP_EOL . $dto->getDescription()); + $execute = true; + } if ($dto->isReplaceTags()) { foreach ($timesheet->getTags() as $tag) { $timesheet->removeTag($tag);
src/Controller/UserController.php+1 −1 modified@@ -25,7 +25,7 @@ use App\User\UserService; use App\Utils\DataTable; use App\Utils\PageSetup; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response;
src/Controller/WizardController.php+38 −1 modified@@ -14,6 +14,7 @@ use App\Form\Type\LanguageType; use App\Form\Type\SkinType; use App\Form\Type\TimezoneType; +use App\Form\UserPasswordType; use App\User\UserService; use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\HttpFoundation\Request; @@ -58,6 +59,11 @@ public function wizard(Request $request, UserService $userService, string $wizar ->setMethod('POST') ->getForm(); + $next = 'done'; + if ($user->requiresPasswordReset()) { + $next = 'password'; + } + $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { @@ -72,13 +78,44 @@ public function wizard(Request $request, UserService $userService, string $wizar if ($data['reload'] === '1') { return $this->redirectToRoute('wizard', ['wizard' => 'profile', '_locale' => $data['language']]); } else { - return $this->redirectToRoute('wizard', ['wizard' => 'done', '_locale' => $data['language']]); + return $this->redirectToRoute('wizard', ['wizard' => $next, '_locale' => $data['language']]); } } return $this->render('wizard/profile.html.twig', [ 'percent' => \intval(100 / \count(User::WIZARDS) * 1), 'previous' => 'intro', + 'next' => $next, + 'form' => $form->createView(), + ]); + } + + if ($wizard === 'password' || $user->requiresPasswordReset()) { + $form = $this->createForm(UserPasswordType::class, $user, [ + 'action' => $this->generateUrl('wizard', ['wizard' => 'password']), + 'method' => 'POST', + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $user->setRequiresPasswordReset(false); + $userService->updateUser($user); + + return $this->redirectToRoute('wizard', ['wizard' => 'done']); + } + + $previous = 'profile'; + $percent = \intval(100 / \count(User::WIZARDS) * 1); + + if ($user->requiresPasswordReset()) { + $previous = null; + $percent = null; + } + + return $this->render('wizard/password.html.twig', [ + 'percent' => $percent, + 'previous' => $previous, 'next' => 'done', 'form' => $form->createView(), ]);
src/Customer/CustomerService.php+1 −1 modified@@ -22,7 +22,7 @@ use App\Utils\NumberGenerator; use App\Validator\ValidationFailedException; use InvalidArgumentException; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; final class CustomerService
src/DependencyInjection/Configuration.php+1 −1 modified@@ -343,7 +343,7 @@ private function getInvoiceNode(): ArrayNodeDefinition ->defaultValue('{Y}/{cy,3}') ->end() ->booleanNode('upload_twig') - ->defaultTrue() + ->defaultFalse() ->end() ->end() ;
src/Doctrine/ModifiedSubscriber.php+50 −0 added@@ -0,0 +1,50 @@ +<?php + +/* + * This file is part of the Kimai time-tracking app. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Doctrine; + +use App\Entity\Timesheet; +use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; +use Doctrine\Common\EventSubscriber; +use Doctrine\ORM\Event\OnFlushEventArgs; +use Doctrine\ORM\Events; + +/** + * Automatically set the modifiedAt field for all Timesheet entries. + */ +#[AsDoctrineListener(event: Events::onFlush, priority: 60)] +final class ModifiedSubscriber implements EventSubscriber, DataSubscriberInterface +{ + public function getSubscribedEvents(): array + { + return [ + Events::onFlush, + ]; + } + + public function onFlush(OnFlushEventArgs $args): void + { + $uow = $args->getObjectManager()->getUnitOfWork(); + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if (!($entity instanceof Timesheet)) { + continue; + } + $entity->setModifiedAt($now); + } + + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if (!($entity instanceof Timesheet)) { + continue; + } + $entity->setModifiedAt($now); + } + } +}
src/Doctrine/TimesheetSubscriber.php+2 −0 modified@@ -11,13 +11,15 @@ use App\Entity\Timesheet; use App\Timesheet\CalculatorInterface; +use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Common\EventSubscriber; use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Events; /** * A listener to make sure all Timesheet entries will be calculated properly (e.g. duration and rates). */ +#[AsDoctrineListener(event: Events::onFlush, priority: 50)] final class TimesheetSubscriber implements EventSubscriber, DataSubscriberInterface { /**
src/Entity/ActivityMeta.php+1 −1 modified@@ -23,7 +23,7 @@ class ActivityMeta implements MetaTableTypeInterface use MetaTableTypeTrait; #[ORM\ManyToOne(targetEntity: Activity::class, inversedBy: 'meta')] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] private ?Activity $activity = null;
src/Entity/Activity.php+3 −3 modified@@ -44,7 +44,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget #[Exporter\Expose(label: 'id', type: 'integer')] private ?int $id = null; #[ORM\ManyToOne(targetEntity: Project::class)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: true)] + #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')] #[Serializer\Expose] #[Serializer\Groups(['Subresource', 'Expanded'])] #[OA\Property(ref: '#/components/schemas/ProjectExpanded')] @@ -87,7 +87,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget * * @var Collection<ActivityMeta> */ - #[ORM\OneToMany(targetEntity: ActivityMeta::class, mappedBy: 'activity', cascade: ['persist'])] + #[ORM\OneToMany(mappedBy: 'activity', targetEntity: ActivityMeta::class, cascade: ['persist'])] #[Serializer\Expose] #[Serializer\Groups(['Activity'])] #[Serializer\Type(name: 'array<App\Entity\ActivityMeta>')] @@ -102,7 +102,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget #[ORM\JoinTable(name: 'kimai2_activities_teams')] #[ORM\JoinColumn(name: 'activity_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\InverseJoinColumn(name: 'team_id', referencedColumnName: 'id', onDelete: 'CASCADE')] - #[ORM\ManyToMany(targetEntity: Team::class, cascade: ['persist'], inversedBy: 'activities')] + #[ORM\ManyToMany(targetEntity: Team::class, inversedBy: 'activities', cascade: ['persist'])] #[Serializer\Expose] #[Serializer\Groups(['Activity'])] #[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Team'))]
src/Entity/ActivityRate.php+1 −1 modified@@ -25,7 +25,7 @@ class ActivityRate implements RateInterface use Rate; #[ORM\ManyToOne(targetEntity: Activity::class)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] private ?Activity $activity = null;
src/Entity/Bookmark.php+1 −1 modified@@ -29,7 +29,7 @@ class Bookmark #[ORM\GeneratedValue(strategy: 'IDENTITY')] private ?int $id = null; #[ORM\ManyToOne(targetEntity: User::class)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] private ?User $user = null; #[ORM\Column(name: 'type', type: 'string', length: 20, nullable: false)]
src/Entity/CommentTableTypeTrait.php+1 −1 modified@@ -22,7 +22,7 @@ trait CommentTableTypeTrait #[Assert\NotNull] private ?string $message = null; #[ORM\ManyToOne(targetEntity: User::class)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] private ?User $createdBy = null; #[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)]
src/Entity/CustomerComment.php+1 −1 modified@@ -21,7 +21,7 @@ class CustomerComment implements CommentInterface use CommentTableTypeTrait; #[ORM\ManyToOne(targetEntity: Customer::class)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] private Customer $customer;
src/Entity/CustomerMeta.php+1 −1 modified@@ -23,7 +23,7 @@ class CustomerMeta implements MetaTableTypeInterface use MetaTableTypeTrait; #[ORM\ManyToOne(targetEntity: Customer::class, inversedBy: 'meta')] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] private ?Customer $customer = null;
src/Entity/Customer.php+3 −3 modified@@ -156,7 +156,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget * * @var Collection<CustomerMeta> */ - #[ORM\OneToMany(targetEntity: CustomerMeta::class, mappedBy: 'customer', cascade: ['persist'])] + #[ORM\OneToMany(mappedBy: 'customer', targetEntity: CustomerMeta::class, cascade: ['persist'])] #[Serializer\Expose] #[Serializer\Groups(['Customer'])] #[Serializer\Type(name: 'array<App\Entity\CustomerMeta>')] @@ -171,7 +171,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget #[ORM\JoinTable(name: 'kimai2_customers_teams')] #[ORM\JoinColumn(name: 'customer_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\InverseJoinColumn(name: 'team_id', referencedColumnName: 'id', onDelete: 'CASCADE')] - #[ORM\ManyToMany(targetEntity: Team::class, cascade: ['persist'], inversedBy: 'customers')] + #[ORM\ManyToMany(targetEntity: Team::class, inversedBy: 'customers', cascade: ['persist'])] #[Serializer\Expose] #[Serializer\Groups(['Customer'])] #[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Team'))] @@ -180,7 +180,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget * Default invoice template for this customer */ #[ORM\ManyToOne(targetEntity: InvoiceTemplate::class)] - #[ORM\JoinColumn(onDelete: 'SET NULL', nullable: true)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] private ?InvoiceTemplate $invoiceTemplate = null; #[ORM\Column(name: 'invoice_text', type: 'text', nullable: true)] private ?string $invoiceText = null;
src/Entity/CustomerRate.php+1 −1 modified@@ -25,7 +25,7 @@ class CustomerRate implements RateInterface use Rate; #[ORM\ManyToOne(targetEntity: Customer::class)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] private ?Customer $customer = null;
src/Entity/InvoiceMeta.php+1 −1 modified@@ -23,7 +23,7 @@ class InvoiceMeta implements MetaTableTypeInterface use MetaTableTypeTrait; #[ORM\ManyToOne(targetEntity: Invoice::class, inversedBy: 'meta')] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] private ?Invoice $invoice = null;
src/Entity/Invoice.php+3 −3 modified@@ -56,11 +56,11 @@ class Invoice implements EntityWithMetaFields #[Exporter\Expose(label: 'comment')] private ?string $comment = null; #[ORM\ManyToOne(targetEntity: Customer::class)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] private ?Customer $customer = null; #[ORM\ManyToOne(targetEntity: User::class)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] private ?User $user = null; #[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)] @@ -109,7 +109,7 @@ class Invoice implements EntityWithMetaFields * * @var Collection<InvoiceMeta> */ - #[ORM\OneToMany(targetEntity: InvoiceMeta::class, mappedBy: 'invoice', cascade: ['persist'])] + #[ORM\OneToMany(mappedBy: 'invoice', targetEntity: InvoiceMeta::class, cascade: ['persist'])] #[Serializer\Expose] #[Serializer\Groups(['Invoice'])] #[Serializer\Type(name: 'array<App\Entity\InvoiceMeta>')]
src/Entity/ProjectComment.php+1 −1 modified@@ -21,7 +21,7 @@ class ProjectComment implements CommentInterface use CommentTableTypeTrait; #[ORM\ManyToOne(targetEntity: Project::class)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] private Project $project;
src/Entity/ProjectMeta.php+1 −1 modified@@ -23,7 +23,7 @@ class ProjectMeta implements MetaTableTypeInterface use MetaTableTypeTrait; #[ORM\ManyToOne(targetEntity: Project::class, inversedBy: 'meta')] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] private ?Project $project = null;
src/Entity/Project.php+3 −3 modified@@ -48,7 +48,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget * Customer for this project */ #[ORM\ManyToOne(targetEntity: Customer::class)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] #[Serializer\Expose] #[Serializer\Groups(['Subresource', 'Expanded'])] @@ -137,7 +137,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget * * @var Collection<ProjectMeta> */ - #[ORM\OneToMany(targetEntity: ProjectMeta::class, mappedBy: 'project', cascade: ['persist'])] + #[ORM\OneToMany(mappedBy: 'project', targetEntity: ProjectMeta::class, cascade: ['persist'])] #[Serializer\Expose] #[Serializer\Groups(['Project'])] #[Serializer\Type(name: 'array<App\Entity\ProjectMeta>')] @@ -152,7 +152,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget #[ORM\JoinTable(name: 'kimai2_projects_teams')] #[ORM\JoinColumn(name: 'project_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\InverseJoinColumn(name: 'team_id', referencedColumnName: 'id', onDelete: 'CASCADE')] - #[ORM\ManyToMany(targetEntity: Team::class, cascade: ['persist'], inversedBy: 'projects')] + #[ORM\ManyToMany(targetEntity: Team::class, inversedBy: 'projects', cascade: ['persist'])] #[Serializer\Expose] #[Serializer\Groups(['Project'])] #[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Team'))]
src/Entity/ProjectRate.php+1 −1 modified@@ -25,7 +25,7 @@ class ProjectRate implements RateInterface use Rate; #[ORM\ManyToOne(targetEntity: Project::class)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] private ?Project $project = null;
src/Entity/Rate.php+1 −1 modified@@ -23,7 +23,7 @@ trait Rate #[Serializer\Groups(['Default'])] private ?int $id = null; #[ORM\ManyToOne(targetEntity: User::class)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: true)] + #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')] #[Serializer\Expose] #[Serializer\Groups(['Default'])] #[OA\Property(ref: '#/components/schemas/User')]
src/Entity/RolePermission.php+1 −1 modified@@ -25,7 +25,7 @@ class RolePermission #[ORM\GeneratedValue(strategy: 'IDENTITY')] private ?int $id = null; #[ORM\ManyToOne(targetEntity: Role::class)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] private ?Role $role = null; #[ORM\Column(name: 'permission', type: 'string', length: 50, nullable: false)]
src/Entity/Tag.php+1 −1 modified@@ -39,7 +39,7 @@ class Tag #[ORM\Column(name: 'name', type: 'string', length: 100, nullable: false)] #[Assert\NotBlank] #[Assert\Length(min: 2, max: 100, normalizer: 'trim')] - #[Assert\Regex(pattern: '/,/', match: false, message: 'Tag name cannot contain comma')] + #[Assert\Regex(pattern: '/,/', message: 'Tag name cannot contain comma', match: false)] #[Serializer\Expose] #[Serializer\Groups(['Default'])] private ?string $name = null;
src/Entity/TeamMember.php+2 −2 modified@@ -26,14 +26,14 @@ class TeamMember #[ORM\GeneratedValue(strategy: 'IDENTITY')] private ?int $id = null; #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'memberships')] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] #[Serializer\Expose] #[Serializer\Groups(['Default', 'Entity', 'Team_Entity'])] #[OA\Property(ref: '#/components/schemas/User')] private ?User $user = null; #[ORM\ManyToOne(targetEntity: Team::class, inversedBy: 'members')] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] #[Serializer\Expose] #[Serializer\Groups(['Default', 'Entity', 'User_Entity'])]
src/Entity/Team.php+4 −4 modified@@ -47,7 +47,7 @@ class Team * * @var Collection<TeamMember> */ - #[ORM\OneToMany(targetEntity: TeamMember::class, mappedBy: 'team', fetch: 'LAZY', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OneToMany(mappedBy: 'team', targetEntity: TeamMember::class, cascade: ['persist', 'remove'], fetch: 'LAZY', orphanRemoval: true)] #[ORM\JoinColumn(onDelete: 'CASCADE')] #[Assert\Count(min: 1)] #[Serializer\Expose] @@ -59,7 +59,7 @@ class Team * * @var Collection<Customer> */ - #[ORM\ManyToMany(targetEntity: Customer::class, mappedBy: 'teams', fetch: 'EXTRA_LAZY', cascade: ['persist'])] + #[ORM\ManyToMany(targetEntity: Customer::class, mappedBy: 'teams', cascade: ['persist'], fetch: 'EXTRA_LAZY')] #[Serializer\Expose] #[Serializer\Groups(['Team_Entity'])] #[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Customer'))] @@ -69,7 +69,7 @@ class Team * * @var Collection<Project> */ - #[ORM\ManyToMany(targetEntity: Project::class, mappedBy: 'teams', fetch: 'EXTRA_LAZY', cascade: ['persist'])] + #[ORM\ManyToMany(targetEntity: Project::class, mappedBy: 'teams', cascade: ['persist'], fetch: 'EXTRA_LAZY')] #[Serializer\Expose] #[Serializer\Groups(['Team_Entity', 'Expanded'])] #[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Project'))] @@ -79,7 +79,7 @@ class Team * * @var Collection<Activity> */ - #[ORM\ManyToMany(targetEntity: Activity::class, mappedBy: 'teams', fetch: 'EXTRA_LAZY', cascade: ['persist'])] + #[ORM\ManyToMany(targetEntity: Activity::class, mappedBy: 'teams', cascade: ['persist'], fetch: 'EXTRA_LAZY')] #[Serializer\Expose] #[Serializer\Groups(['Team_Entity', 'Expanded'])] #[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Activity'))]
src/Entity/TimesheetMeta.php+1 −1 modified@@ -23,7 +23,7 @@ class TimesheetMeta implements MetaTableTypeInterface use MetaTableTypeTrait; #[ORM\ManyToOne(targetEntity: Timesheet::class, inversedBy: 'meta')] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] private ?Timesheet $timesheet = null;
src/Entity/Timesheet.php+13 −12 modified@@ -15,7 +15,6 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use Gedmo\Mapping\Annotation as Gedmo; use JMS\Serializer\Annotation as Serializer; use OpenApi\Attributes as OA; use Symfony\Component\Validator\Constraints as Assert; @@ -128,21 +127,21 @@ class Timesheet implements EntityWithMetaFields, ExportableItem #[Serializer\Groups(['Default'])] private ?int $duration = 0; #[ORM\ManyToOne(targetEntity: User::class)] - #[ORM\JoinColumn(name: '`user`', referencedColumnName: 'id', onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(name: '`user`', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] #[Serializer\Expose] #[Serializer\Groups(['Subresource', 'Expanded'])] #[OA\Property(ref: '#/components/schemas/User')] private ?User $user = null; #[ORM\ManyToOne(targetEntity: Activity::class)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] #[Serializer\Expose] #[Serializer\Groups(['Subresource', 'Expanded'])] #[OA\Property(ref: '#/components/schemas/ActivityExpanded')] private ?Activity $activity = null; #[ORM\ManyToOne(targetEntity: Project::class)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] #[Serializer\Expose] #[Serializer\Groups(['Subresource', 'Expanded'])] @@ -189,12 +188,8 @@ class Timesheet implements EntityWithMetaFields, ExportableItem #[ORM\Column(name: 'category', type: 'string', length: 10, nullable: false, options: ['default' => 'work'])] #[Assert\NotNull] private ?string $category = self::WORK; - /** - * @internal used for limiting queries, eg. via API sync - */ #[ORM\Column(name: 'modified_at', type: 'datetime', nullable: true)] - #[Gedmo\Timestampable] - private ?\DateTime $modifiedAt = null; + private \DateTimeInterface $modifiedAt; /** * Tags * @@ -211,7 +206,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem * * @var Collection<TimesheetMeta> */ - #[ORM\OneToMany(targetEntity: TimesheetMeta::class, mappedBy: 'timesheet', cascade: ['persist'])] + #[ORM\OneToMany(mappedBy: 'timesheet', targetEntity: TimesheetMeta::class, cascade: ['persist'])] #[Serializer\Expose] #[Serializer\Groups(['Timesheet'])] #[Serializer\Type(name: 'array<App\Entity\TimesheetMeta>')] @@ -223,6 +218,7 @@ public function __construct() { $this->tags = new ArrayCollection(); $this->meta = new ArrayCollection(); + $this->modifiedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); } /** @@ -590,11 +586,16 @@ public function setHourlyRate(?float $hourlyRate): Timesheet return $this; } - public function getModifiedAt(): ?DateTime + public function getModifiedAt(): \DateTimeInterface { return $this->modifiedAt; } + public function setModifiedAt(\DateTimeInterface $dateTime): void + { + $this->modifiedAt = $dateTime; + } + /** * @return Collection|MetaTableTypeInterface[] */ @@ -687,7 +688,7 @@ public function __clone() } // field will not be set, if it contains a value - $this->modifiedAt = null; + $this->modifiedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $this->exported = false; $currentMeta = $this->meta;
src/Entity/User.php+30 −14 modified@@ -42,9 +42,9 @@ #[Exporter\Expose(name: 'username', label: 'username', exp: 'object.getUserIdentifier()')] #[Exporter\Expose(name: 'timezone', label: 'timezone', exp: 'object.getTimezone()')] #[Exporter\Expose(name: 'language', label: 'language', exp: 'object.getLanguage()')] -#[Exporter\Expose(name: 'last_login', label: 'lastLogin', exp: 'object.getLastLogin()', type: 'datetime')] -#[Exporter\Expose(name: 'roles', label: 'roles', exp: 'object.getRoles()', type: 'array')] -#[Exporter\Expose(name: 'active', label: 'active', exp: 'object.isEnabled()', type: 'boolean')] +#[Exporter\Expose(name: 'last_login', label: 'lastLogin', type: 'datetime', exp: 'object.getLastLogin()')] +#[Exporter\Expose(name: 'roles', label: 'roles', type: 'array', exp: 'object.getRoles()')] +#[Exporter\Expose(name: 'active', label: 'active', type: 'boolean', exp: 'object.isEnabled()')] #[Constraints\User(groups: ['UserCreate', 'Registration', 'Default', 'Profile'])] class User implements UserInterface, EquatableInterface, ThemeUserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface { @@ -127,15 +127,15 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas * * @var Collection<UserPreference>|null */ - #[ORM\OneToMany(targetEntity: UserPreference::class, mappedBy: 'user', cascade: ['persist'])] + #[ORM\OneToMany(mappedBy: 'user', targetEntity: UserPreference::class, cascade: ['persist'])] private ?Collection $preferences = null; /** * List of all team memberships. * * @var Collection<TeamMember> */ - #[ORM\OneToMany(targetEntity: TeamMember::class, mappedBy: 'user', fetch: 'LAZY', cascade: ['persist'], orphanRemoval: true)] - #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)] + #[ORM\OneToMany(mappedBy: 'user', targetEntity: TeamMember::class, cascade: ['persist'], fetch: 'LAZY', orphanRemoval: true)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Assert\NotNull] #[Serializer\Expose] #[Serializer\Groups(['User_Entity'])] @@ -217,7 +217,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas #[ORM\Column(name: 'system_account', type: 'boolean', nullable: false, options: ['default' => false])] private bool $systemAccount = false; #[ORM\ManyToOne(targetEntity: User::class)] - #[ORM\JoinColumn(onDelete: 'SET NULL', nullable: true)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] #[Serializer\Expose] #[Serializer\Groups(['User_Entity'])] #[OA\Property(ref: '#/components/schemas/User')] @@ -297,6 +297,14 @@ public function setApiToken(?string $apiToken): User return $this; } + #[Serializer\VirtualProperty] + #[Serializer\SerializedName('apiToken')] + #[Serializer\Groups(['Default'])] + public function hasApiToken(): bool + { + return $this->apiToken !== null; + } + public function getPlainApiToken(): ?string { return $this->plainApiToken; @@ -836,9 +844,6 @@ public function hasEmail(): bool return $this->email !== null; } - /** - * {@inheritdoc} - */ public function getPassword(): ?string { return $this->password; @@ -864,9 +869,6 @@ public function getConfirmationToken(): ?string return $this->confirmationToken; } - /** - * {@inheritdoc} - */ public function getRoles(): array { $roles = $this->roles; @@ -963,7 +965,7 @@ public function setConfirmationToken($confirmationToken): User return $this; } - public function setPasswordRequestedAt(\DateTime $date = null): User + public function setPasswordRequestedAt(?\DateTime $date = null): User { $this->passwordRequestedAt = $date; @@ -1113,6 +1115,20 @@ public function getName(): string return $this->getDisplayName(); } + public function requiresPasswordReset(): bool + { + if (!$this->isInternalUser() || !$this->isEnabled()) { + return false; + } + + return $this->getPreferenceValue('__pw_reset__') === '1'; + } + + public function setRequiresPasswordReset(bool $require = true): void + { + $this->setPreferenceValue('__pw_reset__', ($require ? '1' : '0')); + } + public function hasSeenWizard(string $wizard): bool { $wizards = $this->getPreferenceValue('__wizards__');
src/EventSubscriber/Actions/InvoiceDocumentSubscriber.php+0 −4 modified@@ -45,10 +45,6 @@ public function onActions(PageActionsEvent $event): void $event->addAction('download', ['url' => $this->path('admin_invoice_document_download', ['document' => $document->getId()])]); - if ($document->isTwig()) { - $event->addAction('reload', ['url' => $this->path('admin_invoice_document_reload', ['document' => $document->getId()])]); - } - if (!$inUse) { $event->addDelete($this->path('invoice_document_delete', ['id' => $document->getId(), 'token' => $token]), false); }
src/EventSubscriber/MenuBuilderSubscriber.php+1 −1 modified@@ -14,8 +14,8 @@ use App\Utils\MenuItemModel; use KevinPapst\TablerBundle\Event\MenuEvent; use KevinPapst\TablerBundle\Model\MenuItemInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Bundle\SecurityBundle\Security; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; /**
src/EventSubscriber/UserPreferenceSubscriber.php+1 −1 modified@@ -23,7 +23,7 @@ use App\Form\Type\TimezoneType; use App\Form\Type\UserLanguageType; use App\Form\Type\YesNoType; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Form\Extension\Core\Type\MoneyType; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
src/EventSubscriber/UserProfileSubscriber.php+1 −1 modified@@ -11,7 +11,7 @@ use App\Entity\User; use App\Event\PrepareUserEvent; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\KernelEvent; use Symfony\Component\HttpKernel\KernelEvents;
src/EventSubscriber/WizardSubscriber.php+17 −14 modified@@ -49,29 +49,32 @@ public function onKernelRequest(RequestEvent $event): void $uri = $event->getRequest()->getRequestUri(); // never require 2FA on API calls - if (str_starts_with($uri, '/api/') || stripos($uri, '/register/') !== false) { + if (str_starts_with($uri, '/api/') || stripos($uri, '/register/') !== false || stripos($uri, '/wizard/') !== false) { return; } $user = $token->getUser(); - if ($user instanceof User) { - if (stripos($uri, '/wizard/') !== false) { - return; - } + if (!($user instanceof User)) { + return; + } - if (!$this->security->isGranted('IS_AUTHENTICATED_FULLY')) { - return; - } + if (!$this->security->isGranted('IS_AUTHENTICATED_FULLY')) { + return; + } - foreach (User::WIZARDS as $wizard) { - if (!$user->hasSeenWizard($wizard)) { - $response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => $wizard])); - $event->setResponse($response); + foreach (User::WIZARDS as $wizard) { + if (!$user->hasSeenWizard($wizard)) { + $response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => $wizard])); + $event->setResponse($response); - return; - } + return; } } + + if ($user->requiresPasswordReset()) { + $response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => 'password'])); + $event->setResponse($response); + } } }
src/Export/Base/AbstractSpreadsheetRenderer.php+1 −1 modified@@ -30,8 +30,8 @@ use PhpOffice\PhpSpreadsheet\Style\Border; use PhpOffice\PhpSpreadsheet\Style\NumberFormat; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Bundle\SecurityBundle\Security; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag;
src/Export/Base/HtmlRenderer.php+8 −1 modified@@ -21,9 +21,11 @@ use App\Project\ProjectStatisticService; use App\Repository\Query\CustomerQuery; use App\Repository\Query\TimesheetQuery; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use App\Twig\SecurityPolicy\ExportPolicy; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Response; use Twig\Environment; +use Twig\Extension\SandboxExtension; class HtmlRenderer { @@ -93,6 +95,11 @@ public function render(array $timesheets, TimesheetQuery $query): Response $summary = $this->calculateSummary($timesheets); + // enable basic security measures + $sandbox = new SandboxExtension(new ExportPolicy()); + $sandbox->enableSandbox(); + $this->twig->addExtension($sandbox); + $content = $this->twig->render($this->getTemplate(), array_merge([ 'entries' => $timesheets, 'query' => $query,
src/Export/Base/PDFRenderer.php+8 −0 modified@@ -16,8 +16,10 @@ use App\Pdf\PdfRendererTrait; use App\Project\ProjectStatisticService; use App\Repository\Query\TimesheetQuery; +use App\Twig\SecurityPolicy\ExportPolicy; use Symfony\Component\HttpFoundation\Response; use Twig\Environment; +use Twig\Extension\SandboxExtension; class PDFRenderer implements DispositionInlineInterface { @@ -76,6 +78,12 @@ public function render(array $timesheets, TimesheetQuery $query): Response $context->setOption('filename', $filename->getFilename()); $summary = $this->calculateSummary($timesheets); + + // enable basic security measures + $sandbox = new SandboxExtension(new ExportPolicy()); + $sandbox->enableSandbox(); + $this->twig->addExtension($sandbox); + $content = $this->twig->render($this->getTemplate(), array_merge([ 'entries' => $timesheets, 'query' => $query,
src/Export/Renderer/HtmlRendererFactory.php+1 −1 modified@@ -11,7 +11,7 @@ use App\Activity\ActivityStatisticService; use App\Project\ProjectStatisticService; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Twig\Environment; final class HtmlRendererFactory
src/Export/ServiceExport.php+1 −1 modified@@ -14,7 +14,7 @@ use App\Export\Renderer\HtmlRendererFactory; use App\Export\Renderer\PdfRendererFactory; use App\Repository\Query\ExportQuery; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; final class ServiceExport {
src/Export/Spreadsheet/Extractor/MetaFieldExtractor.php+1 −1 modified@@ -12,7 +12,7 @@ use App\Entity\EntityWithMetaFields; use App\Event\MetaDisplayEventInterface; use App\Export\Spreadsheet\ColumnDefinition; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; /** * @internal
src/Export/Spreadsheet/Extractor/UserPreferenceExtractor.php+1 −1 modified@@ -12,7 +12,7 @@ use App\Entity\User; use App\Event\UserPreferenceDisplayEvent; use App\Export\Spreadsheet\ColumnDefinition; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; /** * @internal
src/Form/MultiUpdate/TimesheetMultiUpdateDTO.php+22 −0 modified@@ -49,6 +49,8 @@ final class TimesheetMultiUpdateDTO extends MultiUpdateTableDTO implements Entit * @var array<string> */ private array $updateMeta = []; + private bool $replaceDescription = false; + private ?string $description = null; public function __construct() { @@ -98,6 +100,16 @@ public function setTags(iterable $tags): void $this->tags = $tags; } + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): void + { + $this->description = $description; + } + public function getUser(): ?User { return $this->user; @@ -148,6 +160,16 @@ public function setReplaceTags(bool $replaceTags): void $this->replaceTags = $replaceTags; } + public function isReplaceDescription(): bool + { + return $this->replaceDescription; + } + + public function setReplaceDescription(bool $replaceDescription): void + { + $this->replaceDescription = $replaceDescription; + } + public function getFixedRate(): ?float { return $this->fixedRate;
src/Form/MultiUpdate/TimesheetMultiUpdate.php+23 −2 modified@@ -11,6 +11,7 @@ use App\Form\Type\ActivityType; use App\Form\Type\CustomerType; +use App\Form\Type\DescriptionType; use App\Form\Type\FixedRateType; use App\Form\Type\HourlyRateType; use App\Form\Type\MetaFieldsCollectionType; @@ -141,16 +142,36 @@ function (FormEvent $event) use ($activityOptions) { 'label' => false, 'required' => true, 'expanded' => true, + 'label_attr' => [ + 'class' => 'radio-inline', + ], 'choices' => [ - 'replaceTags' => true, - 'appendTags' => false, + 'append' => false, + 'replace' => true, ] ]); $builder->add('tags', TagsType::class, [ 'required' => false, ]); + $builder->add('replaceDescription', ChoiceType::class, [ + 'label' => false, + 'required' => true, + 'expanded' => true, + 'label_attr' => [ + 'class' => 'radio-inline', + ], + 'choices' => [ + 'append' => false, + 'replace' => true, + ] + ]); + + $builder->add('description', DescriptionType::class, [ + 'required' => false, + ]); + if ($options['include_user']) { $builder->add('user', UserType::class, [ 'required' => false,
src/Form/Type/MenuChoiceType.php+1 −1 modified@@ -11,7 +11,7 @@ use App\Event\ConfigureMainMenuEvent; use App\Utils\MenuItemModel; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\OptionsResolver\Options;
src/Form/Type/SkinType.php+1 −0 modified@@ -26,6 +26,7 @@ final class SkinType extends AbstractType public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ + 'label' => 'skin', 'search' => false, 'required' => true, 'choices' => self::THEMES,
src/Form/UserCreateType.php+7 −0 modified@@ -11,6 +11,7 @@ use App\Form\Type\TeamType; use App\Form\Type\UserRoleType; +use App\Form\Type\YesNoType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\Form\FormBuilderInterface; @@ -63,6 +64,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, ]); } + + $builder->add('requiresPasswordReset', YesNoType::class, [ + 'label' => 'force_password_change', + 'help' => 'force_password_change_help', + 'required' => false, + ]); } public function configureOptions(OptionsResolver $resolver): void
src/Invoice/ServiceInvoice.php+1 −1 modified@@ -22,9 +22,9 @@ use App\Repository\InvoiceRepository; use App\Repository\Query\InvoiceQuery; use App\Utils\FileHelper; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Response; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** * Service to manage invoice dependencies.
src/Kernel.php+1 −1 modified@@ -169,7 +169,7 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa $container->registerExtension(new AppExtension()); $container->setParameter('container.autowiring.strict_mode', true); - $container->setParameter('container.dumper.inline_class_loader', true); + $container->setParameter('.container.dumper.inline_class_loader', true); $confDir = $this->getProjectDir() . '/config'; // using this one instead of $loader->load($confDir . '/packages/*' . self::CONFIG_EXTS, 'glob');
src/Project/ProjectService.php+1 −1 modified@@ -22,7 +22,7 @@ use App\Utils\Context; use App\Validator\ValidationFailedException; use InvalidArgumentException; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; /**
src/Project/ProjectStatisticService.php+1 −1 modified@@ -36,7 +36,7 @@ use App\Timesheet\DateTimeFactory; use DateTime; use Doctrine\DBAL\Types\Types; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; /** * @final
src/Reporting/ReportingService.php+1 −1 modified@@ -11,7 +11,7 @@ use App\Entity\User; use App\Event\ReportingEvent; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; final class ReportingService
src/Repository/TimesheetRepository.php+7 −4 modified@@ -355,14 +355,17 @@ public function countActiveEntries(?User $user = null): int { $qb = $this->getEntityManager()->createQueryBuilder(); - $qb->select('COUNT(t)') + $qb->select($qb->expr()->count('t')) ->from(Timesheet::class, 't') ->andWhere($qb->expr()->isNull('t.end')) - ->orderBy('t.begin', 'DESC'); + ; if (null !== $user) { - $qb->andWhere('t.user = :user'); - $qb->setParameter('user', $user); + $qb + ->andWhere('t.user = :user') + ->groupBy('t.user') + ->setParameter('user', $user) + ; } return (int) $qb->getQuery()->getSingleScalarResult();
src/Security/TwoFactorCondition.php+0 −4 modified@@ -9,7 +9,6 @@ namespace App\Security; -use App\Entity\User; use Scheb\TwoFactorBundle\Security\TwoFactor\AuthenticationContextInterface; use Scheb\TwoFactorBundle\Security\TwoFactor\Condition\TwoFactorConditionInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; @@ -22,9 +21,6 @@ public function __construct(private AuthorizationCheckerInterface $authorization public function shouldPerformTwoFactorAuthentication(AuthenticationContextInterface $context): bool { - /** @var User $user */ - $user = $context->getUser(); - // never require 2FA on API calls if (str_starts_with($context->getRequest()->getRequestUri(), '/api/')) { return false;
src/Timesheet/TimesheetService.php+1 −1 modified@@ -31,7 +31,7 @@ use App\Validator\ValidationException; use App\Validator\ValidationFailedException; use InvalidArgumentException; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Validator\ConstraintViolation;
src/Twig/Extensions.php+0 −6 modified@@ -21,9 +21,6 @@ final class Extensions extends AbstractExtension { public const REPORT_DATE = 'Y-m-d'; - /** - * {@inheritdoc} - */ public function getFilters(): array { return [ @@ -37,9 +34,6 @@ public function getFilters(): array ]; } - /** - * {@inheritdoc} - */ public function getFunctions(): array { return [
src/Twig/LocaleFormatExtensions.php+0 −6 modified@@ -33,9 +33,6 @@ public function __construct(private LocaleService $localeService, private Securi { } - /** - * {@inheritdoc} - */ public function getFilters(): array { return [ @@ -74,9 +71,6 @@ public function getTests(): array ]; } - /** - * {@inheritdoc} - */ public function getFunctions(): array { return [
src/Twig/PaginationExtension.php+0 −3 modified@@ -27,9 +27,6 @@ public function __construct(private UrlGeneratorInterface $router) { } - /** - * {@inheritdoc} - */ public function getFunctions(): array { return [
src/Twig/SecurityPolicy/ChainPolicy.php+48 −0 added@@ -0,0 +1,48 @@ +<?php + +/* + * This file is part of the Kimai time-tracking app. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Twig\SecurityPolicy; + +use Twig\Sandbox\SecurityPolicyInterface; + +final class ChainPolicy implements SecurityPolicyInterface +{ + /** @var array<SecurityPolicyInterface> */ + private array $policies = []; + + public function __construct() + { + } + + public function addPolicy(SecurityPolicyInterface $policy): void + { + $this->policies[] = $policy; + } + + public function checkSecurity($tags, $filters, $functions): void + { + foreach ($this->policies as $policy) { + $policy->checkSecurity($tags, $filters, $functions); + } + } + + public function checkMethodAllowed($obj, $method): void + { + foreach ($this->policies as $policy) { + $policy->checkMethodAllowed($obj, $method); + } + } + + public function checkPropertyAllowed($obj, $property): void + { + foreach ($this->policies as $policy) { + $policy->checkPropertyAllowed($obj, $property); + } + } +}
src/Twig/SecurityPolicy/DefaultPolicy.php+30 −0 added@@ -0,0 +1,30 @@ +<?php + +/* + * This file is part of the Kimai time-tracking app. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Twig\SecurityPolicy; + +use Twig\Sandbox\SecurityPolicyInterface; + +/** + * The Twig environment needs the sandbox extension, which itself needs a policy to start working. + */ +final class DefaultPolicy implements SecurityPolicyInterface +{ + public function checkSecurity($tags, $filters, $functions): void + { + } + + public function checkMethodAllowed($obj, $method): void + { + } + + public function checkPropertyAllowed($obj, $property): void + { + } +}
src/Twig/SecurityPolicy/ExportPolicy.php+41 −0 added@@ -0,0 +1,41 @@ +<?php + +/* + * This file is part of the Kimai time-tracking app. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Twig\SecurityPolicy; + +use Twig\Sandbox\SecurityPolicyInterface; + +/** + * Represents the security policy for custom Twig export templates. + */ +final class ExportPolicy implements SecurityPolicyInterface +{ + private ChainPolicy $policy; + + public function __construct() + { + $this->policy = new ChainPolicy(); + $this->policy->addPolicy(new DefaultPolicy()); + } + + public function checkSecurity($tags, $filters, $functions): void + { + $this->policy->checkSecurity($tags, $filters, $functions); + } + + public function checkMethodAllowed($obj, $method): void + { + $this->policy->checkMethodAllowed($obj, $method); + } + + public function checkPropertyAllowed($obj, $property): void + { + $this->policy->checkPropertyAllowed($obj, $property); + } +}
src/Twig/SecurityPolicy/ForbiddenPolicy.php+109 −0 added@@ -0,0 +1,109 @@ +<?php + +/* + * This file is part of the Kimai time-tracking app. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Twig\SecurityPolicy; + +use Twig\Markup; +use Twig\Sandbox\SecurityNotAllowedFilterError; +use Twig\Sandbox\SecurityNotAllowedFunctionError; +use Twig\Sandbox\SecurityNotAllowedMethodError; +use Twig\Sandbox\SecurityNotAllowedPropertyError; +use Twig\Sandbox\SecurityNotAllowedTagError; +use Twig\Sandbox\SecurityPolicyInterface; +use Twig\Template; + +/** + * A blocking approach for Twig templates. + */ +final class ForbiddenPolicy implements SecurityPolicyInterface +{ + /** @var array<string, array<string>> */ + private array $forbiddenMethods = []; + + /** + * @param array<string> $forbiddenTags + * @param array<string> $forbiddenFilters + * @param array<string, array<string>> $forbiddenMethods + * @param array<string, array<string>> $forbiddenProperties + * @param array<string> $forbiddenFunctions + */ + public function __construct( + private array $forbiddenTags = [], + private array $forbiddenFilters = [], + array $forbiddenMethods = [], + private array $forbiddenProperties = [], + private array $forbiddenFunctions = [] + ) + { + $this->forbiddenMethods = []; + foreach ($forbiddenMethods as $class => $m) { + $this->forbiddenMethods[$class] = array_map(function ($value) { return strtr($value, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); }, \is_array($m) ? $m : [$m]); + } + } + + public function checkSecurity($tags, $filters, $functions): void + { + foreach ($tags as $tag) { + if (\in_array($tag, $this->forbiddenTags)) { + throw new SecurityNotAllowedTagError(sprintf('Tag "%s" is not allowed.', $tag), $tag); + } + } + + foreach ($filters as $filter) { + if (\in_array($filter, $this->forbiddenFilters)) { + throw new SecurityNotAllowedFilterError(sprintf('Filter "%s" is not allowed.', $filter), $filter); + } + } + + foreach ($functions as $function) { + if (\in_array($function, $this->forbiddenFunctions)) { + throw new SecurityNotAllowedFunctionError(sprintf('Function "%s" is not allowed.', $function), $function); + } + } + } + + public function checkMethodAllowed($obj, $method): void + { + if ($obj instanceof Template || $obj instanceof Markup) { + return; + } + + $forbidden = false; + $method = strtr($method, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); + foreach ($this->forbiddenMethods as $class => $methods) { + if ($obj instanceof $class) { + $forbidden = \in_array($method, $methods); + + break; + } + } + + if ($forbidden) { + $class = \get_class($obj); + throw new SecurityNotAllowedMethodError(sprintf('Calling "%s" method on a "%s" object is not allowed.', $method, $class), $class, $method); + } + } + + public function checkPropertyAllowed($obj, $property): void + { + $forbidden = false; + foreach ($this->forbiddenProperties as $class => $properties) { + if ($obj instanceof $class) { + $forbidden = \in_array($property, \is_array($properties) ? $properties : [$properties]); + + break; + } + } + + if ($forbidden) { + $class = \get_class($obj); + throw new SecurityNotAllowedPropertyError(sprintf('Calling "%s" property on a "%s" object is not allowed.', $property, $class), $class, $property); + } + } +}
src/Twig/SecurityPolicy/InvoicePolicy.php+86 −0 added@@ -0,0 +1,86 @@ +<?php + +/* + * This file is part of the Kimai time-tracking app. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Twig\SecurityPolicy; + +use App\Invoice\InvoiceModel; +use App\Pdf\PdfContext; +use Symfony\Component\String\UnicodeString; +use Twig\Markup; +use Twig\Sandbox\SecurityPolicy; +use Twig\Sandbox\SecurityPolicyInterface; +use Twig\Template; + +/** + * Represents the security policy for custom Twig invoice templates. + */ +final class InvoicePolicy implements SecurityPolicyInterface +{ + private ChainPolicy $policy; + + public function __construct() + { + $this->policy = new ChainPolicy(); + $this->policy->addPolicy(new DefaultPolicy()); + $this->policy->addPolicy(new SecurityPolicy( + ['block', 'if', 'for', 'set', 'extends'], + [ + // Twig core filters + 'map', 'escape', 'trans', 'default', 'nl2br', 'trim', 'raw', + 'join', 'u', 'slice', 'date', 'month_name', 'first', 'country_name', + 'replace', 'length', 'number_format', 'split', + + // Kimai filters + 'md2html', 'desc2html', 'comment2html', 'comment1line', 'multiline_indent', 'nl2str', + 'date_short', 'duration', 'amount', 'money', 'duration_decimal', + ], + [ + PdfContext::class => ['setoption'], + InvoiceModel::class => ['toarray'], + ], + [], // properties + [ + // Twig core functions + 'cycle', 'asset', 'range', + + // Kimai functions + 'encore_entry_css_source', 'qr_code_data_uri', 'config', + ] + )); + } + + public function checkSecurity($tags, $filters, $functions): void + { + $this->policy->checkSecurity($tags, $filters, $functions); + } + + public function checkMethodAllowed($obj, $method): void + { + if ($obj instanceof Template || $obj instanceof Markup || $obj instanceof UnicodeString) { + return; + } + + $lm = strtolower($method); + + if (str_starts_with($lm, 'get') || str_starts_with($lm, 'is') || str_starts_with($lm, 'has')) { + return; + } + + if ($lm === '__tostring') { + return; + } + + $this->policy->checkMethodAllowed($obj, $method); + } + + public function checkPropertyAllowed($obj, $property): void + { + $this->policy->checkPropertyAllowed($obj, $property); + } +}
src/Twig/TwigRendererTrait.php+9 −0 modified@@ -9,9 +9,11 @@ namespace App\Twig; +use App\Twig\SecurityPolicy\InvoicePolicy; use Symfony\Bridge\Twig\Extension\TranslationExtension; use Symfony\Contracts\Translation\LocaleAwareInterface; use Twig\Environment; +use Twig\Extension\SandboxExtension; /** * @internal @@ -30,6 +32,13 @@ protected function renderTwigTemplateWithLanguage(Environment $twig, string $tem $previousFormatLocale = $this->switchFormatLocale($twig, $formatLocale); } + // enable basic security measures + if (!$twig->hasExtension(SandboxExtension::class)) { + $sandbox = new SandboxExtension(new InvoicePolicy()); + $sandbox->enableSandbox(); + $twig->addExtension($sandbox); + } + $content = $twig->render($template, $options); if ($previousTranslation !== null) {
src/User/LoginManager.php+1 −1 modified@@ -12,12 +12,12 @@ use App\Entity\User; use App\Event\UserInteractiveLoginEvent; use App\Security\UserChecker; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; final class LoginManager {
src/User/UserService.php+1 −1 modified@@ -22,7 +22,7 @@ use App\Repository\UserRepository; use App\Validator\ValidationFailedException; use InvalidArgumentException; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Validator\Validator\ValidatorInterface;
src/Widget/Type/AbstractActiveUsers.php+2 −2 modified@@ -12,8 +12,8 @@ abstract class AbstractActiveUsers extends AbstractWidgetType { /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array {
src/Widget/Type/AbstractAmountPeriod.php+4 −4 modified@@ -11,7 +11,7 @@ use App\Event\RevenueStatisticEvent; use App\Repository\TimesheetRepository; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; abstract class AbstractAmountPeriod extends AbstractWidget { @@ -30,8 +30,8 @@ public function getTemplateName(): string } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -41,7 +41,7 @@ public function getOptions(array $options = []): array } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options * @return array<string, float> */ protected function getRevenue(?\DateTimeInterface $begin, ?\DateTimeInterface $end, array $options = []): array
src/Widget/Type/AbstractCounterDuration.php+2 −2 modified@@ -12,8 +12,8 @@ abstract class AbstractCounterDuration extends AbstractWidgetType { /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array {
src/Widget/Type/AbstractCounterYear.php+2 −2 modified@@ -21,7 +21,7 @@ public function __construct(private SystemConfiguration $systemConfiguration) } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed { @@ -39,7 +39,7 @@ public function getData(array $options = []): mixed } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ abstract protected function getYearData(\DateTimeInterface $begin, \DateTimeInterface $end, array $options = []): mixed;
src/Widget/Type/AbstractUserRevenuePeriod.php+4 −4 modified@@ -11,7 +11,7 @@ use App\Event\UserRevenueStatisticEvent; use App\Repository\TimesheetRepository; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; abstract class AbstractUserRevenuePeriod extends AbstractWidget { @@ -35,8 +35,8 @@ public function getPermissions(): array } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -46,7 +46,7 @@ public function getOptions(array $options = []): array } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options * @return array<string, float> */ protected function getRevenue(?\DateTimeInterface $begin, ?\DateTimeInterface $end, array $options = []): array
src/Widget/Type/AbstractWidget.php+2 −2 modified@@ -68,8 +68,8 @@ public function setOption(string $name, $value): void } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + * @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array {
src/Widget/Type/ActiveTimesheets.php+14 −4 modified@@ -9,6 +9,7 @@ namespace App\Widget\Type; +use App\Repository\Query\TimesheetQuery; use App\Repository\TimesheetRepository; use App\Widget\WidgetException; use App\Widget\WidgetInterface; @@ -20,16 +21,25 @@ public function __construct(private TimesheetRepository $repository) } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + * @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { - return array_merge(['color' => WidgetInterface::COLOR_TOTAL, 'icon' => 'duration'], parent::getOptions($options)); + // we can safely assume that the user can see + $route = 'admin_timesheet'; + + return array_merge([ + 'color' => WidgetInterface::COLOR_TOTAL, + 'icon' => 'duration', + 'route' => $route, + 'routeOptions' => ['state' => TimesheetQuery::STATE_RUNNING], + ], parent::getOptions($options)); } public function getPermissions(): array { + // if you ever loosen that check, make sure that the above link is probably removed return ['view_all_data']; } @@ -44,7 +54,7 @@ public function getTitle(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/ActiveUsersMonth.php+3 −3 modified@@ -20,8 +20,8 @@ public function __construct(private TimesheetRepository $repository) } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -34,7 +34,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/ActiveUsersToday.php+3 −3 modified@@ -20,8 +20,8 @@ public function __construct(private TimesheetRepository $repository) } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -34,7 +34,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/ActiveUsersTotal.php+3 −3 modified@@ -20,8 +20,8 @@ public function __construct(private TimesheetRepository $repository) } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -34,7 +34,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/ActiveUsersWeek.php+3 −3 modified@@ -20,8 +20,8 @@ public function __construct(private TimesheetRepository $repository) } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -34,7 +34,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/ActiveUsersYear.php+3 −3 modified@@ -22,8 +22,8 @@ public function __construct(private TimesheetRepository $repository, SystemConfi } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -34,7 +34,7 @@ public function getOptions(array $options = []): array } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ protected function getYearData(\DateTimeInterface $begin, \DateTimeInterface $end, array $options = []): mixed {
src/Widget/Type/AmountMonth.php+3 −3 modified@@ -14,8 +14,8 @@ final class AmountMonth extends AbstractAmountPeriod { /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -28,7 +28,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/AmountToday.php+3 −3 modified@@ -14,8 +14,8 @@ final class AmountToday extends AbstractAmountPeriod { /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -28,7 +28,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/AmountTotal.php+3 −3 modified@@ -14,8 +14,8 @@ final class AmountTotal extends AbstractAmountPeriod { /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -33,7 +33,7 @@ public function getPermissions(): array } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/AmountWeek.php+3 −3 modified@@ -14,8 +14,8 @@ final class AmountWeek extends AbstractAmountPeriod { /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -28,7 +28,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/AmountYear.php+4 −4 modified@@ -15,7 +15,7 @@ use App\Repository\TimesheetRepository; use App\Widget\WidgetException; use App\Widget\WidgetInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; final class AmountYear extends AbstractCounterYear { @@ -25,8 +25,8 @@ public function __construct(private TimesheetRepository $repository, SystemConfi } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -37,7 +37,7 @@ public function getOptions(array $options = []): array } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ protected function getYearData(\DateTimeInterface $begin, \DateTimeInterface $end, array $options = []): mixed {
src/Widget/Type/DailyWorkingTimeChart.php+3 −3 modified@@ -46,8 +46,8 @@ public function isInternal(): bool } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -61,7 +61,7 @@ public function getOptions(array $options = []): array } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/DurationMonth.php+3 −3 modified@@ -20,8 +20,8 @@ public function __construct(private TimesheetRepository $repository) } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -44,7 +44,7 @@ public function getTitle(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/DurationToday.php+3 −3 modified@@ -20,8 +20,8 @@ public function __construct(private TimesheetRepository $repository) } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -44,7 +44,7 @@ public function getTitle(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/DurationTotal.php+3 −3 modified@@ -20,8 +20,8 @@ public function __construct(private TimesheetRepository $repository) } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -44,7 +44,7 @@ public function getTitle(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/DurationWeek.php+3 −3 modified@@ -20,8 +20,8 @@ public function __construct(private TimesheetRepository $repository) } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -44,7 +44,7 @@ public function getTitle(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/DurationYear.php+3 −3 modified@@ -22,8 +22,8 @@ public function __construct(private TimesheetRepository $repository, SystemConfi } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -34,7 +34,7 @@ public function getOptions(array $options = []): array } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ protected function getYearData(\DateTimeInterface $begin, \DateTimeInterface $end, array $options = []): mixed {
src/Widget/Type/PaginatedWorkingTimeChart.php+3 −3 modified@@ -48,8 +48,8 @@ public function getTemplateName(): string } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -87,7 +87,7 @@ private function getLastWeekInYear($year): int } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/TotalsActivity.php+3 −3 modified@@ -25,8 +25,8 @@ public function getTitle(): string } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -38,7 +38,7 @@ public function getOptions(array $options = []): array } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/TotalsCustomer.php+3 −3 modified@@ -25,8 +25,8 @@ public function getTitle(): string } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -38,7 +38,7 @@ public function getOptions(array $options = []): array } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/TotalsProject.php+3 −3 modified@@ -25,8 +25,8 @@ public function getTitle(): string } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -38,7 +38,7 @@ public function getOptions(array $options = []): array } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/TotalsUser.php+3 −3 modified@@ -20,8 +20,8 @@ public function __construct(private UserRepository $repository) } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -33,7 +33,7 @@ public function getOptions(array $options = []): array } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/UserAmountMonth.php+3 −3 modified@@ -14,8 +14,8 @@ final class UserAmountMonth extends AbstractUserRevenuePeriod { /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -28,7 +28,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/UserAmountToday.php+3 −3 modified@@ -14,8 +14,8 @@ final class UserAmountToday extends AbstractUserRevenuePeriod { /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -28,7 +28,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/UserAmountTotal.php+3 −3 modified@@ -14,8 +14,8 @@ final class UserAmountTotal extends AbstractUserRevenuePeriod { /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -28,7 +28,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/UserAmountWeek.php+3 −3 modified@@ -14,8 +14,8 @@ final class UserAmountWeek extends AbstractUserRevenuePeriod { /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -28,7 +28,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/UserAmountYear.php+4 −4 modified@@ -15,7 +15,7 @@ use App\Repository\TimesheetRepository; use App\Widget\WidgetException; use App\Widget\WidgetInterface; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; final class UserAmountYear extends AbstractCounterYear { @@ -45,8 +45,8 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -57,7 +57,7 @@ public function getOptions(array $options = []): array } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ protected function getYearData(\DateTimeInterface $begin, \DateTimeInterface $end, array $options = []): mixed {
src/Widget/Type/UserDurationMonth.php+3 −3 modified@@ -20,8 +20,8 @@ public function __construct(private TimesheetRepository $repository) } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -39,7 +39,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/UserDurationToday.php+3 −3 modified@@ -20,8 +20,8 @@ public function __construct(private TimesheetRepository $repository) } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -39,7 +39,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/UserDurationTotal.php+3 −3 modified@@ -20,8 +20,8 @@ public function __construct(private TimesheetRepository $repository) } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -39,7 +39,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/UserDurationWeek.php+3 −3 modified@@ -20,8 +20,8 @@ public function __construct(private TimesheetRepository $repository) } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -39,7 +39,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/UserDurationYear.php+3 −3 modified@@ -32,8 +32,8 @@ public function getTemplateName(): string } /** - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array { @@ -44,7 +44,7 @@ public function getOptions(array $options = []): array } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ protected function getYearData(\DateTimeInterface $begin, \DateTimeInterface $end, array $options = []): mixed {
src/Widget/Type/UserTeamProjects.php+1 −1 modified@@ -60,7 +60,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/Type/UserTeams.php+1 −1 modified@@ -53,7 +53,7 @@ public function getId(): string } /** - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options */ public function getData(array $options = []): mixed {
src/Widget/WidgetInterface.php+3 −3 modified@@ -71,7 +71,7 @@ public function setUser(User $user): void; * make sure that the given $options will overwrite the internal option for * this one call. * - * @param array<string, string|bool|int|null> $options + * @param array<string, string|bool|int|null|array<string, mixed>> $options * @return mixed|null */ public function getData(array $options = []): mixed; @@ -85,8 +85,8 @@ public function getData(array $options = []): mixed; * You can validate the options or simply return: * return array_merge($this->options, $options); * - * @param array<string, string|bool|int|null> $options - * @return array<string, string|bool|int|null> + * @param array<string, string|bool|int|null|array<string, mixed>> $options + * @return array<string, string|bool|int|null|array<string, mixed>> */ public function getOptions(array $options = []): array;
src/WorkingTime/WorkingTimeService.php+1 −1 modified@@ -20,7 +20,7 @@ use App\WorkingTime\Model\Month; use App\WorkingTime\Model\Year; use App\WorkingTime\Model\YearPerUserSummary; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\EventDispatcherInterface; /** * @internal this API and the entire namespace is instable: you should expect changes!
symfony.lock+0 −3 modified@@ -511,9 +511,6 @@ "ref": "179470cb6492db92dffee208cfdb436f175c93b4" } }, - "symfony/polyfill-ctype": { - "version": "v1.8.0" - }, "symfony/polyfill-intl-grapheme": { "version": "v1.13.1" },
templates/reporting/report_by_user_data.html.twig+4 −2 modified@@ -17,10 +17,12 @@ <table class="table table-hover dataTable"> <thead> <tr> - <th> </th> + <th>{% block upper_left %}{{ user.displayName }}{% endblock %}</th> <th class="text-center reportDataTypeTitle">{{ dataTypeTitle|trans }}</th> {% for column in period.dateTimes %} - {% block period_name %}{% endblock %} + {% block period_name %} + <th>{{ column|date_short }}</th> + {% endblock %} {% set columns = columns + 1 %} {% set dateKey = column|report_date %} {% set totalsDuration = totalsDuration|merge({(dateKey): 0}) %}
templates/reporting/report_by_user.html.twig+1 −0 modified@@ -7,6 +7,7 @@ {{ column|date_weekday }} </th> {% endblock %} + {% block upper_left %} {% endblock %} {% block column_classes_project -%} {% if column.date is weekend(user) %} weekend{% endif %}{% if column.date is today %} today{% endif %} {%- endblock %}
templates/reporting/report_by_user_layout.html.twig+2 −0 modified@@ -9,6 +9,8 @@ {{ widgets.username(user) }} {% endif %} {{ form_widget(form.sumType) }} + {% from '@theme/components/buttons.html.twig' import submit_button %} + {{ submit_button('download', {'attr': {'formaction': path(export_route)}, 'icon': 'download', 'combined': false}, 'primary') }} {% endblock %} {% block report %}
templates/reporting/report_by_user_year_export.html.twig+8 −0 added@@ -0,0 +1,8 @@ +{% embed 'reporting/report_by_user_data.html.twig' %} + {% block period_name %} + <th>{{ column|month_name }} {{ column|date_format('Y') }}</th> + {% endblock %} + {% block column_classes_project %}{% endblock %} + {% block column_classes_activity %}{% endblock %} + {% block column_classes_total %}{% endblock %} +{% endembed %}
templates/reporting/report_by_user_year.html.twig+1 −0 modified@@ -10,6 +10,7 @@ </a> </th> {% endblock %} + {% block upper_left %} {% endblock %} {% block column_classes_project %}{% endblock %} {% block column_classes_activity %}{% endblock %} {% block column_classes_total %}{% endblock %}
templates/security/password-reset/check_email.html.twig+1 −1 modified@@ -3,7 +3,7 @@ {% block login_form %} <p> - {{ 'resetting.check_email'|trans({'%tokenLifetime%': tokenLifetime})|nl2br }} + {{ 'resetting.check_email'|trans({'%tokenLifetime%': tokenLifetime|duration})|nl2br }} </p> {% endblock %}
templates/timesheet/multi-update.html.twig+5 −1 modified@@ -22,7 +22,11 @@ </div> </div> </fieldset> - <fieldset class="form-fieldset pb-0"> + <fieldset class="form-fieldset pb-0 h-100"> + {{ form_row(form.replaceDescription) }} + {{ form_row(form.description) }} + </fieldset> + <fieldset class="form-fieldset pb-0 h-100"> {{ form_row(form.replaceTags) }} {{ form_row(form.tags) }} </fieldset>
templates/user/api-token.html.twig+5 −1 modified@@ -13,7 +13,11 @@ <div class="row mb-3"> <div class="d-flex"> - {{ 'api_password.intro'|trans }} + <div class="me-auto"> + <p>{{ 'api_password.intro'|trans }}</p> + + <p>{{ 'username'|trans }}: {{ user.userIdentifier }}</p> + </div> <div class="ms-auto"> <a class="btn btn-icon" target="_blank" href="{{ path('api.swagger_ui') }}" data-toggle="tooltip" title="Swagger API Docs">{{ icon('documentation', true) }}</a>
templates/widget/widget-counter.html.twig+3 −2 modified@@ -7,12 +7,14 @@ <div class="card-body"> <div class="row align-items-center"> <div class="col-auto"> + {% if not url is empty %}<a href="{{ url }}">{% endif %} <span class="bg-{{ options.color|default('green') }} text-white avatar"> <i class="{{ options.icon|icon(true, options.icon) }}"></i> </span> + {% if not url is empty %}</a>{% endif %} </div> <div class="col"> - {% if not url is empty %}<a href="{{ url }}">{% endif %} + <div class="font-weight-medium"> {{ title|trans }} </div> @@ -21,7 +23,6 @@ {{ data }} {% endblock %} </div> - {% if not url is empty %}</a>{% endif %} </div> </div> </div>
templates/wizard/layout.html.twig+5 −3 modified@@ -20,11 +20,13 @@ {% endblock %} {% block wizard_progress_bar %} - {{ progress_bar({current: percent, max: 100, min: 0}) }} + {% if percent is defined and percent is not null %} + {{ progress_bar({current: percent, max: 100, min: 0}) }} + {% endif %} {% endblock %} {% block wizard_previous_button %} - {% if previous is defined %} + {% if previous is defined and previous is not null %} {{ button(false, { title : 'Previous'|trans({}, 'TablerBundle'), combined: true, url: path('wizard', {'wizard': previous}) }, 'link') }} {% endif %} {% endblock %} @@ -33,7 +35,7 @@ {% if form is defined %} {{ submit_button(false, { title : 'Next'|trans({}, 'TablerBundle'), combined: true }, 'primary') }} {% else %} - {% if next is defined %} + {% if next is defined and next is not null %} {{ button(false, { title : 'Next'|trans({}, 'TablerBundle'), combined: true, url: path('wizard', {'wizard': next}) }, 'primary') }} {% endif %} {% endif %}
templates/wizard/password.html.twig+14 −0 added@@ -0,0 +1,14 @@ +{% extends 'wizard/layout.html.twig' %} + +{% block wizard_content %} + <div class="card-body text-center py-4 p-sm-6"> + <h1 class="mt-2">{{ 'wizard.password.title'|trans({}, 'wizard') }}</h1> + <p class="text-body-secondary">{{ 'wizard.password.description'|trans({}, 'wizard') }}</p> + </div> + <div class="hr-text hr-text-center hr-text-spaceless">{{ 'wizard.separator'|trans({}, 'wizard') }}</div> + <div class="card-body"> + <div class="mb-3"> + {{ form_rest(form) }} + </div> + </div> +{% endblock %}
tests/API/APIControllerBaseTest.php+2 −0 modified@@ -340,6 +340,7 @@ protected static function getExpectedResponseStructure(string $type): array 'id' => 'int', 'username' => 'string', 'enabled' => 'bool', + 'apiToken' => 'bool', 'color' => '@string', 'alias' => '@string', 'accountNumber' => '@string', @@ -353,6 +354,7 @@ protected static function getExpectedResponseStructure(string $type): array 'id' => 'int', 'username' => 'string', 'enabled' => 'bool', + 'apiToken' => 'bool', 'alias' => '@string', 'title' => '@string', 'supervisor' => ['result' => 'object', 'type' => '@UserEntity'],
tests/API/UserControllerTest.php+44 −9 modified@@ -45,7 +45,9 @@ public function testGetCollection(): void { $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $this->assertAccessIsGranted($client, '/api/users'); - $result = json_decode($client->getResponse()->getContent(), true); + $content = $client->getResponse()->getContent(); + $this->assertIsString($content); + $result = json_decode($content, true); $this->assertIsArray($result); $this->assertNotEmpty($result); @@ -55,11 +57,30 @@ public function testGetCollection(): void } } + public function testGetCollectionFull(): void + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); + $this->assertAccessIsGranted($client, '/api/users?full=true'); + + $content = $client->getResponse()->getContent(); + $this->assertIsString($content); + $result = json_decode($content, true); + + $this->assertIsArray($result); + $this->assertNotEmpty($result); + $this->assertEquals(7, \count($result)); + foreach ($result as $user) { + self::assertApiResponseTypeStructure('UserEntity', $user); + } + } + public function testGetCollectionWithQuery(): void { $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $this->assertAccessIsGranted($client, '/api/users', 'GET', ['visible' => 2, 'orderBy' => 'email', 'order' => 'DESC', 'term' => 'chris']); - $result = json_decode($client->getResponse()->getContent(), true); + $content = $client->getResponse()->getContent(); + $this->assertIsString($content); + $result = json_decode($content, true); $this->assertIsArray($result); $this->assertNotEmpty($result); @@ -73,7 +94,9 @@ public function testGetCollectionWithQuery2(): void { $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $this->assertAccessIsGranted($client, '/api/users', 'GET', ['visible' => 3, 'orderBy' => 'email', 'order' => 'DESC']); - $result = json_decode($client->getResponse()->getContent(), true); + $content = $client->getResponse()->getContent(); + $this->assertIsString($content); + $result = json_decode($content, true); $this->assertIsArray($result); $this->assertNotEmpty($result); @@ -87,7 +110,9 @@ public function testGetEntity(): void { $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $this->assertAccessIsGranted($client, '/api/users/1'); - $result = json_decode($client->getResponse()->getContent(), true); + $content = $client->getResponse()->getContent(); + $this->assertIsString($content); + $result = json_decode($content, true); $this->assertIsArray($result); self::assertApiResponseTypeStructure('UserEntity', $result); @@ -100,7 +125,9 @@ public function testGetMyProfile(): void { $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $this->assertAccessIsGranted($client, '/api/users/me'); - $result = json_decode($client->getResponse()->getContent(), true); + $content = $client->getResponse()->getContent(); + $this->assertIsString($content); + $result = json_decode($content, true); $this->assertIsArray($result); self::assertApiResponseTypeStructure('UserEntity', $result); @@ -124,7 +151,9 @@ public function testGetEntityAccessAllowedForOwnProfile(): void { $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $this->assertAccessIsGranted($client, '/api/users/2'); - $result = json_decode($client->getResponse()->getContent(), true); + $content = $client->getResponse()->getContent(); + $this->assertIsString($content); + $result = json_decode($content, true); $this->assertIsArray($result); self::assertApiResponseTypeStructure('UserEntity', $result); @@ -150,7 +179,9 @@ public function testPostAction(): void $this->request($client, '/api/users', 'POST', [], json_encode($data)); $this->assertTrue($client->getResponse()->isSuccessful()); - $result = json_decode($client->getResponse()->getContent(), true); + $content = $client->getResponse()->getContent(); + $this->assertIsString($content); + $result = json_decode($content, true); $this->assertIsArray($result); self::assertApiResponseTypeStructure('UserEntity', $result); $this->assertNotEmpty($result['id']); @@ -238,7 +269,9 @@ public function testPatchAction(): void ]; $this->request($client, '/api/users', 'POST', [], json_encode($data)); $this->assertTrue($client->getResponse()->isSuccessful()); - $result = json_decode($client->getResponse()->getContent(), true); + $content = $client->getResponse()->getContent(); + $this->assertIsString($content); + $result = json_decode($content, true); self::assertFalse($result['enabled']); $data = [ @@ -253,7 +286,9 @@ public function testPatchAction(): void $this->request($client, '/api/users/' . $result['id'], 'PATCH', [], json_encode($data)); $this->assertTrue($client->getResponse()->isSuccessful()); - $result = json_decode($client->getResponse()->getContent(), true); + $content = $client->getResponse()->getContent(); + $this->assertIsString($content); + $result = json_decode($content, true); $this->assertIsArray($result); self::assertApiResponseTypeStructure('UserEntity', $result); $this->assertNotEmpty($result['id']);
tests/Command/UserLoginLinkCommandTest.php+47 −0 added@@ -0,0 +1,47 @@ +<?php + +/* + * This file is part of the Kimai time-tracking app. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests\Command; + +use App\Command\UserLoginLinkCommand; +use App\Repository\UserRepository; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface; + +/** + * @covers \App\Command\UserLoginLinkCommand + * @group integration + */ +class UserLoginLinkCommandTest extends KernelTestCase +{ + private Application $application; + + protected function setUp(): void + { + parent::setUp(); + $kernel = self::bootKernel(); + $this->application = new Application($kernel); + + $loginLinkHandler = $this->createMock(LoginLinkHandlerInterface::class); + $userRepository = $this->createMock(UserRepository::class); + $requestStack = $this->createMock(RequestStack::class); + + $this->application->add(new UserLoginLinkCommand($loginLinkHandler, $userRepository, $requestStack)); + } + + public function testCommandName(): void + { + $application = $this->application; + + $command = $application->find('kimai:user:login-link'); + self::assertInstanceOf(UserLoginLinkCommand::class, $command); + } +}
tests/DependencyInjection/ConfigurationTest.php+1 −1 modified@@ -309,7 +309,7 @@ public function testFullDefaultConfig() 1 => 'templates/invoice/renderer/', ], 'number_format' => '{Y}/{cy,3}', - 'upload_twig' => true, + 'upload_twig' => false, ], 'export' => [ 'documents' => [
tests/Entity/TimesheetTest.php+3 −3 modified@@ -34,7 +34,7 @@ public function testDefaultValues(): void self::assertNull($sut->getBegin()); self::assertNull($sut->getEnd()); self::assertTrue($sut->isBillable()); - self::assertNull($sut->getModifiedAt()); + self::assertNotNull($sut->getModifiedAt()); self::assertSame(0, $sut->getDuration()); self::assertSame(0, $sut->getDuration(true)); self::assertSame(0, $sut->getDuration(false)); @@ -179,7 +179,7 @@ public function testCategory(): void public function testClone(): void { $sut = new Timesheet(); - self::assertNull($sut->getModifiedAt()); + self::assertNotNull($sut->getModifiedAt()); $sut->setExported(true); $sut->setDescription('Invalid timesheet category "foo" given, expected one of: work, holiday, sickness, parental, overtime'); @@ -210,7 +210,7 @@ public function testClone(): void $clone = clone $sut; self::assertNotNull($sut->getModifiedAt()); - self::assertNull($clone->getModifiedAt()); + self::assertNotNull($clone->getModifiedAt()); foreach ($sut->getMetaFields() as $metaField) { $cloneMeta = $clone->getMetaField($metaField->getName());
tests/Export/Renderer/PdfRendererTest.php+26 −2 modified@@ -15,6 +15,7 @@ use App\Project\ProjectStatisticService; use App\Tests\Mocks\FileHelperFactory; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Twig\Environment; /** @@ -25,7 +26,7 @@ */ class PdfRendererTest extends AbstractRendererTest { - public function testConfiguration() + public function testConfiguration(): void { $sut = new PDFRenderer( $this->createMock(Environment::class), @@ -43,12 +44,14 @@ public function testConfiguration() $this->assertEquals(['foo' => 'bar', 'bar1' => 'foo1'], $sut->getPdfOptions()); } - public function testRender() + public function testRenderAttachment(): void { $kernel = self::bootKernel(); /** @var Environment $twig */ $twig = self::getContainer()->get('twig'); + /** @var RequestStack $stack */ $stack = self::getContainer()->get('request_stack'); + /** @var string $cacheDir */ $cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir'); $converter = new MPdfConverter((new FileHelperFactory($this))->create(), $cacheDir); $request = new Request(); @@ -63,9 +66,30 @@ public function testRender() $this->assertEquals('application/pdf', $response->headers->get('Content-Type')); $this->assertEquals('attachment; filename=' . $prefix . '-Customer_Name-project_name.pdf', $response->headers->get('Content-Disposition')); $this->assertNotEmpty($response->getContent()); + } + + public function testRenderInline(): void + { + $kernel = self::bootKernel(); + /** @var Environment $twig */ + $twig = self::getContainer()->get('twig'); + /** @var RequestStack $stack */ + $stack = self::getContainer()->get('request_stack'); + /** @var string $cacheDir */ + $cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir'); + $converter = new MPdfConverter((new FileHelperFactory($this))->create(), $cacheDir); + $request = new Request(); + $request->setLocale('en'); + $stack->push($request); + + $sut = new PDFRenderer($twig, $converter, $this->createMock(ProjectStatisticService::class)); + + $prefix = date('Ymd'); $sut->setDispositionInline(true); $response = $this->render($sut); + $this->assertEquals('application/pdf', $response->headers->get('Content-Type')); $this->assertEquals('inline; filename=' . $prefix . '-Customer_Name-project_name.pdf', $response->headers->get('Content-Disposition')); + $this->assertNotEmpty($response->getContent()); } }
tests/Export/Timesheet/HtmlRendererTest.php+4 −2 modified@@ -14,6 +14,7 @@ use App\Project\ProjectStatisticService; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Twig\Environment; /** @@ -22,7 +23,7 @@ */ class HtmlRendererTest extends AbstractRendererTest { - public function testConfiguration() + public function testConfiguration(): void { $sut = new HtmlRenderer( $this->createMock(Environment::class), @@ -34,11 +35,12 @@ public function testConfiguration() $this->assertEquals('print', $sut->getId()); } - public function testRender() + public function testRender(): void { $kernel = self::bootKernel(); /** @var Environment $twig */ $twig = self::getContainer()->get('twig'); + /** @var RequestStack $stack */ $stack = self::getContainer()->get('request_stack'); $request = new Request(); $request->setLocale('en');
tests/Export/Timesheet/PdfRendererTest.php+5 −2 modified@@ -15,6 +15,7 @@ use App\Project\ProjectStatisticService; use App\Tests\Mocks\FileHelperFactory; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Twig\Environment; /** @@ -25,7 +26,7 @@ */ class PdfRendererTest extends AbstractRendererTest { - public function testConfiguration() + public function testConfiguration(): void { $sut = new PDFRenderer( $this->createMock(Environment::class), @@ -36,12 +37,14 @@ public function testConfiguration() $this->assertEquals('pdf', $sut->getId()); } - public function testRender() + public function testRender(): void { $kernel = self::bootKernel(); /** @var Environment $twig */ $twig = self::getContainer()->get('twig'); + /** @var RequestStack $stack */ $stack = self::getContainer()->get('request_stack'); + /** @var string $cacheDir */ $cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir'); $converter = new MPdfConverter((new FileHelperFactory($this))->create(), $cacheDir); $request = new Request();
tests/Invoice/Renderer/PdfRendererTest.php+79 −2 modified@@ -10,11 +10,13 @@ namespace App\Tests\Invoice\Renderer; use App\Invoice\Renderer\PdfRenderer; +use App\Model\InvoiceDocument; use App\Pdf\HtmlToPdfConverter; use App\Pdf\MPdfConverter; use App\Tests\Mocks\FileHelperFactory; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Twig\Environment; use Twig\Loader\ArrayLoader; use Twig\Loader\FilesystemLoader; @@ -28,7 +30,7 @@ class PdfRendererTest extends KernelTestCase { use RendererTestTrait; - public function testSupports() + public function testSupports(): void { $env = new Environment(new ArrayLoader([])); $sut = new PdfRenderer($env, $this->createMock(HtmlToPdfConverter::class)); @@ -40,11 +42,12 @@ public function testSupports() $this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods', true))); } - public function testRender() + public function testRenderAttachment(): void { $kernel = self::bootKernel(); /** @var Environment $twig */ $twig = self::getContainer()->get('twig'); + /** @var RequestStack $stack */ $stack = self::getContainer()->get('request_stack'); $cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir'); @@ -64,9 +67,83 @@ public function testRender() $this->assertEquals('application/pdf', $response->headers->get('Content-Type')); $this->assertStringContainsString('attachment; filename', $response->headers->get('Content-Disposition')); $this->assertNotEmpty($response->getContent()); + } + + public function testRenderInline(): void + { + $kernel = self::bootKernel(); + /** @var Environment $twig */ + $twig = self::getContainer()->get('twig'); + /** @var RequestStack $stack */ + $stack = self::getContainer()->get('request_stack'); + /** @var string $cacheDir */ + $cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir'); + + $request = new Request(); + $request->setLocale('en'); + $stack->push($request); + + /** @var FilesystemLoader $loader */ + $loader = $twig->getLoader(); + $loader->addPath(__DIR__ . '/../templates/', 'invoice'); + + $sut = new PdfRenderer($twig, new MPdfConverter((new FileHelperFactory($this))->create(), $cacheDir)); + $model = $this->getInvoiceModel(); + $document = $this->getInvoiceDocument('default.pdf.twig', true); $sut->setDispositionInline(true); $response = $sut->render($document, $model); $this->assertStringContainsString('inline; filename', $response->headers->get('Content-Disposition')); + $this->assertNotEmpty($response->getContent()); + } + + public function testRenderAll(): void + { + $kernel = self::bootKernel(); + /** @var Environment $twig */ + $twig = self::getContainer()->get('twig'); + $stack = self::getContainer()->get('request_stack'); + /** @var string $cacheDir */ + $cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir'); + + $request = new Request(); + $request->setLocale('en'); + $stack->push($request); + + /** @var FilesystemLoader $loader */ + $loader = $twig->getLoader(); + $loader->addPath(__DIR__ . '/../templates/', 'invoice'); + + $dirs = [ + __DIR__ . '/../../../templates/invoice/renderer/', + __DIR__ . '/../../../var/invoices/', + __DIR__ . '/../../../var/invoices_customer/', + __DIR__ . '/../../../var/invoices_old/', + ]; + + $files = []; + foreach ($dirs as $dir) { + if (!is_dir($dir)) { + continue; + } + $dir = realpath($dir); + $loader->addPath($dir . '/', 'invoice'); + $found = glob($dir . '/*.pdf.twig'); + if ($found !== false) { + $files = array_merge($files, $found); + } + } + + $sut = new PdfRenderer($twig, new MPdfConverter((new FileHelperFactory($this))->create(), $cacheDir)); + $model = $this->getInvoiceModel(); + + foreach ($files as $filename) { + $document = new InvoiceDocument(new \SplFileInfo($filename)); + + $response = $sut->render($document, $model); + $this->assertEquals('application/pdf', $response->headers->get('Content-Type')); + $this->assertStringContainsString('attachment; filename', $response->headers->get('Content-Disposition')); + $this->assertNotEmpty($response->getContent()); + } } }
tests/Invoice/Renderer/TwigRendererTest.php+55 −3 modified@@ -10,8 +10,10 @@ namespace App\Tests\Invoice\Renderer; use App\Invoice\Renderer\TwigRenderer; +use App\Model\InvoiceDocument; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Twig\Environment; use Twig\Loader\FilesystemLoader; @@ -24,7 +26,7 @@ class TwigRendererTest extends KernelTestCase { use RendererTestTrait; - public function testSupports() + public function testSupports(): void { $loader = new FilesystemLoader(); $env = new Environment($loader); @@ -38,11 +40,12 @@ public function testSupports() $this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods', true))); } - public function testRender() + public function testRender(): void { $kernel = self::bootKernel(); /** @var Environment $twig */ $twig = self::getContainer()->get('twig'); + /** @var RequestStack $stack */ $stack = self::getContainer()->get('request_stack'); $request = new Request(); $request->setLocale('en'); @@ -55,7 +58,7 @@ public function testRender() $sut = new TwigRenderer($twig); $model = $this->getInvoiceModel(); - $model->getTemplate()->setLanguage('de'); + $model->getTemplate()?->setLanguage('de'); $document = $this->getInvoiceDocument('timesheet.html.twig'); $response = $sut->render($document, $model); @@ -75,4 +78,53 @@ public function testRender() "bar\r\n" . 'Hello'), $content); } + + public function testRenderAll(): void + { + $kernel = self::bootKernel(); + /** @var Environment $twig */ + $twig = self::getContainer()->get('twig'); + /** @var RequestStack $stack */ + $stack = self::getContainer()->get('request_stack'); + $request = new Request(); + $request->setLocale('en'); + $stack->push($request); + + /** @var FilesystemLoader $loader */ + $loader = $twig->getLoader(); + $loader->addPath($this->getInvoiceTemplatePath(), 'invoice'); + + $dirs = [ + __DIR__ . '/../../../templates/invoice/renderer/', + __DIR__ . '/../../../var/invoices/', + __DIR__ . '/../../../var/invoices_customer/', + __DIR__ . '/../../../var/invoices_old/', + ]; + + $files = []; + foreach ($dirs as $dir) { + if (!is_dir($dir)) { + continue; + } + $dir = realpath($dir); + $loader->addPath($dir . '/', 'invoice'); + $found = glob($dir . '/*.html.twig'); + if ($found !== false) { + $files = array_merge($files, $found); + } + } + + $sut = new TwigRenderer($twig); + + $model = $this->getInvoiceModel(); + $model->getTemplate()?->setLanguage('de'); + + foreach ($files as $filename) { + $document = new InvoiceDocument(new \SplFileInfo($filename)); + + $response = $sut->render($document, $model); + $this->assertEquals('text/html; charset=UTF-8', $response->headers->get('Content-Type')); + $this->assertNotEmpty($response->getContent()); + } + } }
tests/Invoice/templates/default.pdf.twig+1 −2 modified@@ -1,6 +1,5 @@ <!DOCTYPE html> -{% set fallback = app.request is not null ? app.request.locale : 'en' %} -{% set language = model.template.language|default(fallback) %} +{% set language = invoice['invoice.language'] %} {% set currency = model.currency %} <html lang="{{ language }}"> <head>
tests/phpstan.neon+2 −92 modified@@ -1237,11 +1237,6 @@ parameters: count: 1 path: API/UserControllerTest.php - - - message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#" - count: 9 - path: API/UserControllerTest.php - - message: "#^Parameter \\#5 \\$content of method App\\\\Tests\\\\API\\\\APIControllerBaseTest\\:\\:request\\(\\) expects string\\|null, string\\|false given\\.$#" count: 8 @@ -1673,7 +1668,7 @@ parameters: path: Command/InvoiceCreateCommandTest.php - - message: "#^Parameter \\#6 \\$eventDispatcher of class App\\\\Command\\\\InvoiceCreateCommand constructor expects Symfony\\\\Contracts\\\\EventDispatcher\\\\EventDispatcherInterface, object\\|null given\\.$#" + message: "#^Parameter \\#6 \\$eventDispatcher of class App\\\\Command\\\\InvoiceCreateCommand constructor expects Psr\\\\EventDispatcher\\\\EventDispatcherInterface, object\\|null given\\.$#" count: 1 path: Command/InvoiceCreateCommandTest.php @@ -5177,26 +5172,6 @@ parameters: count: 1 path: Export/Renderer/PdfRendererFactoryTest.php - - - message: "#^Cannot call method push\\(\\) on object\\|null\\.$#" - count: 1 - path: Export/Renderer/PdfRendererTest.php - - - - message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\PdfRendererTest\\:\\:testConfiguration\\(\\) has no return type specified\\.$#" - count: 1 - path: Export/Renderer/PdfRendererTest.php - - - - message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\PdfRendererTest\\:\\:testRender\\(\\) has no return type specified\\.$#" - count: 1 - path: Export/Renderer/PdfRendererTest.php - - - - message: "#^Parameter \\#2 \\$cacheDirectory of class App\\\\Pdf\\\\MPdfConverter constructor expects string, array\\|bool\\|float\\|int\\|string\\|null given\\.$#" - count: 1 - path: Export/Renderer/PdfRendererTest.php - - message: "#^Call to an undefined method App\\\\Export\\\\ExportRendererInterface\\|App\\\\Export\\\\TimesheetExportInterface\\:\\:getIcon\\(\\)\\.$#" count: 1 @@ -5632,46 +5607,11 @@ parameters: count: 1 path: Export/Timesheet/CsvRendererTest.php - - - message: "#^Cannot call method push\\(\\) on object\\|null\\.$#" - count: 1 - path: Export/Timesheet/HtmlRendererTest.php - - - - message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\HtmlRendererTest\\:\\:testConfiguration\\(\\) has no return type specified\\.$#" - count: 1 - path: Export/Timesheet/HtmlRendererTest.php - - - - message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\HtmlRendererTest\\:\\:testRender\\(\\) has no return type specified\\.$#" - count: 1 - path: Export/Timesheet/HtmlRendererTest.php - - message: "#^Parameter \\#2 \\$haystack of method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#" count: 1 path: Export/Timesheet/HtmlRendererTest.php - - - message: "#^Cannot call method push\\(\\) on object\\|null\\.$#" - count: 1 - path: Export/Timesheet/PdfRendererTest.php - - - - message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\PdfRendererTest\\:\\:testConfiguration\\(\\) has no return type specified\\.$#" - count: 1 - path: Export/Timesheet/PdfRendererTest.php - - - - message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\PdfRendererTest\\:\\:testRender\\(\\) has no return type specified\\.$#" - count: 1 - path: Export/Timesheet/PdfRendererTest.php - - - - message: "#^Parameter \\#2 \\$cacheDirectory of class App\\\\Pdf\\\\MPdfConverter constructor expects string, array\\|bool\\|float\\|int\\|string\\|null given\\.$#" - count: 1 - path: Export/Timesheet/PdfRendererTest.php - - message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\XlsxRendererTest\\:\\:testConfiguration\\(\\) has no return type specified\\.$#" count: 1 @@ -6457,51 +6397,21 @@ parameters: count: 1 path: Invoice/Renderer/PdfRendererTest.php - - - message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\PdfRendererTest\\:\\:testRender\\(\\) has no return type specified\\.$#" - count: 1 - path: Invoice/Renderer/PdfRendererTest.php - - - - message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\PdfRendererTest\\:\\:testSupports\\(\\) has no return type specified\\.$#" - count: 1 - path: Invoice/Renderer/PdfRendererTest.php - - message: "#^Parameter \\#2 \\$cacheDirectory of class App\\\\Pdf\\\\MPdfConverter constructor expects string, array\\|bool\\|float\\|int\\|string\\|null given\\.$#" count: 1 path: Invoice/Renderer/PdfRendererTest.php - message: "#^Parameter \\#2 \\$haystack of method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|null given\\.$#" - count: 2 + count: 3 path: Invoice/Renderer/PdfRendererTest.php - - - message: "#^Cannot call method push\\(\\) on object\\|null\\.$#" - count: 1 - path: Invoice/Renderer/TwigRendererTest.php - - - - message: "#^Cannot call method setLanguage\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#" - count: 1 - path: Invoice/Renderer/TwigRendererTest.php - - message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\TwigRendererTest\\:\\:getAbstractRenderer\\(\\) should return App\\\\Invoice\\\\Renderer\\\\AbstractRenderer but returns object\\.$#" count: 1 path: Invoice/Renderer/TwigRendererTest.php - - - message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\TwigRendererTest\\:\\:testRender\\(\\) has no return type specified\\.$#" - count: 1 - path: Invoice/Renderer/TwigRendererTest.php - - - - message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\TwigRendererTest\\:\\:testSupports\\(\\) has no return type specified\\.$#" - count: 1 - path: Invoice/Renderer/TwigRendererTest.php - - message: "#^Parameter \\#1 \\$haystack of function substr_count expects string, string\\|false given\\.$#" count: 1
translations/messages.ar.xlf+0 −8 modified@@ -756,10 +756,6 @@ <source>searchTerm</source> <target state="translated">مصطلح البحث</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>استبدل العلامات</target> - </trans-unit> <trans-unit id="dChNncv" resname="color"> <source>color</source> <target>اللون</target> @@ -804,10 +800,6 @@ <source>set_as_default</source> <target>حفظ الإعداد ات كمفضلة البحث</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>إلحاق كلمات مفتاحية</target> - </trans-unit> <trans-unit id="xkugSAA" resname="stats.durationFinancialYear"> <source>stats.durationFinancialYear</source> <target>ساعات العمل هذه السنة المالية</target>
translations/messages.cs.xlf+6 −6 modified@@ -886,13 +886,13 @@ <source>profile.registration_date</source> <target>Registrován</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Připojit štítky</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Připojit</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Nahradit štítky</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Nahradit</target> </trans-unit> <trans-unit id="b43sCwO" resname="help.fixedRate"> <source>help.fixedRate</source>
translations/messages.da.xlf+6 −6 modified@@ -349,13 +349,13 @@ <source>color</source> <target>Farve</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Erstat etiketter</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Erstat</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Tilføj etiketter</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Tilføj</target> </trans-unit> <!-- User profile
translations/messages.de_CH.xlf+6 −6 modified@@ -573,13 +573,13 @@ <source>profile.title</source> <target>Benutzerprofil</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Schlagworte hinzufügen</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Hinzufügen</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Schlagworte ersetzen</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Ersetzen</target> </trans-unit> <trans-unit id="dChNncv" resname="color"> <source>color</source>
translations/messages.de.xlf+14 −6 modified@@ -523,13 +523,13 @@ <source>color</source> <target>Farbe</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Schlagworte ersetzen</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Ersetzen</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Schlagworte hinzufügen</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Hinzufügen</target> </trans-unit> <!-- Column modal --> <trans-unit id="UbnqJLn" resname="modal.columns.title"> @@ -1625,6 +1625,14 @@ <source>supervisor</source> <target>Vorgesetzter</target> </trans-unit> + <trans-unit id=".wmq_rT" resname="force_password_change"> + <source>force_password_change</source> + <target>Neues Passwort anfordern</target> + </trans-unit> + <trans-unit id="0bKwA0k" resname="force_password_change_help"> + <source>force_password_change_help</source> + <target>Bei der nächsten Anmeldung muss der Benutzer ein neues Passwort hinterlegen</target> + </trans-unit> </body> </file> </xliff>
translations/messages.el.xlf+6 −6 modified@@ -441,13 +441,13 @@ <source>color</source> <target>Χρώμα</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Αντικατάσταση ετικετών</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Αντικατάσταση</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Προσάρτηση ετικετών</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Προσάρτηση</target> </trans-unit> <!-- User profile
translations/messages.en.xlf+14 −6 modified@@ -523,13 +523,13 @@ <source>color</source> <target>Color</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Replace tags</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Replace</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Append tags</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Append</target> </trans-unit> <!-- Column modal --> <trans-unit id="UbnqJLn" resname="modal.columns.title"> @@ -1625,6 +1625,14 @@ <source>supervisor</source> <target>Supervisor</target> </trans-unit> + <trans-unit id=".wmq_rT" resname="force_password_change"> + <source>force_password_change</source> + <target>Request new password</target> + </trans-unit> + <trans-unit id="0bKwA0k" resname="force_password_change_help"> + <source>force_password_change_help</source> + <target>The next time the user logs in, they must enter a new password.</target> + </trans-unit> </body> </file> </xliff>
translations/messages.eo.xlf+6 −6 modified@@ -373,13 +373,13 @@ <source>color</source> <target>Koloro</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Anstataŭigi etikedojn</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Anstataŭigi</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Postglui etikedojn</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Postglui</target> </trans-unit> <!-- User profile
translations/messages.es.xlf+3 −7 modified@@ -910,9 +910,9 @@ <source>status</source> <target>Situación</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Reemplazar etiquetas</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Reemplazar</target> </trans-unit> <trans-unit id="Qc5nsFn" resname="recalculate_rates"> <source>recalculate_rates</source> @@ -958,10 +958,6 @@ <source>profile.registration_date</source> <target>Registrado el</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Agregar etiquetas</target> - </trans-unit> <trans-unit id="b43sCwO" resname="help.fixedRate"> <source>help.fixedRate</source> <target>Cada registro de tiempo obtiene el mismo valor, independientemente de su duración</target>
translations/messages.eu.xlf+6 −6 modified@@ -937,13 +937,13 @@ <source>profile.registration_date</source> <target>Hemen erregistratuta</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Gehitu etiketak</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Gehitu</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Ordeztu etiketak</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Ordeztu</target> </trans-unit> <trans-unit id="aVwNQcX" resname="billable"> <source>billable</source>
translations/messages.fa.xlf+0 −8 modified@@ -419,14 +419,6 @@ <source>help.fixedRate</source> <target>هربار سابقه مقدار معینی میگیرد، صرف نظر از مدت آن</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>جایگزینی برچسب ها</target> - </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>پیوست تگ ها</target> - </trans-unit> <trans-unit id="1_wnK76" resname="profile.title"> <source>profile.title</source> <target>نمایه کاربر</target>
translations/messages.fi.xlf+6 −6 modified@@ -405,13 +405,13 @@ <source>color</source> <target state="translated">Väri</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target state="translated">Vaihda tunnisteet</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target state="translated">Vaihda</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target state="translated">Lisää tunnisteita</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target state="translated">Lisää</target> </trans-unit> <!-- User profile
translations/messages.fo.xlf+6 −6 modified@@ -759,13 +759,13 @@ <source>profile.title</source> <target>Brúkara vangi</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Legg aftrat frámerki</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Legg aftrat</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Útskift frámerki</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Útskift</target> </trans-unit> <trans-unit id="VXj0Y3h" resname="timesheet.title"> <source>timesheet.title</source>
translations/messages.fr.xlf+6 −6 modified@@ -766,13 +766,13 @@ <source>globalsOnly</source> <target>Uniquement global</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Ajouter des mots-clés</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Ajouter</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Remplacer les mots-clés</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Remplacer</target> </trans-unit> <trans-unit id="b43sCwO" resname="help.fixedRate"> <source>help.fixedRate</source>
translations/messages.he.xlf+0 −8 modified@@ -366,14 +366,6 @@ <source>color</source> <target>צבע</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>החלפת תגיות</target> - </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>הוספת תגיות</target> - </trans-unit> <trans-unit id="1_wnK76" resname="profile.title"> <source>profile.title</source> <target>פרופיל משתמש</target>
translations/messages.hr.xlf+6 −6 modified@@ -453,13 +453,13 @@ <source>color</source> <target>Boja</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Zamijeni oznake</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Zamijeni</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Dodaj oznake</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Dodaj</target> </trans-unit> <!-- User profile
translations/messages.hu.xlf+0 −8 modified@@ -966,14 +966,6 @@ <source>profile.registration_date</source> <target>Regisztrált ekkor</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Címkék megnyitása</target> - </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Címkék cseréje</target> - </trans-unit> <trans-unit id="b43sCwO" resname="help.fixedRate"> <source>help.fixedRate</source> <target>Minden alkalommal a felvétel ugyanazt az értéket kapja, függetlenül a terjedelmétől</target>
translations/messages.it.xlf+6 −6 modified@@ -405,13 +405,13 @@ <source>color</source> <target>Colore</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Sostituisci etichette</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Sostituisci</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Aggiungi etichette</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Aggiungi</target> </trans-unit> <!-- User profile
translations/messages.ko.xlf+0 −8 modified@@ -850,14 +850,6 @@ <source>lastLogin</source> <target>지난 로그인</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>태그 추가</target> - </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>태그 교체</target> - </trans-unit> <trans-unit id="1c0EQaz" resname="profile.registration_date"> <source>profile.registration_date</source> <target>에 등록</target>
translations/messages.nb_NO.xlf+0 −8 modified@@ -234,10 +234,6 @@ <source>color</source> <target>Farge</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Erstatt etiketter</target> - </trans-unit> <!-- User profile --> @@ -624,10 +620,6 @@ <source>fixedRate</source> <target>Fast sats</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Legg til etiketter</target> - </trans-unit> <trans-unit id="j4Kd9N1" resname="calendar_initial_view"> <source>calendar_initial_view</source> <target>Startvisning for kalender</target>
translations/messages.nl.xlf+0 −8 modified@@ -942,14 +942,6 @@ <source>lastLogin</source> <target>Laatste login</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Voeg labels toe</target> - </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Vervang labels</target> - </trans-unit> <trans-unit id="b43sCwO" resname="help.fixedRate"> <source>help.fixedRate</source> <target state="translated">Elke tijdsinvoer krijgt dezelfde prijs, ongeacht de duur ervan</target>
translations/messages.pl.xlf+6 −6 modified@@ -361,13 +361,13 @@ <source>color</source> <target>Kolor</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Zamień tagi</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Zamień</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Dodaj tagi</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Dodaj</target> </trans-unit> <!-- User profile
translations/messages.pt_BR.xlf+0 −8 modified@@ -806,10 +806,6 @@ <source>update_multiple</source> <target>%action% %count% entradas?</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Tags de apêndice</target> - </trans-unit> <trans-unit id="E7G60OF" resname="Allowed character: A-Z and _"> <source>Allowed character: A-Z and _</source> <target>Caractere permitido: A-Z e _</target> @@ -930,10 +926,6 @@ <source>profile.registration_date</source> <target>Registrado em</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Substituir tags</target> - </trans-unit> <trans-unit id="tfkKlua" resname="progress"> <source>progress</source> <target>Progresso</target>
translations/messages.pt.xlf+6 −6 modified@@ -862,13 +862,13 @@ <source>lastLogin</source> <target>Última sessão</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Anexar etiquetas</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Anexar</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Substituir etiquetas</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Substituir</target> </trans-unit> <trans-unit id="b43sCwO" resname="help.fixedRate"> <source>help.fixedRate</source>
translations/messages.ro.xlf+0 −8 modified@@ -346,14 +346,6 @@ <source>color</source> <target>Culoare</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Înlocuiește etichetele</target> - </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Adaugă etichete</target> - </trans-unit> <trans-unit id="1_wnK76" resname="profile.title"> <source>profile.title</source> <target>Profilul utilizatorului</target>
translations/messages.ru.xlf+6 −6 modified@@ -712,13 +712,13 @@ <source>profile.registration_date</source> <target state="translated">Зарегистрирован(-а)</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target state="translated">Добавить метки</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target state="translated">Добавить</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target state="translated">Заменить метки</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target state="translated">Заменить</target> </trans-unit> <trans-unit id="dChNncv" resname="color"> <source>color</source>
translations/messages.sk.xlf+6 −6 modified@@ -357,13 +357,13 @@ <source>color</source> <target>Farba</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Zmeniť značky</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Zmeniť</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Pridať značky</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Pridať</target> </trans-unit> <!-- User profile
translations/messages.sv.xlf+6 −6 modified@@ -938,13 +938,13 @@ <source>export_decimal</source> <target>Använd decimallängd vid export</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Fäst taggar</target> + <trans-unit id="ICpl9sx" resname="append"> + <source>append</source> + <target>Fäst</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Ersätt taggar</target> + <trans-unit id="tl01EdS" resname="replace"> + <source>replace</source> + <target>Ersätt</target> </trans-unit> <trans-unit id="dChNncv" resname="color"> <source>color</source>
translations/messages.tr.xlf+0 −8 modified@@ -906,14 +906,6 @@ <source>profile.registration_date</source> <target>Kayıt tarihi</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Etiketleri ekle</target> - </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Etiketleri değiştir</target> - </trans-unit> <trans-unit id="b43sCwO" resname="help.fixedRate"> <source>help.fixedRate</source> <target state="translated">Süresi ne olursa olsun her kayıt aynı fiyatı alır</target>
translations/messages.uk.xlf+0 −8 modified@@ -342,14 +342,6 @@ <source>color</source> <target state="translated">Колір</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target state="translated">Заміна міток</target> - </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target state="translated">Додати мітки</target> - </trans-unit> <trans-unit id="1_wnK76" resname="profile.title"> <source>profile.title</source> <target state="translated">Профіль користувача</target>
translations/messages.vi.xlf+0 −8 modified@@ -389,14 +389,6 @@ <source>color</source> <target>Màu sắc</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>Thay thế thẻ</target> - </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>Nối các thẻ</target> - </trans-unit> <!-- User profile -->
translations/messages.zh_CN.xlf+0 −8 modified@@ -349,14 +349,6 @@ <source>color</source> <target>颜色</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target>替换标签</target> - </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target>附加标签</target> - </trans-unit> <!-- User profile -->
translations/messages.zh_Hant.xlf+0 −8 modified@@ -697,10 +697,6 @@ <source>profile.roles</source> <target state="translated">角色</target> </trans-unit> - <trans-unit id="tl01EdS" resname="replaceTags"> - <source>replaceTags</source> - <target state="translated">替換 tags</target> - </trans-unit> <trans-unit id="HWKJZ0T" resname="profile.about_me"> <source>profile.about_me</source> <target state="translated">關於我</target> @@ -721,10 +717,6 @@ <source>color</source> <target state="translated">色彩</target> </trans-unit> - <trans-unit id="ICpl9sx" resname="appendTags"> - <source>appendTags</source> - <target state="translated">添加 tags</target> - </trans-unit> <trans-unit id="1_wnK76" resname="profile.title"> <source>profile.title</source> <target state="translated">使用者個人資料</target>
translations/wizard.de.xlf+8 −0 modified@@ -22,6 +22,14 @@ <source>wizard.profile.description</source> <target>Einige Grundeinstellungen um Ihnen die Arbeit mit Kimai zu erleichtern.</target> </trans-unit> + <trans-unit id="7Vipfs7" resname="wizard.password.title"> + <source>wizard.password.title</source> + <target>Ihr Passwort</target> + </trans-unit> + <trans-unit id="zlNbbUg" resname="wizard.password.description"> + <source>wizard.password.description</source> + <target>Bitte geben Sie ihr neues Passwort ein, das von nun an für die Anmeldung gelten soll.</target> + </trans-unit> <trans-unit id="8p.2pXa" resname="wizard.done.title"> <source>wizard.done.title</source> <target>Herzlichen Glückwunsch</target>
translations/wizard.en.xlf+8 −0 modified@@ -22,6 +22,14 @@ <source>wizard.profile.description</source> <target>Some basic settings to make your work with Kimai easier.</target> </trans-unit> + <trans-unit id="7Vipfs7" resname="wizard.password.title"> + <source>wizard.password.title</source> + <target>Your password</target> + </trans-unit> + <trans-unit id="zlNbbUg" resname="wizard.password.description"> + <source>wizard.password.description</source> + <target>Please enter your new password, which will be used for logging in from now on.</target> + </trans-unit> <trans-unit id="8p.2pXa" resname="wizard.done.title"> <source>wizard.done.title</source> <target>Congratulations</target>
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-fjhg-96cp-6fcwghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-46245ghsaADVISORY
- github.com/kimai/kimai/commit/38e37f1c2e91e1acb221ec5c13f11b735bd50ae4ghsax_refsource_MISCWEB
- github.com/kimai/kimai/security/advisories/GHSA-fjhg-96cp-6fcwghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.