Compare commits

...

10 Commits

Author SHA1 Message Date
root
553c87d7ea Initial Kimai 2.61.0 setup with Kimai 1 migration
Some checks failed
Frontend / Frontend verification (push) Has been cancelled
Lint PHP / Linting (8.2) (push) Has been cancelled
Check .lock files / Verify lock file integrity (push) Has been cancelled
Release Drafter / Verify repository (push) Has been cancelled
Release Drafter / Draft next release (push) Has been cancelled
Tests / Integration (8.2) (push) Has been cancelled
Tests / Integration (8.3) (push) Has been cancelled
Tests / Integration (8.4) (push) Has been cancelled
Tests / Integration (8.5) (push) Has been cancelled
Actions Security Analysis / Scan workflows (push) Has been cancelled
- PHP 8.3, Symfony 6.4, MariaDB 10.11
- Migrated from Kimai 1.x: 908K timesheets, 2,154 users, 427 projects
- Custom migration script at migrate.php
- All users login with: changeme
2026-06-18 21:54:38 +00:00
Kevin Papst
0bedb7a877 added missing response object in OpenAPI description 2026-06-17 22:15:41 +02:00
Kevin Papst
1f905c5c74 Update mysql Docker digest to e370cd5 (#5997) 2026-06-17 10:21:33 +02:00
Kevin Papst
2750bf6b86 Update release-drafter/release-drafter action to v7.4.0 (#5998) 2026-06-17 10:21:16 +02:00
Kevin Papst
8bf711c417 Some random tests and fixes (#6000)
* added tests for form models
* apply form theme only to the own custom form type
* change route order for improves matching
2026-06-17 10:20:50 +02:00
Kevin Papst
f8bc853d2f Release 2.61 (#5990) 2026-06-17 07:27:57 +02:00
Kevin Papst
7f716fa2d6 Update pnpm/action-setup action to v6.0.9 (#5994) 2026-06-16 22:06:25 +02:00
Kevin Papst
23f5628cc9 Update mysql Docker digest to c11782a (#5978) 2026-06-10 17:44:56 +02:00
Kevin Papst
dd55a3be06 Update shivammathur/setup-php action to v2.37.2 (#5984) 2026-06-10 13:32:57 +02:00
Kevin Papst
e66ecc6b8b Update renovatebot/github-action action to v46.1.15 (#5982) 2026-06-10 13:32:16 +02:00
28 changed files with 1361 additions and 176 deletions

View File

@@ -22,7 +22,7 @@ jobs:
persist-credentials: false
- name: Setup PNPM
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
run_install: false

View File

@@ -29,7 +29,7 @@ jobs:
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: ${{ matrix.php }}
coverage: pcov

View File

@@ -29,6 +29,6 @@ jobs:
needs: correct_repository
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
- uses: release-drafter/release-drafter@ed4bc48ec97379be2258e7b7ac2624a3e26ab809 # v7.4.0
env:
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Run Renovate
uses: renovatebot/github-action@693b9ef15eec82123529a37c782242f091365961 # v46.1.14
uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15
with:
token: ${{ secrets.RENOVATE_TOKEN }}
env:

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
services:
mysql:
image: mysql@sha256:c36050afdca850f23cef85703f84c7531a5ae155a11b5ee1c60acb09937c4084 # 8.4.9
image: mysql@sha256:e370cd5f64599d46985b7729b452f2153825246f88d82753ec595c5dfc6fef6a # 8.4.9
env:
MYSQL_ALLOW_EMPTY_PASSWORD: false
MYSQL_ROOT_PASSWORD: kimai
@@ -39,7 +39,7 @@ jobs:
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: ${{ matrix.php }}
coverage: pcov

View File

@@ -98,12 +98,18 @@
"symfony/css-selector": "^6.0",
"symfony/debug-bundle": "^6.0",
"symfony/dom-crawler": "^6.0",
"symfony/maker-bundle": "^1.67",
"symfony/phpunit-bridge": "^6.0",
"symfony/stopwatch": "^6.0",
"symfony/var-dumper": "^6.0",
"symfony/web-profiler-bundle": "^6.0"
},
"repositories": [
{
"name": "import-plugin",
"type": "vcs",
"url": "https://github.com/kevinpapst/ImportBundle.git"
},
{
"type": "artifact",
"url": "var/packages/"

225
composer.lock generated
View File

@@ -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": "3a36f355f322598267719beb6a7c22e0",
"content-hash": "ccb50983df5715995863ee9aa6d53de2",
"packages": [
{
"name": "azuyalabs/yasumi",
@@ -135,28 +135,29 @@
},
{
"name": "composer/pcre",
"version": "3.3.2",
"version": "3.4.0",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
"phpstan/phpstan": "<2.2.2"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^9"
},
"type": "library",
"extra": {
@@ -194,7 +195,7 @@
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
"source": "https://github.com/composer/pcre/tree/3.4.0"
},
"funding": [
{
@@ -204,13 +205,9 @@
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
"time": "2026-06-07T11:47:49+00:00"
},
{
"name": "composer/semver",
@@ -774,16 +771,16 @@
},
{
"name": "doctrine/doctrine-bundle",
"version": "2.18.2",
"version": "2.18.3",
"source": {
"type": "git",
"url": "https://github.com/doctrine/DoctrineBundle.git",
"reference": "0ff098b29b8b3c68307c8987dcaed7fd829c6546"
"reference": "241d61f6bbc77275d5a9f95d514241c058bf2d0a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/0ff098b29b8b3c68307c8987dcaed7fd829c6546",
"reference": "0ff098b29b8b3c68307c8987dcaed7fd829c6546",
"url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/241d61f6bbc77275d5a9f95d514241c058bf2d0a",
"reference": "241d61f6bbc77275d5a9f95d514241c058bf2d0a",
"shasum": ""
},
"require": {
@@ -820,6 +817,7 @@
"psr/log": "^1.1.4 || ^2.0 || ^3.0",
"symfony/doctrine-messenger": "^6.4 || ^7.0",
"symfony/expression-language": "^6.4 || ^7.0",
"symfony/http-kernel": "^6.4 || ^7.0",
"symfony/messenger": "^6.4 || ^7.0",
"symfony/property-info": "^6.4 || ^7.0",
"symfony/security-bundle": "^6.4 || ^7.0",
@@ -875,7 +873,7 @@
],
"support": {
"issues": "https://github.com/doctrine/DoctrineBundle/issues",
"source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.2"
"source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.3"
},
"funding": [
{
@@ -891,7 +889,7 @@
"type": "tidelift"
}
],
"time": "2025-12-20T21:35:32+00:00"
"time": "2026-06-08T08:22:50+00:00"
},
{
"name": "doctrine/doctrine-migrations-bundle",
@@ -5187,16 +5185,16 @@
},
{
"name": "setasign/fpdi",
"version": "v2.6.7",
"version": "v2.6.8",
"source": {
"type": "git",
"url": "https://github.com/Setasign/FPDI.git",
"reference": "388c51e69982a3fc16698710b763e8107a49f510"
"reference": "881945be29a4996ad3d008eb18ddc01fa3df890c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Setasign/FPDI/zipball/388c51e69982a3fc16698710b763e8107a49f510",
"reference": "388c51e69982a3fc16698710b763e8107a49f510",
"url": "https://api.github.com/repos/Setasign/FPDI/zipball/881945be29a4996ad3d008eb18ddc01fa3df890c",
"reference": "881945be29a4996ad3d008eb18ddc01fa3df890c",
"shasum": ""
},
"require": {
@@ -5208,7 +5206,7 @@
},
"require-dev": {
"phpunit/phpunit": "^8.5.52",
"setasign/fpdf": "~1.8.6",
"setasign/fpdf": "^1.9.0",
"setasign/tfpdf": "~1.33",
"squizlabs/php_codesniffer": "^3.5",
"tecnickcom/tcpdf": "^6.8"
@@ -5247,7 +5245,7 @@
],
"support": {
"issues": "https://github.com/Setasign/FPDI/issues",
"source": "https://github.com/Setasign/FPDI/tree/v2.6.7"
"source": "https://github.com/Setasign/FPDI/tree/v2.6.8"
},
"funding": [
{
@@ -5255,7 +5253,7 @@
"type": "tidelift"
}
],
"time": "2026-05-13T10:16:22+00:00"
"time": "2026-06-11T10:37:24+00:00"
},
{
"name": "smalot/pdfparser",
@@ -8028,16 +8026,16 @@
},
{
"name": "symfony/polyfill-php83",
"version": "v1.38.1",
"version": "v1.38.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
"reference": "8339098cae28673c15cce00d80734af0453054e2"
"reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2",
"reference": "8339098cae28673c15cce00d80734af0453054e2",
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8",
"reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8",
"shasum": ""
},
"require": {
@@ -8084,7 +8082,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1"
"source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2"
},
"funding": [
{
@@ -8104,7 +8102,7 @@
"type": "tidelift"
}
],
"time": "2026-05-26T12:51:13+00:00"
"time": "2026-05-27T06:51:48+00:00"
},
{
"name": "symfony/polyfill-php84",
@@ -10749,16 +10747,16 @@
},
{
"name": "webmozart/assert",
"version": "2.4.0",
"version": "2.4.1",
"source": {
"type": "git",
"url": "https://github.com/webmozarts/assert.git",
"reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155"
"reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155",
"reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155",
"url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70",
"reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70",
"shasum": ""
},
"require": {
@@ -10809,9 +10807,9 @@
],
"support": {
"issues": "https://github.com/webmozarts/assert/issues",
"source": "https://github.com/webmozarts/assert/tree/2.4.0"
"source": "https://github.com/webmozarts/assert/tree/2.4.1"
},
"time": "2026-05-20T13:07:01+00:00"
"time": "2026-06-15T15:31:57+00:00"
},
{
"name": "willdurand/jsonp-callback-validator",
@@ -11617,16 +11615,16 @@
},
{
"name": "friendsofphp/php-cs-fixer",
"version": "v3.95.4",
"version": "v3.95.8",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "3f8f68856837a77e1f1d870354eca3c8747f2f72"
"reference": "4140023f552ff02346df9b1329742532166f677f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3f8f68856837a77e1f1d870354eca3c8747f2f72",
"reference": "3f8f68856837a77e1f1d870354eca3c8747f2f72",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/4140023f552ff02346df9b1329742532166f677f",
"reference": "4140023f552ff02346df9b1329742532166f677f",
"shasum": ""
},
"require": {
@@ -11644,7 +11642,7 @@
"react/event-loop": "^1.5",
"react/socket": "^1.16",
"react/stream": "^1.4",
"sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0",
"sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0",
"symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0",
"symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
"symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
@@ -11660,16 +11658,16 @@
"require-dev": {
"facile-it/paraunit": "^1.3.1 || ^2.11.0",
"infection/infection": "^0.32.7",
"justinrainbow/json-schema": "^6.8.0",
"justinrainbow/json-schema": "^6.9.0",
"keradus/cli-executor": "^2.3",
"mikey179/vfsstream": "^1.6.12",
"php-coveralls/php-coveralls": "^2.9.1",
"php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8",
"php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8",
"phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55",
"symfony/polyfill-php85": "^1.37",
"symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8",
"symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.11"
"symfony/polyfill-php85": "^1.38",
"symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0",
"symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0"
},
"suggest": {
"ext-dom": "For handling output formats in XML",
@@ -11710,7 +11708,7 @@
],
"support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.4"
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.8"
},
"funding": [
{
@@ -11718,7 +11716,7 @@
"type": "github"
}
],
"time": "2026-06-03T18:02:44+00:00"
"time": "2026-06-16T09:52:26+00:00"
},
{
"name": "masterminds/html5",
@@ -12021,21 +12019,21 @@
},
{
"name": "phpstan/phpstan-doctrine",
"version": "2.0.25",
"version": "2.0.27",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-doctrine.git",
"reference": "e20e8bf3223ae6eba9c4b5987c391d922e094b3c"
"reference": "39b4ca45a07cdd6366eeefa2f7a993cddf3b9f9f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/e20e8bf3223ae6eba9c4b5987c391d922e094b3c",
"reference": "e20e8bf3223ae6eba9c4b5987c391d922e094b3c",
"url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/39b4ca45a07cdd6366eeefa2f7a993cddf3b9f9f",
"reference": "39b4ca45a07cdd6366eeefa2f7a993cddf3b9f9f",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.1.34"
"phpstan/phpstan": "^2.2.2"
},
"conflict": {
"doctrine/collections": "<1.0",
@@ -12092,9 +12090,9 @@
],
"support": {
"issues": "https://github.com/phpstan/phpstan-doctrine/issues",
"source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.25"
"source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.27"
},
"time": "2026-06-02T20:27:36+00:00"
"time": "2026-06-10T10:39:35+00:00"
},
{
"name": "phpstan/phpstan-phpunit",
@@ -12205,16 +12203,16 @@
},
{
"name": "phpstan/phpstan-symfony",
"version": "2.0.19",
"version": "2.0.20",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-symfony.git",
"reference": "546071ed7f80a89ec30909346eb7cc741800740a"
"reference": "53f1a6462dbe71fad36ce054caf5e1b725b740fd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/546071ed7f80a89ec30909346eb7cc741800740a",
"reference": "546071ed7f80a89ec30909346eb7cc741800740a",
"url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/53f1a6462dbe71fad36ce054caf5e1b725b740fd",
"reference": "53f1a6462dbe71fad36ce054caf5e1b725b740fd",
"shasum": ""
},
"require": {
@@ -12273,9 +12271,9 @@
],
"support": {
"issues": "https://github.com/phpstan/phpstan-symfony/issues",
"source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.19"
"source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.20"
},
"time": "2026-05-29T12:52:44+00:00"
"time": "2026-06-16T09:17:35+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -14407,6 +14405,105 @@
],
"time": "2026-05-19T20:33:22+00:00"
},
{
"name": "symfony/maker-bundle",
"version": "v1.67.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/maker-bundle.git",
"reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/maker-bundle/zipball/6ce8b313845f16bcf385ee3cb31d8b24e30d5516",
"reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.1",
"doctrine/inflector": "^2.0",
"nikic/php-parser": "^5.0",
"php": ">=8.1",
"symfony/config": "^6.4|^7.0|^8.0",
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/deprecation-contracts": "^2.2|^3",
"symfony/filesystem": "^6.4|^7.0|^8.0",
"symfony/finder": "^6.4|^7.0|^8.0",
"symfony/framework-bundle": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/process": "^6.4|^7.0|^8.0"
},
"conflict": {
"doctrine/doctrine-bundle": "<2.10",
"doctrine/orm": "<2.15"
},
"require-dev": {
"composer/semver": "^3.0",
"doctrine/doctrine-bundle": "^2.10|^3.0",
"doctrine/orm": "^2.15|^3",
"doctrine/persistence": "^3.1|^4.0",
"symfony/http-client": "^6.4|^7.0|^8.0",
"symfony/phpunit-bridge": "^6.4.1|^7.0|^8.0",
"symfony/security-core": "^6.4|^7.0|^8.0",
"symfony/security-http": "^6.4|^7.0|^8.0",
"symfony/yaml": "^6.4|^7.0|^8.0",
"twig/twig": "^3.0|^4.x-dev"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Bundle\\MakerBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony Maker helps you create empty commands, controllers, form classes, tests and more so you can forget about writing boilerplate code.",
"homepage": "https://symfony.com/doc/current/bundles/SymfonyMakerBundle/index.html",
"keywords": [
"code generator",
"dev",
"generator",
"scaffold",
"scaffolding"
],
"support": {
"issues": "https://github.com/symfony/maker-bundle/issues",
"source": "https://github.com/symfony/maker-bundle/tree/v1.67.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-18T13:39:06+00:00"
},
{
"name": "symfony/phpunit-bridge",
"version": "v6.4.35",

View File

@@ -19,4 +19,5 @@ return [
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
KevinPapst\TablerBundle\TablerBundle::class => ['all' => true],
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
];

View File

@@ -74,6 +74,7 @@ security:
login_link:
check_route: link_login_check
lifetime: 900
login_path: login
max_uses: 3
# 'password' binds the HMAC signature to the password hash, so a
# link becomes invalid as soon as the user changes the password
@@ -93,6 +94,8 @@ security:
access_control:
- {path: '^/auth/2fa', role: IS_AUTHENTICATED_2FA_IN_PROGRESS}
- {path: '^/auth', roles: PUBLIC_ACCESS}
- {path: '^/login', roles: PUBLIC_ACCESS}
- {path: '^/logout', roles: PUBLIC_ACCESS}
- {path: '^/{_locale}$', role: PUBLIC_ACCESS}
- {path: '^/{_locale}/auth', role: PUBLIC_ACCESS}
- {path: '^/{_locale}/login', role: PUBLIC_ACCESS}

View File

@@ -1,3 +1,28 @@
home:
path: /
defaults:
_controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction
route: homepage
permanent: true
# SF DefaultAuthenticationSuccessHandler uses /login as default target
loginFallback:
path: /login
defaults:
_controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction
_locale: '%locale%'
route: login
permanent: true
# SF LogoutListener uses /logout as default target
logoutFallback:
path: /logout
defaults:
_controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction
_locale: '%locale%'
route: logout
permanent: true
controllers:
resource: ../src/Controller/
type: attribute
@@ -31,13 +56,6 @@ kernel:
resource: ../src/Kernel.php
type: attribute
home:
path: /
defaults:
_controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction
route: homepage
permanent: true
homeLocale:
path: /{_locale}
defaults:

656
migrate.php Normal file
View File

@@ -0,0 +1,656 @@
#!/usr/bin/env php
<?php
/**
* Kimai v1 → Kimai v2 Migration Script
*
* Reads from `kimai` database (prefix: kimai15_)
* Writes to `kimai2` database (prefix: kimai2_)
*
* Usage: php8.3 /root/workspace/kimai2/migrate.php
*/
declare(strict_types=1);
// ── Config ──────────────────────────────────────────────────────────────────
const SOURCE_DSN = 'mysql:host=localhost;dbname=kimai;charset=utf8mb4';
const TARGET_DSN = 'mysql:host=localhost;dbname=kimai2;charset=utf8mb4';
const DB_USER = 'root';
const DB_PASS = '';
const DEFAULT_PASSWORD = 'changeme';
const DEFAULT_TIMEZONE = 'UTC';
const DEFAULT_CURRENCY = 'EUR';
const DEFAULT_COUNTRY = '';
const BATCH_SIZE = 2000; // rows per INSERT batch
// ── Bootstrap ───────────────────────────────────────────────────────────────
$src = new PDO(SOURCE_DSN, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$dst = new PDO(TARGET_DSN, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
// Disable foreign key checks and autocommit for speed
$dst->exec('SET FOREIGN_KEY_CHECKS = 0');
$dst->exec('SET AUTOCOMMIT = 0');
// ── Helpers ─────────────────────────────────────────────────────────────────
function progress(string $label, int $done, int $total): void {
$pct = $total > 0 ? round($done / $total * 100, 1) : 100.0;
printf("\r %s: %d / %d (%s%%)", str_pad($label, 20), $done, $total, $pct);
}
function done(string $label, int $count, float $elapsed): void {
printf("\r %s: %d rows (%.2f s)\n", str_pad($label, 20), $count, $elapsed);
}
function batchInsert(PDO $dst, string $table, array $columns, array $rows, int $batchSize = BATCH_SIZE): int {
if (empty($rows)) {
return 0;
}
$total = 0;
$placeholders = '(' . implode(',', array_fill(0, count($columns), '?')) . ')';
$sql = sprintf(
'INSERT INTO `%s` (`%s`) VALUES %s',
$table,
implode('`, `', $columns),
implode(',', array_fill(0, min($batchSize, count($rows)), $placeholders))
);
foreach (array_chunk($rows, $batchSize) as $chunk) {
// Rebuild SQL if chunk is smaller than batchSize (last chunk)
if (count($chunk) < $batchSize) {
$sql = sprintf(
'INSERT INTO `%s` (`%s`) VALUES %s',
$table,
implode('`, `', $columns),
implode(',', array_fill(0, count($chunk), $placeholders))
);
}
$flat = [];
foreach ($chunk as $row) {
foreach ($row as $val) {
$flat[] = $val;
}
}
$stmt = $dst->prepare($sql);
$stmt->execute($flat);
$total += count($chunk);
}
return $total;
}
function safeString(?string $val, int $maxLen = 255): ?string {
if ($val === null || $val === '') {
return null;
}
$val = trim($val);
if ($val === '') {
return null;
}
return mb_substr($val, 0, $maxLen);
}
function timestampToDateTime(int $ts): ?string {
if ($ts <= 0) {
return null;
}
return date('Y-m-d H:i:s', $ts);
}
// ── Truncate target tables first ────────────────────────────────────────────
echo "Clearing target tables...\n";
$tables = [
'kimai2_timesheet_tags', 'kimai2_timesheet_meta', 'kimai2_timesheet',
'kimai2_activities_rates', 'kimai2_activities_teams', 'kimai2_activities_meta', 'kimai2_activities',
'kimai2_projects_rates', 'kimai2_projects_teams', 'kimai2_projects_comments', 'kimai2_projects_meta', 'kimai2_projects',
'kimai2_customers_rates', 'kimai2_customers_teams', 'kimai2_customers_comments', 'kimai2_customers_meta', 'kimai2_customers',
'kimai2_users_teams', 'kimai2_user_preferences', 'kimai2_users',
'kimai2_teams',
];
foreach ($tables as $table) {
try {
$dst->exec("DELETE FROM `$table`");
echo " Cleared: $table\n";
} catch (PDOException $e) {
echo " Skipped (doesn't exist): $table\n";
}
}
$dst->exec('COMMIT');
echo "\n";
// ── Migration order: master data first, then transactions ───────────────────
// ╔══════════════════════════════════════════════════════════════════════════════╗
// ║ 1. Groups → Teams ║
// ╚══════════════════════════════════════════════════════════════════════════════╝
echo "--- Migrating Groups → Teams ---\n";
$t0 = microtime(true);
$groups = $src->query('SELECT grp_ID, grp_name FROM kimai15_grp WHERE grp_trash = 0')->fetchAll();
$rows = [];
foreach ($groups as $g) {
$rows[] = [
'id' => (int) $g['grp_ID'],
'name' => safeString($g['grp_name'], 100) ?? '',
'color' => null,
];
}
$count = batchInsert($dst, 'kimai2_teams', ['id', 'name', 'color'], $rows);
$dst->exec('COMMIT');
done('Teams', $count, microtime(true) - $t0);
// ╔══════════════════════════════════════════════════════════════════════════════╗
// ║ 2. Customers ║
// ╚══════════════════════════════════════════════════════════════════════════════╝
echo "--- Migrating Customers ---\n";
$t0 = microtime(true);
$customers = $src->query('SELECT * FROM kimai15_knd WHERE knd_trash = 0')->fetchAll();
$total = count($customers);
$count = 0;
$rows = [];
foreach ($customers as $c) {
$rows[] = [
'id' => (int) $c['knd_ID'],
'name' => safeString($c['knd_name'], 150) ?: '',
'number' => null,
'comment' => $c['knd_comment'] ?: null,
'visible' => (int) $c['knd_visible'],
'company' => safeString($c['knd_company'], 100),
'contact' => safeString($c['knd_contact'], 100),
'address' => $c['knd_street'] ?: null,
'country' => DEFAULT_COUNTRY,
'currency' => DEFAULT_CURRENCY,
'phone' => safeString($c['knd_tel'], 30),
'fax' => safeString($c['knd_fax'], 30),
'mobile' => safeString($c['knd_mobile'], 30),
'email' => safeString($c['knd_mail'], 75),
'homepage' => safeString($c['knd_homepage'], 100),
'timezone' => DEFAULT_TIMEZONE,
'color' => null,
'time_budget' => 0,
'budget' => 0.0,
'vat_id' => safeString($c['knd_vat'], 50),
'budget_type' => null,
'billable' => 1,
'invoice_template_id' => null,
'invoice_text' => null,
'created_at' => date('Y-m-d H:i:s'),
'address_line1' => safeString($c['knd_street'], 150),
'address_line2' => null,
'address_line3' => null,
'postcode' => safeString($c['knd_zipcode'], 20),
'city' => safeString($c['knd_city'], 50),
'buyer_reference' => null,
];
$count++;
if ($count % 100 === 0) {
progress('Customers', $count, $total);
}
}
$imported = batchInsert($dst, 'kimai2_customers', [
'id', 'name', 'number', 'comment', 'visible', 'company', 'contact', 'address',
'country', 'currency', 'phone', 'fax', 'mobile', 'email', 'homepage',
'timezone', 'color', 'time_budget', 'budget', 'vat_id', 'budget_type',
'billable', 'invoice_template_id', 'invoice_text', 'created_at',
'address_line1', 'address_line2', 'address_line3', 'postcode', 'city', 'buyer_reference',
], $rows);
$dst->exec('COMMIT');
done('Customers', $imported, microtime(true) - $t0);
// Reset auto-increment after inserting with explicit IDs
$dst->exec("ALTER TABLE kimai2_customers AUTO_INCREMENT = " . ((int) $src->query('SELECT MAX(knd_ID) FROM kimai15_knd')->fetchColumn() + 1));
// ╔══════════════════════════════════════════════════════════════════════════════╗
// ║ 3. Projects ║
// ╚══════════════════════════════════════════════════════════════════════════════╝
echo "--- Migrating Projects ---\n";
$t0 = microtime(true);
$projects = $src->query('SELECT * FROM kimai15_pct WHERE pct_trash = 0')->fetchAll();
$total = count($projects);
$count = 0;
$rows = [];
foreach ($projects as $p) {
$rows[] = [
'id' => (int) $p['pct_ID'],
'customer_id' => (int) $p['pct_kndID'],
'name' => safeString($p['pct_name'], 150) ?: '',
'order_number'=> null,
'comment' => $p['pct_comment'] ?: null,
'visible' => (int) $p['pct_visible'],
'budget' => (float) $p['pct_budget'],
'color' => null,
'time_budget' => 0,
'order_date' => null,
'start' => null,
'end' => null,
'timezone' => null,
'budget_type' => null,
'billable' => (int) $p['pct_billable'],
'invoice_text'=> null,
'global_activities' => 1,
'number' => null,
'created_at' => date('Y-m-d H:i:s'),
];
$count++;
if ($count % 100 === 0) {
progress('Projects', $count, $total);
}
}
$imported = batchInsert($dst, 'kimai2_projects', [
'id', 'customer_id', 'name', 'order_number', 'comment', 'visible',
'budget', 'color', 'time_budget', 'order_date', 'start', 'end',
'timezone', 'budget_type', 'billable', 'invoice_text',
'global_activities', 'number', 'created_at',
], $rows);
$dst->exec('COMMIT');
done('Projects', $imported, microtime(true) - $t0);
$dst->exec("ALTER TABLE kimai2_projects AUTO_INCREMENT = " . ((int) $src->query('SELECT MAX(pct_ID) FROM kimai15_pct')->fetchColumn() + 1));
// ╔══════════════════════════════════════════════════════════════════════════════╗
// ║ 4. Activities (global) ║
// ╚══════════════════════════════════════════════════════════════════════════════╝
echo "--- Migrating Activities ---\n";
$t0 = microtime(true);
$activities = $src->query('SELECT * FROM kimai15_evt WHERE evt_trash = 0')->fetchAll();
$total = count($activities);
$count = 0;
$rows = [];
foreach ($activities as $a) {
$rows[] = [
'id' => (int) $a['evt_ID'],
'project_id' => null, // global activities
'name' => safeString($a['evt_name'], 150) ?: '',
'comment' => $a['evt_comment'] ?: null,
'visible' => (int) $a['evt_visible'],
'color' => null,
'time_budget'=> 0,
'budget' => 0.0,
'budget_type'=> null,
'billable' => 1,
'invoice_text'=> null,
'number' => null,
'created_at' => date('Y-m-d H:i:s'),
];
$count++;
if ($count % 100 === 0) {
progress('Activities', $count, $total);
}
}
$imported = batchInsert($dst, 'kimai2_activities', [
'id', 'project_id', 'name', 'comment', 'visible', 'color',
'time_budget', 'budget', 'budget_type', 'billable', 'invoice_text',
'number', 'created_at',
], $rows);
$dst->exec('COMMIT');
done('Activities', $imported, microtime(true) - $t0);
$dst->exec("ALTER TABLE kimai2_activities AUTO_INCREMENT = " . ((int) $src->query('SELECT MAX(evt_ID) FROM kimai15_evt')->fetchColumn() + 1));
// ╔══════════════════════════════════════════════════════════════════════════════╗
// ║ 5. Users ║
// ╚══════════════════════════════════════════════════════════════════════════════╝
echo "--- Migrating Users ---\n";
$t0 = microtime(true);
$users = $src->query('SELECT * FROM kimai15_usr ORDER BY usr_ID')->fetchAll();
$total = count($users);
$count = 0;
$now = date('Y-m-d H:i:s');
$passwordHash = password_hash(DEFAULT_PASSWORD, PASSWORD_BCRYPT);
$userRows = [];
$prefRows = [];
$teamRows = [];
$seenEmails = [];
$seenUsernames = [];
foreach ($users as $u) {
$userId = (int) $u['usr_ID'];
$username = safeString($u['usr_name'], 180) ?: 'user_' . $userId;
$alias = safeString($u['usr_alias'], 60);
$email = safeString($u['usr_mail'], 180) ?: ($username . '@migrated.local');
$enabled = (int) $u['usr_active'];
$v1group = (int) $u['usr_grp'];
// Deduplicate username (case-insensitive)
$origUsername = $username;
$suffix = 1;
while (isset($seenUsernames[mb_strtolower($username)])) {
$username = mb_substr($origUsername, 0, 170) . '_' . $suffix;
$suffix++;
}
$seenUsernames[mb_strtolower($username)] = true;
// Deduplicate email
$origEmail = $email;
$suffix = 1;
$emailLower = mb_strtolower($email);
while (isset($seenEmails[$emailLower])) {
$atPos = strpos($origEmail, '@');
if ($atPos !== false) {
$email = mb_substr($origEmail, 0, min($atPos, 170)) . '+' . $suffix . mb_substr($origEmail, $atPos);
} else {
$email = mb_substr($origEmail, 0, 170) . '+' . $suffix . '@migrated.local';
}
$email = mb_substr($email, 0, 180);
$emailLower = mb_strtolower($email);
$suffix++;
}
$seenEmails[$emailLower] = true;
// Role mapping: admin user gets SUPER_ADMIN, others get USER
if ($origUsername === 'admin' || $origUsername === 'administrator') {
$roles = serialize(['ROLE_SUPER_ADMIN']);
} else {
$roles = serialize(['ROLE_USER']);
}
$userRows[] = [
'id' => $userId,
'username' => $username,
'email' => $email,
'password' => $passwordHash,
'alias' => $alias,
'enabled' => $enabled,
'registration_date' => $now,
'title' => null,
'avatar' => null,
'roles' => $roles,
'last_login' => null,
'confirmation_token' => null,
'password_requested_at' => null,
'api_token' => null,
'auth' => 'kimai',
'color' => null,
'account' => null,
'totp_secret' => null,
'totp_enabled' => 0,
'system_account' => 0,
'supervisor_id' => null,
'signature_date' => null,
];
// User preferences: hourly_rate
$prefRows[] = [
'user_id' => $userId,
'name' => 'hourly_rate',
'value' => '0',
];
$prefRows[] = [
'user_id' => $userId,
'name' => 'timezone',
'value' => DEFAULT_TIMEZONE,
];
// Team membership
if ($v1group > 0) {
$teamRows[] = [
'user_id' => $userId,
'team_id' => $v1group,
'teamlead' => 0,
];
}
$count++;
if ($count % 100 === 0) {
progress('Users', $count, $total);
}
}
// Verify no duplicate emails before insert
$emailCheck = [];
$dupFound = false;
foreach ($userRows as $r) {
$em = mb_strtolower($r['email']);
if (isset($emailCheck[$em])) {
echo "\n *** DUPLICATE EMAIL: $em (IDs: {$emailCheck[$em]} and {$r['id']})\n";
$dupFound = true;
}
$emailCheck[$em] = $r['id'];
}
echo " Verified: " . count($userRows) . " users, " . count($emailCheck) . " unique emails\n";
if ($dupFound) {
echo " ERROR: Duplicate emails detected, aborting.\n";
exit(1);
}
$imported = batchInsert($dst, 'kimai2_users', [
'id', 'username', 'email', 'password', 'alias', 'enabled',
'registration_date', 'title', 'avatar', 'roles', 'last_login',
'confirmation_token', 'password_requested_at', 'api_token', 'auth',
'color', 'account', 'totp_secret', 'totp_enabled', 'system_account',
'supervisor_id', 'signature_date',
], $userRows);
$dst->exec('COMMIT');
done('Users', $imported, microtime(true) - $t0);
$dst->exec("ALTER TABLE kimai2_users AUTO_INCREMENT = " . ((int) $src->query('SELECT MAX(usr_ID) FROM kimai15_usr')->fetchColumn() + 1));
// User preferences
echo "--- Migrating User Preferences ---\n";
$t0 = microtime(true);
$imported = batchInsert($dst, 'kimai2_user_preferences', ['user_id', 'name', 'value'], $prefRows);
$dst->exec('COMMIT');
done('User Preferences', $imported, microtime(true) - $t0);
// User-Team mappings
echo "--- Migrating User-Team Mappings ---\n";
$t0 = microtime(true);
$imported = batchInsert($dst, 'kimai2_users_teams', ['user_id', 'team_id', 'teamlead'], $teamRows);
$dst->exec('COMMIT');
done('User-Team Mappings', $imported, microtime(true) - $t0);
// ╔══════════════════════════════════════════════════════════════════════════════╗
// ║ 6. Rates ║
// ╚══════════════════════════════════════════════════════════════════════════════╝
echo "--- Migrating Rates ---\n";
$t0 = microtime(true);
$rates = $src->query('SELECT * FROM kimai15_rates')->fetchAll();
$projectRates = [];
$activityRates = [];
foreach ($rates as $r) {
if (!empty($r['project_id'])) {
$projectRates[] = [
'user_id' => $r['user_id'] ? (int) $r['user_id'] : null,
'project_id' => (int) $r['project_id'],
'rate' => (float) $r['rate'],
'fixed' => 0,
'internal_rate' => null,
];
}
if (!empty($r['event_id'])) {
$activityRates[] = [
'user_id' => $r['user_id'] ? (int) $r['user_id'] : null,
'activity_id' => (int) $r['event_id'],
'rate' => (float) $r['rate'],
'fixed' => 0,
'internal_rate' => null,
];
}
}
$count1 = batchInsert($dst, 'kimai2_projects_rates', ['user_id', 'project_id', 'rate', 'fixed', 'internal_rate'], $projectRates);
$count2 = batchInsert($dst, 'kimai2_activities_rates', ['user_id', 'activity_id', 'rate', 'fixed', 'internal_rate'], $activityRates);
$dst->exec('COMMIT');
done('Rates (projects)', $count1, microtime(true) - $t0);
done('Rates (activities)', $count2, microtime(true) - $t0);
// ╔══════════════════════════════════════════════════════════════════════════════╗
// ║ 7. Timesheets (the big one: ~908K rows) ║
// ╚══════════════════════════════════════════════════════════════════════════════╝
echo "--- Migrating Timesheets (~908K rows) ---\n";
$t0 = microtime(true);
// Count total
$total = (int) $src->query('SELECT COUNT(*) FROM kimai15_zef')->fetchColumn();
$count = 0;
// Stream with unbuffered query
$stmt = $src->prepare('SELECT zef_ID, zef_in, zef_out, zef_time, zef_usrID, zef_pctID, zef_evtID, zef_comment, zef_rate FROM kimai15_zef ORDER BY zef_ID');
$stmt->execute();
$rows = [];
while ($row = $stmt->fetch()) {
$startTime = timestampToDateTime((int) $row['zef_in']);
// Skip entries with no valid start time
if ($startTime === null) {
$count++;
continue;
}
$endTime = timestampToDateTime((int) $row['zef_out']);
$duration = (int) $row['zef_time']; // seconds
// date_tz: extract date portion from start_time
$dateTz = $startTime ? substr($startTime, 0, 10) : null;
$rows[] = [
'id' => (int) $row['zef_ID'],
'user' => (int) $row['zef_usrID'],
'activity_id' => (int) $row['zef_evtID'],
'project_id' => (int) $row['zef_pctID'],
'start_time' => $startTime,
'end_time' => $endTime,
'duration' => $duration > 0 ? $duration : null,
'description' => $row['zef_comment'] ?: null,
'rate' => (float) $row['zef_rate'],
'fixed_rate' => null,
'hourly_rate' => null,
'exported' => 0,
'timezone' => DEFAULT_TIMEZONE,
'internal_rate'=> null,
'billable' => 1,
'category' => 'work',
'modified_at' => null,
'date_tz' => $dateTz,
'break' => null,
];
$count++;
if ($count % BATCH_SIZE === 0) {
batchInsert($dst, 'kimai2_timesheet', [
'id', 'user', 'activity_id', 'project_id', 'start_time', 'end_time',
'duration', 'description', 'rate', 'fixed_rate', 'hourly_rate',
'exported', 'timezone', 'internal_rate', 'billable', 'category',
'modified_at', 'date_tz', 'break',
], $rows, BATCH_SIZE);
$dst->exec('COMMIT');
$rows = [];
progress('Timesheets', $count, $total);
}
}
// Insert final batch
if (!empty($rows)) {
batchInsert($dst, 'kimai2_timesheet', [
'id', 'user', 'activity_id', 'project_id', 'start_time', 'end_time',
'duration', 'description', 'rate', 'fixed_rate', 'hourly_rate',
'exported', 'timezone', 'internal_rate', 'billable', 'category',
'modified_at', 'date_tz', 'break',
], $rows, BATCH_SIZE);
$dst->exec('COMMIT');
}
done('Timesheets', $count, microtime(true) - $t0);
$dst->exec("ALTER TABLE kimai2_timesheet AUTO_INCREMENT = " . ((int) $src->query('SELECT MAX(zef_ID) FROM kimai15_zef')->fetchColumn() + 1));
// ╔══════════════════════════════════════════════════════════════════════════════╗
// ║ 8. Expenses (optional) ║
// ╚══════════════════════════════════════════════════════════════════════════════╝
echo "--- Migrating Expenses ---\n";
$t0 = microtime(true);
$expenses = $src->query('SELECT * FROM kimai15_exp')->fetchAll();
$total = count($expenses);
// Expenses are stored as timesheet entries with category='expense' in Kimai2
// Expenses: need a fallback activity_id since Kimai2 requires it NOT NULL
$fallbackActivityId = (int) ($dst->query('SELECT MIN(id) FROM kimai2_activities')->fetchColumn() ?: 1);
$rows = [];
foreach ($expenses as $e) {
$startTime = timestampToDateTime((int) $e['exp_timestamp']);
$dateTz = $startTime ? substr($startTime, 0, 10) : null;
$rows[] = [
'user' => (int) $e['exp_usrID'],
'activity_id' => $fallbackActivityId,
'project_id' => (int) $e['exp_pctID'],
'start_time' => $startTime,
'end_time' => $startTime, // expenses: same as start
'duration' => null,
'description' => ($e['exp_designation'] ?: '') . ($e['exp_comment'] ? ' — ' . $e['exp_comment'] : ''),
'rate' => (float) $e['exp_value'],
'fixed_rate' => null,
'hourly_rate' => null,
'exported' => (int) $e['exp_cleared'],
'timezone' => DEFAULT_TIMEZONE,
'internal_rate'=> null,
'billable' => (int) $e['exp_refundable'],
'category' => 'expense',
'modified_at' => null,
'date_tz' => $dateTz,
'break' => null,
];
}
$imported = batchInsert($dst, 'kimai2_timesheet', [
'user', 'activity_id', 'project_id', 'start_time', 'end_time',
'duration', 'description', 'rate', 'fixed_rate', 'hourly_rate',
'exported', 'timezone', 'internal_rate', 'billable', 'category',
'modified_at', 'date_tz', 'break',
], $rows);
$dst->exec('COMMIT');
done('Expenses', $imported, microtime(true) - $t0);
// ╔══════════════════════════════════════════════════════════════════════════════╗
// ║ Re-enable foreign keys ║
// ╚══════════════════════════════════════════════════════════════════════════════╝
$dst->exec('SET FOREIGN_KEY_CHECKS = 1');
$dst->exec('SET AUTOCOMMIT = 1');
// ╔══════════════════════════════════════════════════════════════════════════════╗
// ║ Summary ║
// ╚══════════════════════════════════════════════════════════════════════════════╝
echo "\n";
echo str_repeat('=', 60) . "\n";
echo "MIGRATION SUMMARY\n";
echo str_repeat('=', 60) . "\n";
$summaryTables = [
'Teams' => 'kimai2_teams',
'Customers' => 'kimai2_customers',
'Projects' => 'kimai2_projects',
'Activities' => 'kimai2_activities',
'Users' => 'kimai2_users',
'User Preferences' => 'kimai2_user_preferences',
'User-Team Mappings'=> 'kimai2_users_teams',
'Timesheets' => 'kimai2_timesheet',
];
foreach ($summaryTables as $label => $table) {
$cnt = $dst->query("SELECT COUNT(*) FROM `$table`")->fetchColumn();
printf(" %-20s → %s: %d rows\n", $label, $table, $cnt);
}
echo "\nDefault password for all users: " . DEFAULT_PASSWORD . "\n";
echo "Password hash algorithm: BCRYPT\n";
echo "\nDone.\n";

View File

@@ -42,14 +42,14 @@
"bootstrap": "^5.3.8",
"chart.js": "^4.5.1",
"core-js": "^3.49.0",
"dompurify": "^3.4.7",
"dompurify": "^3.4.10",
"eslint": "^9.39.4",
"globals": "^15.15.0",
"gridstack": "^7.3.0",
"highlight.js": "^11.11.1",
"litepicker": "^2.0.12",
"luxon": "^3.7.2",
"sass": "^1.100.0",
"sass": "^1.101.0",
"sass-loader": "^16.0.8",
"tom-select": "2.5.2",
"webpack": "^5.107.2",

165
pnpm-lock.yaml generated
View File

@@ -52,7 +52,7 @@ importers:
version: 2.11.8
'@symfony/webpack-encore':
specifier: ^6.0.0
version: 6.0.0(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(postcss@8.5.15)(sass-loader@16.0.8(sass@1.100.0)(webpack@5.107.2))(sass@1.100.0)(webpack-cli@6.0.1)(webpack@5.107.2)
version: 6.0.0(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(postcss@8.5.15)(sass-loader@16.0.8(sass@1.101.0)(webpack@5.107.2))(sass@1.101.0)(webpack-cli@6.0.1)(webpack@5.107.2)
'@tabler/core':
specifier: ^1.4.0
version: 1.4.0
@@ -66,8 +66,8 @@ importers:
specifier: ^3.49.0
version: 3.49.0
dompurify:
specifier: ^3.4.7
version: 3.4.7
specifier: ^3.4.10
version: 3.4.10
eslint:
specifier: ^9.39.4
version: 9.39.4
@@ -87,11 +87,11 @@ importers:
specifier: ^3.7.2
version: 3.7.2
sass:
specifier: ^1.100.0
version: 1.100.0
specifier: ^1.101.0
version: 1.101.0
sass-loader:
specifier: ^16.0.8
version: 16.0.8(sass@1.100.0)(webpack@5.107.2)
version: 16.0.8(sass@1.101.0)(webpack@5.107.2)
tom-select:
specifier: 2.5.2
version: 2.5.2
@@ -945,8 +945,8 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@25.9.1':
resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
'@types/node@25.9.3':
resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -1047,8 +1047,8 @@ packages:
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
acorn@8.17.0:
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
engines: {node: '>=0.4.0'}
hasBin: true
@@ -1117,8 +1117,8 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
baseline-browser-mapping@2.10.33:
resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==}
baseline-browser-mapping@2.10.37:
resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -1156,8 +1156,8 @@ packages:
caniuse-api@3.0.0:
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
caniuse-lite@1.0.30001799:
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
@@ -1351,8 +1351,8 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dompurify@3.4.7:
resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==}
dompurify@3.4.10:
resolution: {integrity: sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==}
domutils@2.8.0:
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
@@ -1360,8 +1360,8 @@ packages:
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
electron-to-chromium@1.5.364:
resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==}
electron-to-chromium@1.5.374:
resolution: {integrity: sha512-HCF5i7izveksHSGqa7mhDh6tr3Uz9Dar2RAjwuh69bw3QGPVObjQIgLwQWeO/Rxp9/r0KdboKy9RbpQDl97fjg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -1370,8 +1370,8 @@ packages:
resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==}
engines: {node: '>= 4'}
enhanced-resolve@5.22.1:
resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==}
enhanced-resolve@5.24.0:
resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==}
engines: {node: '>=10.13.0'}
entities@2.2.0:
@@ -1744,8 +1744,8 @@ packages:
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-releases@2.0.46:
resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
node-releases@2.0.47:
resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
engines: {node: '>=18'}
nth-check@2.1.1:
@@ -1975,8 +1975,8 @@ packages:
peerDependencies:
postcss: ^8.5.13
postcss-selector-parser@7.1.1:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
postcss-selector-parser@7.1.4:
resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==}
engines: {node: '>=4'}
postcss-svgo@7.1.3:
@@ -2037,8 +2037,8 @@ packages:
regjsgen@0.8.0:
resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==}
regjsparser@0.13.1:
resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==}
regjsparser@0.13.2:
resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==}
hasBin: true
renderkid@3.0.0:
@@ -2090,8 +2090,8 @@ packages:
webpack:
optional: true
sass@1.100.0:
resolution: {integrity: sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==}
sass@1.101.0:
resolution: {integrity: sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==}
engines: {node: '>=20.19.0'}
hasBin: true
@@ -2107,8 +2107,8 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.8.1:
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
semver@7.8.4:
resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
engines: {node: '>=10'}
hasBin: true
@@ -2286,8 +2286,8 @@ packages:
utila@0.4.0:
resolution: {integrity: sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==}
watchpack@2.5.1:
resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
watchpack@2.5.2:
resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==}
engines: {node: '>=10.13.0'}
webpack-cli@6.0.1:
@@ -3143,7 +3143,7 @@ snapshots:
'@jest/pattern@30.4.0':
dependencies:
'@types/node': 25.9.1
'@types/node': 25.9.3
jest-regex-util: 30.4.0
'@jest/schemas@30.4.1':
@@ -3156,7 +3156,7 @@ snapshots:
'@jest/schemas': 30.4.1
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 25.9.1
'@types/node': 25.9.3
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -3269,7 +3269,7 @@ snapshots:
'@sinclair/typebox@0.34.49': {}
'@symfony/webpack-encore@6.0.0(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(postcss@8.5.15)(sass-loader@16.0.8(sass@1.100.0)(webpack@5.107.2))(sass@1.100.0)(webpack-cli@6.0.1)(webpack@5.107.2)':
'@symfony/webpack-encore@6.0.0(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(postcss@8.5.15)(sass-loader@16.0.8(sass@1.101.0)(webpack@5.107.2))(sass@1.101.0)(webpack-cli@6.0.1)(webpack@5.107.2)':
dependencies:
'@babel/core': 7.29.7
'@babel/preset-env': 7.29.7(@babel/core@7.29.7)
@@ -3282,7 +3282,7 @@ snapshots:
picocolors: 1.1.1
pretty-error: 4.0.0
resolve-url-loader: 5.0.0
semver: 7.8.1
semver: 7.8.4
style-loader: 4.0.0(webpack@5.107.2)
tapable: 2.3.3
terser-webpack-plugin: 5.6.1(postcss@8.5.15)(webpack@5.107.2)
@@ -3293,8 +3293,8 @@ snapshots:
yargs-parser: 21.1.1
optionalDependencies:
postcss: 8.5.15
sass: 1.100.0
sass-loader: 16.0.8(sass@1.100.0)(webpack@5.107.2)
sass: 1.101.0
sass-loader: 16.0.8(sass@1.101.0)(webpack@5.107.2)
transitivePeerDependencies:
- '@minify-html/node'
- '@parcel/css'
@@ -3329,7 +3329,7 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/node@25.9.1':
'@types/node@25.9.3':
dependencies:
undici-types: 7.24.6
@@ -3439,15 +3439,15 @@ snapshots:
'@xtuc/long@4.2.2': {}
acorn-import-phases@1.0.4(acorn@8.16.0):
acorn-import-phases@1.0.4(acorn@8.17.0):
dependencies:
acorn: 8.16.0
acorn: 8.17.0
acorn-jsx@5.3.2(acorn@8.16.0):
acorn-jsx@5.3.2(acorn@8.17.0):
dependencies:
acorn: 8.16.0
acorn: 8.17.0
acorn@8.16.0: {}
acorn@8.17.0: {}
adjust-sourcemap-loader@4.0.0:
dependencies:
@@ -3518,7 +3518,7 @@ snapshots:
balanced-match@1.0.2: {}
baseline-browser-mapping@2.10.33: {}
baseline-browser-mapping@2.10.37: {}
big.js@5.2.2: {}
@@ -3539,10 +3539,10 @@ snapshots:
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.33
caniuse-lite: 1.0.30001793
electron-to-chromium: 1.5.364
node-releases: 2.0.46
baseline-browser-mapping: 2.10.37
caniuse-lite: 1.0.30001799
electron-to-chromium: 1.5.374
node-releases: 2.0.47
update-browserslist-db: 1.2.3(browserslist@4.28.2)
buffer-from@1.1.2: {}
@@ -3552,11 +3552,11 @@ snapshots:
caniuse-api@3.0.0:
dependencies:
browserslist: 4.28.2
caniuse-lite: 1.0.30001793
caniuse-lite: 1.0.30001799
lodash.memoize: 4.1.2
lodash.uniq: 4.5.0
caniuse-lite@1.0.30001793: {}
caniuse-lite@1.0.30001799: {}
chalk@4.1.2:
dependencies:
@@ -3628,7 +3628,7 @@ snapshots:
postcss-modules-scope: 3.2.1(postcss@8.5.15)
postcss-modules-values: 4.0.0(postcss@8.5.15)
postcss-value-parser: 4.2.0
semver: 7.8.1
semver: 7.8.4
optionalDependencies:
webpack: 5.107.2(postcss@8.5.15)(webpack-cli@6.0.1)
@@ -3755,7 +3755,7 @@ snapshots:
dependencies:
domelementtype: 2.3.0
dompurify@3.4.7:
dompurify@3.4.10:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -3771,13 +3771,13 @@ snapshots:
domelementtype: 2.3.0
domhandler: 5.0.3
electron-to-chromium@1.5.364: {}
electron-to-chromium@1.5.374: {}
emoji-regex@8.0.0: {}
emojis-list@3.0.0: {}
enhanced-resolve@5.22.1:
enhanced-resolve@5.24.0:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.3
@@ -3857,8 +3857,8 @@ snapshots:
espree@10.4.0:
dependencies:
acorn: 8.16.0
acorn-jsx: 5.3.2(acorn@8.16.0)
acorn: 8.17.0
acorn-jsx: 5.3.2(acorn@8.17.0)
eslint-visitor-keys: 4.2.1
esquery@1.7.0:
@@ -3992,7 +3992,7 @@ snapshots:
jest-util@30.4.1:
dependencies:
'@jest/types': 30.4.1
'@types/node': 25.9.1
'@types/node': 25.9.3
chalk: 4.1.2
ci-info: 4.4.0
graceful-fs: 4.2.11
@@ -4000,13 +4000,13 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 25.9.1
'@types/node': 25.9.3
merge-stream: 2.0.0
supports-color: 8.1.1
jest-worker@30.4.1:
dependencies:
'@types/node': 25.9.1
'@types/node': 25.9.3
'@ungap/structured-clone': 1.3.1
jest-util: 30.4.1
merge-stream: 2.0.0
@@ -4106,7 +4106,7 @@ snapshots:
node-addon-api@7.1.1:
optional: true
node-releases@2.0.46: {}
node-releases@2.0.47: {}
nth-check@2.1.1:
dependencies:
@@ -4160,7 +4160,7 @@ snapshots:
postcss-calc@10.1.1(postcss@8.5.15):
dependencies:
postcss: 8.5.15
postcss-selector-parser: 7.1.1
postcss-selector-parser: 7.1.4
postcss-value-parser: 4.2.0
postcss-colormin@7.0.10(postcss@8.5.15):
@@ -4180,7 +4180,7 @@ snapshots:
postcss-discard-comments@7.0.8(postcss@8.5.15):
dependencies:
postcss: 8.5.15
postcss-selector-parser: 7.1.1
postcss-selector-parser: 7.1.4
postcss-discard-duplicates@7.0.4(postcss@8.5.15):
dependencies:
@@ -4206,7 +4206,7 @@ snapshots:
caniuse-api: 3.0.0
cssnano-utils: 5.0.3(postcss@8.5.15)
postcss: 8.5.15
postcss-selector-parser: 7.1.1
postcss-selector-parser: 7.1.4
postcss-minify-font-values@7.0.3(postcss@8.5.15):
dependencies:
@@ -4233,7 +4233,7 @@ snapshots:
caniuse-api: 3.0.0
cssesc: 3.0.0
postcss: 8.5.15
postcss-selector-parser: 7.1.1
postcss-selector-parser: 7.1.4
postcss-modules-extract-imports@3.1.0(postcss@8.5.15):
dependencies:
@@ -4243,13 +4243,13 @@ snapshots:
dependencies:
icss-utils: 5.1.0(postcss@8.5.15)
postcss: 8.5.15
postcss-selector-parser: 7.1.1
postcss-selector-parser: 7.1.4
postcss-value-parser: 4.2.0
postcss-modules-scope@3.2.1(postcss@8.5.15):
dependencies:
postcss: 8.5.15
postcss-selector-parser: 7.1.1
postcss-selector-parser: 7.1.4
postcss-modules-values@4.0.0(postcss@8.5.15):
dependencies:
@@ -4318,7 +4318,7 @@ snapshots:
postcss: 8.5.15
postcss-value-parser: 4.2.0
postcss-selector-parser@7.1.1:
postcss-selector-parser@7.1.4:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
@@ -4332,7 +4332,7 @@ snapshots:
postcss-unique-selectors@7.0.7(postcss@8.5.15):
dependencies:
postcss: 8.5.15
postcss-selector-parser: 7.1.1
postcss-selector-parser: 7.1.4
postcss-value-parser@4.2.0: {}
@@ -4372,13 +4372,13 @@ snapshots:
regenerate: 1.4.2
regenerate-unicode-properties: 10.2.2
regjsgen: 0.8.0
regjsparser: 0.13.1
regjsparser: 0.13.2
unicode-match-property-ecmascript: 2.0.0
unicode-match-property-value-ecmascript: 2.2.1
regjsgen@0.8.0: {}
regjsparser@0.13.1:
regjsparser@0.13.2:
dependencies:
jsesc: 3.1.0
@@ -4415,14 +4415,14 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
sass-loader@16.0.8(sass@1.100.0)(webpack@5.107.2):
sass-loader@16.0.8(sass@1.101.0)(webpack@5.107.2):
dependencies:
neo-async: 2.6.2
optionalDependencies:
sass: 1.100.0
sass: 1.101.0
webpack: 5.107.2(postcss@8.5.15)(webpack-cli@6.0.1)
sass@1.100.0:
sass@1.101.0:
dependencies:
chokidar: 5.0.0
immutable: 5.1.6
@@ -4441,7 +4441,7 @@ snapshots:
semver@6.3.1: {}
semver@7.8.1: {}
semver@7.8.4: {}
serialize-javascript@7.0.5: {}
@@ -4488,7 +4488,7 @@ snapshots:
dependencies:
browserslist: 4.28.2
postcss: 8.5.15
postcss-selector-parser: 7.1.1
postcss-selector-parser: 7.1.4
supports-color@7.2.0:
dependencies:
@@ -4525,7 +4525,7 @@ snapshots:
terser@5.48.0:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.16.0
acorn: 8.17.0
commander: 2.20.3
source-map-support: 0.5.21
@@ -4569,9 +4569,8 @@ snapshots:
utila@0.4.0: {}
watchpack@2.5.1:
watchpack@2.5.2:
dependencies:
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
webpack-cli@6.0.1(webpack@5.107.2):
@@ -4617,11 +4616,11 @@ snapshots:
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.16.0
acorn-import-phases: 1.0.4(acorn@8.16.0)
acorn: 8.17.0
acorn-import-phases: 1.0.4(acorn@8.17.0)
browserslist: 4.28.2
chrome-trace-event: 1.0.4
enhanced-resolve: 5.22.1
enhanced-resolve: 5.24.0
es-module-lexer: 2.1.0
eslint-scope: 5.1.1
events: 3.3.0
@@ -4633,7 +4632,7 @@ snapshots:
schema-utils: 4.3.3
tapable: 2.3.3
terser-webpack-plugin: 5.6.1(postcss@8.5.15)(webpack@5.107.2)
watchpack: 2.5.1
watchpack: 2.5.2
webpack-sources: 3.5.0
optionalDependencies:
webpack-cli: 6.0.1(webpack@5.107.2)

View File

@@ -135,9 +135,9 @@ final class UserController extends BaseApiController
* Create user
*/
#[IsGranted('create_user')]
#[OA\Post(description: 'Creates a new user and returns it afterwards')]
#[OA\Post(description: 'Creates a new user and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created user', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserCreateForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_user')]
#[Route(path: '', name: 'post_user', methods: ['POST'])]
public function postAction(Request $request, UserService $userService): Response
{
$user = $userService->createNewUser();

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.60.0';
public const VERSION = '2.61.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 26000;
public const VERSION_ID = 26100;
/**
* The software name
*/

View File

@@ -23,6 +23,7 @@ final class Configuration
private array $options = [];
private bool $enabled = true;
private bool $required = true;
private ?string $formTheme = null;
/**
* @var Constraint[]
*/
@@ -149,4 +150,16 @@ final class Configuration
return $this;
}
public function getFormTheme(): ?string
{
return $this->formTheme;
}
public function setFormTheme(string $formTheme): Configuration
{
$this->formTheme = $formTheme;
return $this;
}
}

View File

@@ -51,7 +51,7 @@ final class MultiUserTimesheet extends Timesheet
public function removeUser(User $user): void
{
if ($this->users->contains($user)) {
$this->users->remove($user);
$this->users->removeElement($user);
}
}
@@ -71,7 +71,7 @@ final class MultiUserTimesheet extends Timesheet
public function removeTeam(Team $team): void
{
if ($this->teams->contains($team)) {
$this->teams->remove($team);
$this->teams->removeElement($team);
}
}
}

