VYPR
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.

PackageAffected versionsPatched versions
kimai/kimaiPackagist
< 2.1.02.1.0

Affected products

1

Patches

1
38e37f1c2e91

Release 2.1.0 (#4321)

https://github.com/kimai/kimaiKevin PapstOct 19, 2023via ghsa
210 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>&nbsp;</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 %}&nbsp;{% 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 %}&nbsp;{% 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

News mentions

0

No linked articles in our index yet.