View File

@@ -15,7 +15,7 @@ final class TotpActivation
{
private ?string $code = null;
public function __construct(private User $user)
public function __construct(private readonly User $user)
{
}

View File

@@ -16,7 +16,7 @@ final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSucces
{
protected $defaultOptions = [
'always_use_default_target_path' => false,
'default_target_path' => '/',
'default_target_path' => 'homepage',
'login_path' => 'saml_login',
'target_path_parameter' => '_target_path',
'use_referer' => false,

View File

@@ -461,6 +461,15 @@
"config/packages/mailer.yaml"
]
},
"symfony/maker-bundle": {
"version": "1.67",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/mime": {
"version": "v4.3.3"
},

View File

@@ -37,6 +37,11 @@
{% embed formEditTemplate with {'title': title, 'form': section.form} %}
{% block form_before %}<span id="conf_{{ section.model.section }}"></span>{% endblock %}
{% block form_body %}
{% for pref in form.children.configuration %}
{% if pref.vars.data.formTheme is not null %}
{% form_theme pref pref.vars.data.formTheme %}
{% endif %}
{% endfor %}
{% for pref in form.children.configuration %}
{{ form_widget(pref) }}
{% endfor %}

View File

@@ -11,6 +11,11 @@
{% endif %}
{% embed formEditTemplate with {'title': title, 'form': section.form} %}
{% block form_body %}
{% for pref in form.children.configuration %}
{% if pref.vars.data.formTheme is not null %}
{% form_theme pref pref.vars.data.formTheme %}
{% endif %}
{% endfor %}
{% for pref in form.children.configuration %}
{{ form_widget(pref) }}
{% endfor %}

View File

@@ -0,0 +1,125 @@
<?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\Form\Model;
use App\Form\Model\Configuration;
use App\Form\Type\YesNoType;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Validator\Constraints\NotBlank;
#[CoversClass(Configuration::class)]
class ConfigurationTest extends TestCase
{
public function testDefaultValues(): void
{
$sut = new Configuration('foo');
self::assertSame('foo', $sut->getName());
self::assertNull($sut->getLabel());
self::assertSame('messages', $sut->getTranslationDomain());
self::assertNull($sut->getValue());
self::assertNull($sut->getType());
self::assertSame([], $sut->getOptions());
self::assertTrue($sut->isEnabled());
self::assertTrue($sut->isRequired());
self::assertNull($sut->getFormTheme());
self::assertSame([], $sut->getConstraints());
}
public function testFluentSetterAndGetter(): void
{
$sut = new Configuration('foo');
$constraints = [new NotBlank()];
$options = ['attr' => ['data-test' => 'value'], 'empty_data' => 0];
$result = $sut
->setLabel('Foo label')
->setTranslationDomain('admin')
->setType('custom-type')
->setValue(42.5)
->setOptions($options)
->setConstraints($constraints)
->setEnabled(false)
->setRequired(false)
->setFormTheme('@MyBundle/form/test.html.twig');
self::assertSame($sut, $result);
self::assertSame('Foo label', $sut->getLabel());
self::assertSame('admin', $sut->getTranslationDomain());
self::assertSame('custom-type', $sut->getType());
self::assertSame(42.5, $sut->getValue());
self::assertSame($options, $sut->getOptions());
self::assertSame($constraints, $sut->getConstraints());
self::assertFalse($sut->isEnabled());
self::assertFalse($sut->isRequired());
self::assertSame('@MyBundle/form/test.html.twig', $sut->getFormTheme());
}
#[DataProvider('provideScalarValues')]
public function testSetValueKeepsOriginalTypeForNonBooleanField(string|int|null|bool|float $value): void
{
$sut = new Configuration('foo');
self::assertSame($sut, $sut->setValue($value));
self::assertSame($value, $sut->getValue());
}
/**
* @return iterable<string, array{0: string, 1: string|int|null|bool|float, 2: bool}>
*/
public static function provideBooleanTypeValues(): iterable
{
yield 'checkbox true string' => [CheckboxType::class, '1', true];
yield 'checkbox zero string' => [CheckboxType::class, '0', false];
yield 'checkbox integer zero' => [CheckboxType::class, 0, false];
yield 'checkbox integer one' => [CheckboxType::class, 1, true];
yield 'checkbox null' => [CheckboxType::class, null, false];
yield 'checkbox empty string' => [CheckboxType::class, '', false];
yield 'yes no text value' => [YesNoType::class, 'yes', true];
yield 'yes no false bool' => [YesNoType::class, false, false];
yield 'yes no float' => [YesNoType::class, 3.14, true];
}
#[DataProvider('provideBooleanTypeValues')]
public function testSetValueCastsToBoolForBooleanTypes(string $type, string|int|null|bool|float $value, bool $expected): void
{
$sut = new Configuration('foo');
self::assertSame($sut, $sut->setType($type));
self::assertSame($sut, $sut->setValue($value));
self::assertSame($expected, $sut->getValue());
}
public function testChangingTypeAfterSettingValueDoesNotRetroactivelyCastValue(): void
{
$sut = new Configuration('foo');
$sut->setValue('1');
$sut->setType(CheckboxType::class);
self::assertSame('1', $sut->getValue());
}
/**
* @return iterable<string, array{0: string|int|null|bool|float}>
*/
public static function provideScalarValues(): iterable
{
yield 'null' => [null];
yield 'string' => ['hello world'];
yield 'integer' => [123];
yield 'float' => [123.45];
yield 'true' => [true];
yield 'false' => [false];
}
}

View File

@@ -0,0 +1,122 @@
<?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\Form\Model;
use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Form\Model\MultiUserTimesheet;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(MultiUserTimesheet::class)]
class MultiUserTimesheetTest extends TestCase
{
public function testDefaultCollectionsAreEmpty(): void
{
$sut = new MultiUserTimesheet();
self::assertInstanceOf(Timesheet::class, $sut);
self::assertEmpty($sut->getTeams());
self::assertEmpty($sut->getUsers());
}
public function testAddAndRemoveUser(): void
{
$sut = new MultiUserTimesheet();
$user = $this->createUser('alpha');
$sut->addUser($user);
self::assertCount(1, $sut->getUsers());
self::assertSame($user, $sut->getUsers()->first());
$sut->removeUser($user);
self::assertCount(0, $sut->getUsers());
}
public function testRemovingUnknownUserDoesNothing(): void
{
$sut = new MultiUserTimesheet();
$user = $this->createUser('alpha');
$sut->removeUser($user);
self::assertCount(0, $sut->getUsers());
}
public function testRemovingUserOnlyRemovesOneDuplicateEntry(): void
{
$sut = new MultiUserTimesheet();
$user = $this->createUser('alpha');
$sut->addUser($user);
$sut->addUser($user);
self::assertCount(2, $sut->getUsers());
$sut->removeUser($user);
self::assertCount(1, $sut->getUsers());
self::assertSame($user, $sut->getUsers()->first());
}
public function testAddAndRemoveTeam(): void
{
$sut = new MultiUserTimesheet();
$team = new Team('Team Alpha');
$sut->addTeam($team);
self::assertCount(1, $sut->getTeams());
self::assertSame($team, $sut->getTeams()->first());
$sut->removeTeam($team);
self::assertCount(0, $sut->getTeams());
}
public function testRemovingUnknownTeamDoesNothing(): void
{
$sut = new MultiUserTimesheet();
$team = new Team('Team Alpha');
$sut->removeTeam($team);
self::assertCount(0, $sut->getTeams());
}
public function testRemovingTeamOnlyRemovesOneDuplicateEntry(): void
{
$sut = new MultiUserTimesheet();
$team = new Team('Team Alpha');
$sut->addTeam($team);
$sut->addTeam($team);
self::assertCount(2, $sut->getTeams());
$sut->removeTeam($team);
self::assertCount(1, $sut->getTeams());
self::assertSame($team, $sut->getTeams()->first());
}
private function createUser(string $username): User
{
$user = new User();
$user->setUsername($username);
$user->setAlias($username);
$user->setEmail($username . '@example.com');
return $user;
}
}

View File

@@ -14,7 +14,6 @@ use App\Form\Model\SystemConfiguration;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(Configuration::class)]
#[CoversClass(SystemConfiguration::class)]
class SystemConfigurationTest extends TestCase
{

View File

@@ -0,0 +1,34 @@
<?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\Form\Model;
use App\Entity\User;
use App\Form\Model\TotpActivation;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(TotpActivation::class)]
class TotpActivationTest extends TestCase
{
public function testDefaultValues(): void
{
$user = new User();
$sut = new TotpActivation($user);
self::assertSame($user, $sut->getUser());
self::assertNull($sut->getCode());
$sut->setCode('');
self::assertEquals('', $sut->getCode());
$sut->setCode('jztfztfjzfjhgfjhgfjhgfjtzfiuzgbljv');
self::assertEquals('jztfztfjzfjhgfjhgfjhgfjtzfiuzgbljv', $sut->getCode());
$sut->setCode(null);
self::assertNull($sut->getCode());
}
}

View File

@@ -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\Tests\Form\Model;
use App\Entity\User;
use App\Form\Model\UserContractModel;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(UserContractModel::class)]
class UserContractModelTest extends TestCase
{
public function testIssetAlwaysReturnsTrue(): void
{
$user = new User();
$sut = new UserContractModel($user);
self::assertTrue($sut->__isset('alias'));
self::assertTrue($sut->__isset('unknownPreference'));
}
public function testSetAndGetExistingUserPropertyUsesUserMethods(): void
{
$user = new User();
$sut = new UserContractModel($user);
$sut->__set('alias', 'contract-user');
self::assertSame('contract-user', $sut->__get('alias'));
self::assertSame('contract-user', $user->getAlias());
}
public function testSetAndGetExistingPreferenceBackedMethodUsesUserMethod(): void
{
$user = new User();
$sut = new UserContractModel($user);
$sut->__set('workContractMode', 'default');
self::assertSame('default', $sut->__get('workContractMode'));
self::assertSame('default', $user->getWorkContractMode());
}
public function testSetAndGetUnknownPropertyUsesPreferenceFallback(): void
{
$user = new User();
$sut = new UserContractModel($user);
$sut->__set('customContractField', 'weekly');
self::assertSame('weekly', $sut->__get('customContractField'));
self::assertSame('weekly', $user->getPreferenceValue('customContractField'));
}
public function testSetUnknownPropertyAllowsNullPreferenceValue(): void
{
$user = new User();
$sut = new UserContractModel($user);
$sut->__set('customContractField', null);
self::assertNull($sut->__get('customContractField'));
self::assertNull($user->getPreferenceValue('customContractField'));
}
public function testGetUnknownPropertyWithoutPreferenceReturnsNull(): void
{
$sut = new UserContractModel(new User());
self::assertNull($sut->__get('missingPreference'));
}
public function testSetUnknownPropertyRejectsNonScalarValues(): void
{
$sut = new UserContractModel(new User());
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid value passed');
$sut->__set('customContractField', ['invalid']);
}
}

View File

@@ -24,7 +24,7 @@ class SamlAuthenticationSuccessHandlerTest extends TestCase
{
public function testRelayStateWithInvalidHost(): void
{
$handler = new SamlAuthenticationSuccessHandler(new HttpUtils($this->getUrlGenerator()));
$handler = new SamlAuthenticationSuccessHandler(new HttpUtils($this->getUrlGenerator(['homepage' => 'http://example.com/'])));
$response = $handler->onAuthenticationSuccess($this->getRequest('http://example.com/sso/login', 'https://localhost/relayed'), $this->getSamlToken());
self::assertInstanceOf(RedirectResponse::class, $response);
$target = $response->getTargetUrl();
@@ -57,9 +57,9 @@ class SamlAuthenticationSuccessHandlerTest extends TestCase
public function testWithoutRelayState(): void
{
$httpUtils = new HttpUtils($this->getUrlGenerator());
$httpUtils = new HttpUtils($this->getUrlGenerator(['homepage' => '/homepage']));
$handler = new SamlAuthenticationSuccessHandler($httpUtils);
$defaultTargetPath = $httpUtils->generateUri($this->getRequest('/sso/login'), '/');
$defaultTargetPath = $httpUtils->generateUri($this->getRequest('/sso/login'), 'homepage');
$response = $handler->onAuthenticationSuccess($this->getRequest(), $this->getSamlToken());
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertTrue($response->isRedirect($defaultTargetPath));
@@ -75,13 +75,17 @@ class SamlAuthenticationSuccessHandlerTest extends TestCase
self::assertTrue(!$response->isRedirect($loginPath));
}
private function getUrlGenerator(): UrlGeneratorInterface
private function getUrlGenerator(array $rules = []): UrlGeneratorInterface
{
$urlGenerator = $this->getMockBuilder(UrlGeneratorInterface::class)->getMock();
$urlGenerator
->expects($this->any())
->method('generate')
->willReturnCallback(function ($name) {
->willReturnCallback(function ($name) use ($rules) {
if (\array_key_exists($name, $rules)) {
return $rules[$name];
}
return (string) $name;
})
;