Release 2.59 (#5957)
This commit is contained in:
21
.env.dist
21
.env.dist
@@ -1,15 +1,19 @@
|
||||
#
|
||||
# You should NOT use this file in production, but instead move the environment variables to your webserver configuration.
|
||||
# The .env file is only existing to simplify the initial setup!
|
||||
# Do NOT use this file in production, but instead move the environment variables to one of:
|
||||
# - your webserver configuration
|
||||
# - your Docker environment
|
||||
# - the place where you define your application environment variables
|
||||
#
|
||||
# The .env file is existing to simplify the initial (test) setup!
|
||||
#
|
||||
|
||||
#================================================================================
|
||||
# Configure your database connection and set the correct server version.
|
||||
#
|
||||
# You have to replace the following values with your defaults:
|
||||
# - the version "5.7" (the database server version, examples for MySQL and MariaDB below)
|
||||
# - the database username "user"
|
||||
# - the password "password" for "user"
|
||||
# - the version "10.5.8-MariaDB" (the database server version)
|
||||
# - the database "user"
|
||||
# - the database "password"
|
||||
# - the database schema "database"
|
||||
# - you might have to adapt port "3306" and server IP "127.0.0.1" as well
|
||||
#
|
||||
@@ -34,12 +38,12 @@ MAILER_URL=null://null
|
||||
APP_ENV=prod
|
||||
|
||||
#================================================================================
|
||||
# should be changed to a unique character sequence, used for hashing cookies
|
||||
APP_SECRET=change_this_to_something_unique
|
||||
# You MUST change this to a long and random character sequence!
|
||||
APP_SECRET=
|
||||
|
||||
#================================================================================
|
||||
# For security reasons, you SHOULD configure the domain names for your Kimai
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html#trusted-hosts
|
||||
# See https://symfony.com/doc/current/reference/configuration/framework.html#trusted-hosts
|
||||
# TRUSTED_HOSTS=localhost|example.com
|
||||
|
||||
#================================================================================
|
||||
@@ -47,5 +51,4 @@ APP_SECRET=change_this_to_something_unique
|
||||
# TRUSTED_PROXIES=127.0.0.1,127.0.0.2
|
||||
|
||||
#================================================================================
|
||||
# unlikely, that you need to change this one
|
||||
CORS_ALLOW_ORIGIN=^https?://localhost(:[0-9]+)?$
|
||||
|
||||
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1,3 +1,2 @@
|
||||
github: [kevinpapst]
|
||||
open_collective: kimai
|
||||
custom: ["https://www.kimai.org/", "https://www.kimai.cloud/", "https://www.kevinpapst.de/"]
|
||||
|
||||
@@ -255,7 +255,9 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
|
||||
if (hasFieldError || hasFormError || hasFlashError) {
|
||||
this._openFormInModal(html);
|
||||
} else {
|
||||
events.trigger(eventName);
|
||||
if (eventName !== undefined) {
|
||||
events.trigger(eventName);
|
||||
}
|
||||
|
||||
this._isDirty = false;
|
||||
this._getModal().hide();
|
||||
|
||||
374
composer.lock
generated
374
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@ security:
|
||||
lifetime: 604800
|
||||
path: /
|
||||
always_remember_me: true
|
||||
signature_properties: ['email', 'password', 'signatureDate']
|
||||
|
||||
# activate all configured user provider
|
||||
provider: chain_provider
|
||||
@@ -72,11 +73,11 @@ security:
|
||||
|
||||
login_link:
|
||||
check_route: link_login_check
|
||||
# 'password' binds the HMAC signature to the password hash, so a
|
||||
# link becomes invalid as soon as the user changes the password
|
||||
signature_properties: ['id', 'password']
|
||||
lifetime: 900
|
||||
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
|
||||
signature_properties: ['email', 'password', 'signatureDate']
|
||||
|
||||
access_decision_manager:
|
||||
# only grants access if there is no voter denying access
|
||||
@@ -98,7 +99,7 @@ security:
|
||||
- {path: '^/{_locale}/register', role: PUBLIC_ACCESS}
|
||||
- {path: '^/{_locale}/resetting', role: PUBLIC_ACCESS}
|
||||
- {path: '^/{_locale}/', roles: ROLE_USER}
|
||||
- {path: '^/api', roles: IS_AUTHENTICATED}
|
||||
- {path: '^/api', roles: IS_AUTHENTICATED_REMEMBERED}
|
||||
|
||||
when@test:
|
||||
# this configuration simplifies testing URLs protected by the security mechanism
|
||||
|
||||
43
migrations/Version20260530080724.php
Normal file
43
migrations/Version20260530080724.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?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 DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* @version 2.59
|
||||
*/
|
||||
final class Version20260530080724 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds the signature_date column to the user table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE kimai2_users ADD signature_date DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
|
||||
// improve session garbage collection
|
||||
$this->addSql('CREATE INDEX lifetime_idx ON kimai2_sessions (lifetime)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE kimai2_users DROP signature_date');
|
||||
// improve session garbage collection
|
||||
$this->addSql('DROP INDEX lifetime_idx ON kimai2_sessions');
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
10
package.json
10
package.json
@@ -23,10 +23,10 @@
|
||||
"defaults"
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/eslint-parser": "^7.28.6",
|
||||
"@babel/core": "^7.29.7",
|
||||
"@babel/eslint-parser": "^7.29.7",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/preset-env": "^7.29.5",
|
||||
"@babel/preset-env": "^7.29.7",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@fullcalendar/bootstrap5": "^5.11.5",
|
||||
@@ -42,7 +42,7 @@
|
||||
"bootstrap": "^5.3.8",
|
||||
"chart.js": "^4.5.1",
|
||||
"core-js": "^3.49.0",
|
||||
"dompurify": "^3.4.5",
|
||||
"dompurify": "^3.4.7",
|
||||
"eslint": "^9.39.4",
|
||||
"globals": "^15.15.0",
|
||||
"gridstack": "^7.3.0",
|
||||
@@ -52,7 +52,7 @@
|
||||
"sass": "^1.100.0",
|
||||
"sass-loader": "^16.0.8",
|
||||
"tom-select": "2.5.2",
|
||||
"webpack": "^5.107.1",
|
||||
"webpack": "^5.107.2",
|
||||
"webpack-cli": "^6.0.1"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
605
phpstan.neon
605
phpstan.neon
@@ -1917,611 +1917,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/TimesheetPreCreateForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\AbstractToolbarForm\\:\\:addActivityMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/AbstractToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\AbstractToolbarForm\\:\\:addActivitySelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/AbstractToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\AbstractToolbarForm\\:\\:addCustomerMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/AbstractToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\AbstractToolbarForm\\:\\:addCustomerSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/AbstractToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\AbstractToolbarForm\\:\\:addDateRange\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/AbstractToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\AbstractToolbarForm\\:\\:addOrderBy\\(\\) has parameter \\$allowedColumns with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/AbstractToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\AbstractToolbarForm\\:\\:addProjectMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/AbstractToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\AbstractToolbarForm\\:\\:addProjectSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/AbstractToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\AbstractToolbarForm\\:\\:addTeamsChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/AbstractToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\AbstractToolbarForm\\:\\:addUsersChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/AbstractToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of method App\\\\Repository\\\\Query\\\\BaseFormTypeQuery\\:\\:setUser\\(\\) expects App\\\\Entity\\\\User, mixed given\\.$#"
|
||||
count: 2
|
||||
path: src/Form/Toolbar/AbstractToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ActivityToolbarForm\\:\\:addActivityMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ActivityToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ActivityToolbarForm\\:\\:addActivitySelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ActivityToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ActivityToolbarForm\\:\\:addCustomerMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ActivityToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ActivityToolbarForm\\:\\:addCustomerSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ActivityToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ActivityToolbarForm\\:\\:addDateRange\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ActivityToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ActivityToolbarForm\\:\\:addOrderBy\\(\\) has parameter \\$allowedColumns with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ActivityToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ActivityToolbarForm\\:\\:addProjectMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ActivityToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ActivityToolbarForm\\:\\:addProjectSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ActivityToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ActivityToolbarForm\\:\\:addTeamsChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ActivityToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ActivityToolbarForm\\:\\:addUsersChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ActivityToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of method App\\\\Repository\\\\Query\\\\BaseFormTypeQuery\\:\\:setUser\\(\\) expects App\\\\Entity\\\\User, mixed given\\.$#"
|
||||
count: 2
|
||||
path: src/Form/Toolbar/ActivityToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\CustomerToolbarForm\\:\\:addActivityMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/CustomerToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\CustomerToolbarForm\\:\\:addActivitySelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/CustomerToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\CustomerToolbarForm\\:\\:addCustomerMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/CustomerToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\CustomerToolbarForm\\:\\:addCustomerSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/CustomerToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\CustomerToolbarForm\\:\\:addDateRange\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/CustomerToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\CustomerToolbarForm\\:\\:addOrderBy\\(\\) has parameter \\$allowedColumns with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/CustomerToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\CustomerToolbarForm\\:\\:addProjectMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/CustomerToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\CustomerToolbarForm\\:\\:addProjectSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/CustomerToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\CustomerToolbarForm\\:\\:addTeamsChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/CustomerToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\CustomerToolbarForm\\:\\:addUsersChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/CustomerToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of method App\\\\Repository\\\\Query\\\\BaseFormTypeQuery\\:\\:setUser\\(\\) expects App\\\\Entity\\\\User, mixed given\\.$#"
|
||||
count: 2
|
||||
path: src/Form/Toolbar/CustomerToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ExportToolbarForm\\:\\:addActivityMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ExportToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ExportToolbarForm\\:\\:addActivitySelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ExportToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ExportToolbarForm\\:\\:addCustomerMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ExportToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ExportToolbarForm\\:\\:addCustomerSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ExportToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ExportToolbarForm\\:\\:addDateRange\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ExportToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ExportToolbarForm\\:\\:addOrderBy\\(\\) has parameter \\$allowedColumns with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ExportToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ExportToolbarForm\\:\\:addProjectMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ExportToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ExportToolbarForm\\:\\:addProjectSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ExportToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ExportToolbarForm\\:\\:addTeamsChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ExportToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ExportToolbarForm\\:\\:addUsersChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ExportToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of method App\\\\Repository\\\\Query\\\\BaseFormTypeQuery\\:\\:setUser\\(\\) expects App\\\\Entity\\\\User, mixed given\\.$#"
|
||||
count: 2
|
||||
path: src/Form/Toolbar/ExportToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceArchiveForm\\:\\:addActivityMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceArchiveForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceArchiveForm\\:\\:addActivitySelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceArchiveForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceArchiveForm\\:\\:addCustomerMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceArchiveForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceArchiveForm\\:\\:addCustomerSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceArchiveForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceArchiveForm\\:\\:addDateRange\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceArchiveForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceArchiveForm\\:\\:addOrderBy\\(\\) has parameter \\$allowedColumns with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceArchiveForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceArchiveForm\\:\\:addProjectMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceArchiveForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceArchiveForm\\:\\:addProjectSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceArchiveForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceArchiveForm\\:\\:addTeamsChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceArchiveForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceArchiveForm\\:\\:addUsersChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceArchiveForm.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of method App\\\\Repository\\\\Query\\\\BaseFormTypeQuery\\:\\:setUser\\(\\) expects App\\\\Entity\\\\User, mixed given\\.$#"
|
||||
count: 2
|
||||
path: src/Form/Toolbar/InvoiceArchiveForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceToolbarForm\\:\\:addActivityMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceToolbarForm\\:\\:addActivitySelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceToolbarForm\\:\\:addCustomerMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceToolbarForm\\:\\:addCustomerSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceToolbarForm\\:\\:addDateRange\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceToolbarForm\\:\\:addOrderBy\\(\\) has parameter \\$allowedColumns with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceToolbarForm\\:\\:addProjectMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceToolbarForm\\:\\:addProjectSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceToolbarForm\\:\\:addTeamsChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\InvoiceToolbarForm\\:\\:addUsersChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/InvoiceToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of method App\\\\Repository\\\\Query\\\\BaseFormTypeQuery\\:\\:setUser\\(\\) expects App\\\\Entity\\\\User, mixed given\\.$#"
|
||||
count: 2
|
||||
path: src/Form/Toolbar/InvoiceToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ProjectToolbarForm\\:\\:addActivityMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ProjectToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ProjectToolbarForm\\:\\:addActivitySelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ProjectToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ProjectToolbarForm\\:\\:addCustomerMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ProjectToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ProjectToolbarForm\\:\\:addCustomerSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ProjectToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ProjectToolbarForm\\:\\:addDateRange\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ProjectToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ProjectToolbarForm\\:\\:addOrderBy\\(\\) has parameter \\$allowedColumns with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ProjectToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ProjectToolbarForm\\:\\:addProjectMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ProjectToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ProjectToolbarForm\\:\\:addProjectSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ProjectToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ProjectToolbarForm\\:\\:addTeamsChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ProjectToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\ProjectToolbarForm\\:\\:addUsersChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/ProjectToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of method App\\\\Repository\\\\Query\\\\BaseFormTypeQuery\\:\\:setUser\\(\\) expects App\\\\Entity\\\\User, mixed given\\.$#"
|
||||
count: 2
|
||||
path: src/Form/Toolbar/ProjectToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TagToolbarForm\\:\\:addActivityMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TagToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TagToolbarForm\\:\\:addActivitySelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TagToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TagToolbarForm\\:\\:addCustomerMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TagToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TagToolbarForm\\:\\:addCustomerSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TagToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TagToolbarForm\\:\\:addDateRange\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TagToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TagToolbarForm\\:\\:addOrderBy\\(\\) has parameter \\$allowedColumns with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TagToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TagToolbarForm\\:\\:addProjectMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TagToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TagToolbarForm\\:\\:addProjectSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TagToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TagToolbarForm\\:\\:addTeamsChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TagToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TagToolbarForm\\:\\:addUsersChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TagToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of method App\\\\Repository\\\\Query\\\\BaseFormTypeQuery\\:\\:setUser\\(\\) expects App\\\\Entity\\\\User, mixed given\\.$#"
|
||||
count: 2
|
||||
path: src/Form/Toolbar/TagToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TeamToolbarForm\\:\\:addActivityMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TeamToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TeamToolbarForm\\:\\:addActivitySelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TeamToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TeamToolbarForm\\:\\:addCustomerMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TeamToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TeamToolbarForm\\:\\:addCustomerSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TeamToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TeamToolbarForm\\:\\:addDateRange\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TeamToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TeamToolbarForm\\:\\:addOrderBy\\(\\) has parameter \\$allowedColumns with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TeamToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TeamToolbarForm\\:\\:addProjectMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TeamToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TeamToolbarForm\\:\\:addProjectSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TeamToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TeamToolbarForm\\:\\:addTeamsChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TeamToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TeamToolbarForm\\:\\:addUsersChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TeamToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of method App\\\\Repository\\\\Query\\\\BaseFormTypeQuery\\:\\:setUser\\(\\) expects App\\\\Entity\\\\User, mixed given\\.$#"
|
||||
count: 2
|
||||
path: src/Form/Toolbar/TeamToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TimesheetToolbarForm\\:\\:addActivityMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TimesheetToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TimesheetToolbarForm\\:\\:addActivitySelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TimesheetToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TimesheetToolbarForm\\:\\:addCustomerMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TimesheetToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TimesheetToolbarForm\\:\\:addCustomerSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TimesheetToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TimesheetToolbarForm\\:\\:addDateRange\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TimesheetToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TimesheetToolbarForm\\:\\:addOrderBy\\(\\) has parameter \\$allowedColumns with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TimesheetToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TimesheetToolbarForm\\:\\:addProjectMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TimesheetToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TimesheetToolbarForm\\:\\:addProjectSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TimesheetToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TimesheetToolbarForm\\:\\:addTeamsChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TimesheetToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\TimesheetToolbarForm\\:\\:addUsersChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/TimesheetToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of method App\\\\Repository\\\\Query\\\\BaseFormTypeQuery\\:\\:setUser\\(\\) expects App\\\\Entity\\\\User, mixed given\\.$#"
|
||||
count: 2
|
||||
path: src/Form/Toolbar/TimesheetToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\UserToolbarForm\\:\\:addActivityMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/UserToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\UserToolbarForm\\:\\:addActivitySelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/UserToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\UserToolbarForm\\:\\:addCustomerMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/UserToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\UserToolbarForm\\:\\:addCustomerSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/UserToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\UserToolbarForm\\:\\:addDateRange\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/UserToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\UserToolbarForm\\:\\:addOrderBy\\(\\) has parameter \\$allowedColumns with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/UserToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\UserToolbarForm\\:\\:addProjectMultiChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/UserToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\UserToolbarForm\\:\\:addProjectSelect\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/UserToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\UserToolbarForm\\:\\:addTeamsChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/UserToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Toolbar\\\\UserToolbarForm\\:\\:addUsersChoice\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Toolbar/UserToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of method App\\\\Repository\\\\Query\\\\BaseFormTypeQuery\\:\\:setUser\\(\\) expects App\\\\Entity\\\\User, mixed given\\.$#"
|
||||
count: 2
|
||||
path: src/Form/Toolbar/UserToolbarForm.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Type\\\\ActivityType\\:\\:groupBy\\(\\) has parameter \\$index with no type specified\\.$#"
|
||||
count: 1
|
||||
|
||||
1370
pnpm-lock.yaml
generated
1370
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[113],{9599:function(i,n,u){u(5510)},5510:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=9599,i(i.s=n)}]);
|
||||
1
public/build/app-rtl.d60af4d3.js
Normal file
1
public/build/app-rtl.d60af4d3.js
Normal file
@@ -0,0 +1 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[113],{9851:function(i,n,u){u(31)},31:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=9851,i(i.s=n)}]);
|
||||
File diff suppressed because one or more lines are too long
2
public/build/app.b37ecf21.js
Normal file
2
public/build/app.b37ecf21.js
Normal file
File diff suppressed because one or more lines are too long
@@ -176,4 +176,4 @@
|
||||
* [KIMAI] Wrapper class for loading Kimai app in browser script scope
|
||||
*/
|
||||
|
||||
/*! @license DOMPurify 3.4.5 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.5/LICENSE */
|
||||
/*! @license DOMPurify 3.4.7 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.7/LICENSE */
|
||||
File diff suppressed because one or more lines are too long
2
public/build/calendar.7f100f67.js
Normal file
2
public/build/calendar.7f100f67.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/app.4f8430f7.js"
|
||||
"/build/app.b37ecf21.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.49955ea2.css"
|
||||
@@ -12,7 +12,7 @@
|
||||
"app-rtl": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/app-rtl.88289026.js"
|
||||
"/build/app-rtl.d60af4d3.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app-rtl.e8e3029e.css"
|
||||
@@ -21,7 +21,7 @@
|
||||
"export-pdf": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/export-pdf.b060a67d.js"
|
||||
"/build/export-pdf.ae450b6f.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/export-pdf.d8a6c23b.css"
|
||||
@@ -30,7 +30,7 @@
|
||||
"invoice": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/invoice.f221c649.js"
|
||||
"/build/invoice.a24e99c2.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/invoice.36018785.css"
|
||||
@@ -39,7 +39,7 @@
|
||||
"invoice-pdf": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/invoice-pdf.fa283f9c.js"
|
||||
"/build/invoice-pdf.455a623b.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/invoice-pdf.2b749265.css"
|
||||
@@ -48,13 +48,13 @@
|
||||
"chart": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/chart.14220427.js"
|
||||
"/build/chart.b21da607.js"
|
||||
]
|
||||
},
|
||||
"calendar": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/calendar.2ffa0172.js"
|
||||
"/build/calendar.7f100f67.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/calendar.287b9d06.css"
|
||||
@@ -63,7 +63,7 @@
|
||||
"dashboard": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/dashboard.33f69438.js"
|
||||
"/build/dashboard.3485b30a.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/dashboard.b7129fa1.css"
|
||||
@@ -72,7 +72,7 @@
|
||||
"highlight": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/highlight.6a607cc4.js"
|
||||
"/build/highlight.60f96e8e.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/highlight.98bf3927.css"
|
||||
@@ -81,22 +81,22 @@
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.684e9f6d.js": "sha384-suKiEX2de4fdNqQzdYbUd6osp4AepD9FiMXl+1QdvgMW9dcQqUWQNQasf3KWzwLr",
|
||||
"/build/app.4f8430f7.js": "sha384-X029h9FgtC24+GvFSqlEVnKRrumwmpyWaD8nkFZlIYfDoV3/43Gnj5A4s88GopVD",
|
||||
"/build/app.b37ecf21.js": "sha384-1SEhDk1nV9XUef26cxxcMHWSS5vurCrlUK++QcwddqJ5dYBt9VR/vUUtFVd67mk1",
|
||||
"/build/app.49955ea2.css": "sha384-8ix/CKnR2d1mU3tMEFJPrOOPtQA2tdzHugEJMnODX+7my6dFwlworNdfZwrwuvz5",
|
||||
"/build/app-rtl.88289026.js": "sha384-gOohic29TurHJC5g2lJbDPbhnbrpZv8dCzS2QrUOHToldu1kIhhV+nwIhrIcvh2M",
|
||||
"/build/app-rtl.d60af4d3.js": "sha384-6FJJeI9vhVlvR3aai1llZp662mZG3Fqv7a/6XZWSmxJaNOrFBUBoFK+DvV2YfCkC",
|
||||
"/build/app-rtl.e8e3029e.css": "sha384-21kGyBRbajbE/gt4g8ej+clQSGAvYx1BMmiUMRTAE3ZVpMb5bvO/kVRcdKuV97J5",
|
||||
"/build/export-pdf.b060a67d.js": "sha384-Yhkr6LgU58/93pG8yLmlzBUAzvd7mK7Gdc2U6JbFOPt8wU/VNa+XJp5Dmbh1aJkM",
|
||||
"/build/export-pdf.ae450b6f.js": "sha384-2Tqs5I8CSBIqUe6gFQUahHICyuyeEUUmYznouJFIRJYI6rod1P6KMm1XR4cQDFbE",
|
||||
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
|
||||
"/build/invoice.f221c649.js": "sha384-YcLo/XsWx8SlTy8Pr+2yahxJemh08qdhN4sVuWQGQ8f4O3fk02obLUHRaWDYV2Fl",
|
||||
"/build/invoice.a24e99c2.js": "sha384-pMdWqm7KyH9q4yLvN7HjJT934ZTNEEiXxzYoodR+vU+PsThV7kuLZl/o3ayGp2+r",
|
||||
"/build/invoice.36018785.css": "sha384-jukM9uZ6pexDxXKgZThSxiqXimzsxzniBMHz08N9x8ryXZYkM5r/ZgaainCV0+J6",
|
||||
"/build/invoice-pdf.fa283f9c.js": "sha384-2s+cusNcE7spSvrkZI92zdwRigdFszvuu9lTQcmw+c1nO3LelF4YCqKnGyvUOSRr",
|
||||
"/build/invoice-pdf.455a623b.js": "sha384-UnAhvPe1DZsI9ZZx3vLqz0DSX9UKoAGqQ9HQAUQo/zhyo8CrgUre+C9lgIKUG2dj",
|
||||
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
|
||||
"/build/chart.14220427.js": "sha384-VsC6lrd9uQcpTz5uCDRw7GdzxnYRcUfO9dQLVAtP5hz+7pKJm0/yvUkgKuTh6DkM",
|
||||
"/build/calendar.2ffa0172.js": "sha384-vmPnbhkvU1mpQ6XFYyrKK4ZAm6xrNEliD1g4X9WhmoWV/BtrHgmdQiVLjHiqhWeF",
|
||||
"/build/chart.b21da607.js": "sha384-/fFG+fPuAmuBnuLQBn7uC/3lT9zqKBSj6n3R6+8BIviR6DMYrWRWpyh1/kN7A6zf",
|
||||
"/build/calendar.7f100f67.js": "sha384-1bKtpSG8lTeDzKLavFZNey/ZuINRPQiBJF1HB6halbmDIagXIFTkFVMf0UeOGfWH",
|
||||
"/build/calendar.287b9d06.css": "sha384-U1MhUddqhzynvw0SfqX+rVSrWakw85c2KAJgM+F3EtQ7b7rAJzBvakX0Apwy7Vu0",
|
||||
"/build/dashboard.33f69438.js": "sha384-w9MGJT/Qasi0qd2XS0jo+ON09ofvbaZ1wf+rRzoiEIqc0CksO91rN0g1dCpNJTds",
|
||||
"/build/dashboard.3485b30a.js": "sha384-r0WM8JWtoPAYbyYqO7lUaGIRqt+ajMHjXotKjRQOnM642kEg+3yO5WeiNLBI9+AD",
|
||||
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
|
||||
"/build/highlight.6a607cc4.js": "sha384-cdJx43cIcyTx4HT6yOK/dIafuPgfIE9D1L8EmA3jkXu4YQBv5RXxC7eSBXSJelvK",
|
||||
"/build/highlight.60f96e8e.js": "sha384-njFG2WH6c7KuZ6QWzqthZm3QlkQNW/CNKQihJegRktD/8Y/g93mNXKHaGZ0x6AHC",
|
||||
"/build/highlight.98bf3927.css": "sha384-YgweSwDwN0dI4DEmh478xYVw/TewJYvCO1QTbWHpaHFDPuLrZvYMR0Tc+QfFxVPE"
|
||||
}
|
||||
}
|
||||
1
public/build/export-pdf.ae450b6f.js
Normal file
1
public/build/export-pdf.ae450b6f.js
Normal file
@@ -0,0 +1 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[872],{3678:function(i,n,u){u(9804)},9804:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=3678,i(i.s=n)}]);
|
||||
@@ -1 +0,0 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[872],{8642:function(i,n,u){u(6175)},6175:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=8642,i(i.s=n)}]);
|
||||
File diff suppressed because one or more lines are too long
1
public/build/invoice-pdf.455a623b.js
Normal file
1
public/build/invoice-pdf.455a623b.js
Normal file
@@ -0,0 +1 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[117],{899:function(i,n,u){u(6091)},6091:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=899,i(i.s=n)}]);
|
||||
@@ -1 +0,0 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[117],{999:function(i,n,u){u(2254)},2254:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=999,i(i.s=n)}]);
|
||||
1
public/build/invoice.a24e99c2.js
Normal file
1
public/build/invoice.a24e99c2.js
Normal file
@@ -0,0 +1 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[896],{2836:function(i,n,u){u(7180)},7180:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=2836,i(i.s=n)}]);
|
||||
@@ -1 +0,0 @@
|
||||
(self.webpackChunkkimai=self.webpackChunkkimai||[]).push([[896],{976:function(i,n,u){u(5197)},5197:function(i,n,u){"use strict";u.r(n)}},function(i){var n;n=976,i(i.s=n)}]);
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"build/app.css": "/build/app.49955ea2.css",
|
||||
"build/app.js": "/build/app.4f8430f7.js",
|
||||
"build/app.js": "/build/app.b37ecf21.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.e8e3029e.css",
|
||||
"build/app-rtl.js": "/build/app-rtl.88289026.js",
|
||||
"build/app-rtl.js": "/build/app-rtl.d60af4d3.js",
|
||||
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
|
||||
"build/export-pdf.js": "/build/export-pdf.b060a67d.js",
|
||||
"build/export-pdf.js": "/build/export-pdf.ae450b6f.js",
|
||||
"build/invoice.css": "/build/invoice.36018785.css",
|
||||
"build/invoice.js": "/build/invoice.f221c649.js",
|
||||
"build/invoice.js": "/build/invoice.a24e99c2.js",
|
||||
"build/invoice-pdf.css": "/build/invoice-pdf.2b749265.css",
|
||||
"build/invoice-pdf.js": "/build/invoice-pdf.fa283f9c.js",
|
||||
"build/chart.js": "/build/chart.14220427.js",
|
||||
"build/invoice-pdf.js": "/build/invoice-pdf.455a623b.js",
|
||||
"build/chart.js": "/build/chart.b21da607.js",
|
||||
"build/calendar.css": "/build/calendar.287b9d06.css",
|
||||
"build/calendar.js": "/build/calendar.2ffa0172.js",
|
||||
"build/calendar.js": "/build/calendar.7f100f67.js",
|
||||
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
|
||||
"build/dashboard.js": "/build/dashboard.33f69438.js",
|
||||
"build/dashboard.js": "/build/dashboard.3485b30a.js",
|
||||
"build/highlight.css": "/build/highlight.98bf3927.css",
|
||||
"build/highlight.js": "/build/highlight.6a607cc4.js",
|
||||
"build/highlight.js": "/build/highlight.60f96e8e.js",
|
||||
"build/runtime.js": "/build/runtime.684e9f6d.js",
|
||||
"build/fonts/fa-solid-900.ttf": "/build/fonts/fa-solid-900.2582b0e4.ttf",
|
||||
"build/fonts/fa-brands-400.ttf": "/build/fonts/fa-brands-400.1815e004.ttf",
|
||||
|
||||
@@ -31,6 +31,8 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
*/
|
||||
class ActivityService
|
||||
{
|
||||
private int $generatedNumbers = 0;
|
||||
|
||||
public function __construct(
|
||||
private readonly ActivityRepository $repository,
|
||||
private readonly SystemConfiguration $configuration,
|
||||
@@ -138,7 +140,8 @@ class ActivityService
|
||||
}
|
||||
|
||||
// we cannot use max(number) because a varchar column returns unexpected results
|
||||
$start = $this->repository->countActivity();
|
||||
$count = $this->repository->countActivity();
|
||||
$start = $count + $this->generatedNumbers;
|
||||
$i = 0;
|
||||
$createDate = new \DateTimeImmutable();
|
||||
|
||||
@@ -170,6 +173,10 @@ class ActivityService
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remember how far we advanced — including iterations spent skipping numbers that
|
||||
// already exist — so the next call on this instance starts beyond the issued number.
|
||||
$this->generatedNumbers = $start - $count;
|
||||
|
||||
return $number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ final class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.58.0';
|
||||
public const VERSION = '2.59.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 25800;
|
||||
public const VERSION_ID = 25900;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -102,7 +102,7 @@ final class DoctorController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enabled: bool, status: false|array<mixed>}
|
||||
* @return array{unknown: bool, enabled: bool, status: false|array<mixed>}
|
||||
*/
|
||||
private function getOpcacheConfiguration(): array
|
||||
{
|
||||
@@ -111,7 +111,7 @@ final class DoctorController extends AbstractController
|
||||
|
||||
$enabled = \is_array($status) && $status['opcache_enabled'];
|
||||
|
||||
if ($enabled && \array_key_exists('scripts', $status)) {
|
||||
if ($enabled && \is_array($status) && \array_key_exists('scripts', $status)) {
|
||||
unset($status['scripts']);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||
use App\Form\InvoiceDocumentUploadForm;
|
||||
use App\Form\InvoiceEditForm;
|
||||
use App\Form\InvoiceTemplateForm;
|
||||
use App\Form\Toolbar\InvoiceArchiveForm;
|
||||
use App\Form\Toolbar\InvoiceArchiveToolbarForm;
|
||||
use App\Form\Toolbar\InvoiceToolbarForm;
|
||||
use App\Form\Type\DatePickerType;
|
||||
use App\Form\Type\InvoiceTemplateType;
|
||||
@@ -753,7 +753,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
private function getArchiveToolbarForm(InvoiceArchiveQuery $query): FormInterface
|
||||
{
|
||||
return $this->createSearchForm(InvoiceArchiveForm::class, $query, [
|
||||
return $this->createSearchForm(InvoiceArchiveToolbarForm::class, $query, [
|
||||
'action' => $this->generateUrl('admin_invoice_list', []),
|
||||
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
|
||||
'attr' => [
|
||||
|
||||
@@ -28,6 +28,15 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* This is the anonymous "password forgotten" flow.
|
||||
* The flow itself can be deactivated via a system-configuration.
|
||||
*
|
||||
* When a user enters his username or email:
|
||||
* - a login link will be generated
|
||||
* - the user will be flagged as "a new password is required during next login"
|
||||
* - the login link will be sent to the user via email
|
||||
*/
|
||||
#[Route(path: '/resetting')]
|
||||
final class PasswordResetController extends AbstractController
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Form\Type\UserLanguageType;
|
||||
use App\Form\Type\UserLocaleType;
|
||||
use App\Form\UserPasswordType;
|
||||
use App\User\UserService;
|
||||
use App\Wizard\WizardManager;
|
||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -27,112 +28,156 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
final class WizardController extends AbstractController
|
||||
{
|
||||
#[Route(path: '/{wizard}', name: 'wizard', methods: ['GET', 'POST'])]
|
||||
public function wizard(Request $request, UserService $userService, string $wizard): Response
|
||||
/**
|
||||
* Virtual "forward" route. Redirects to the first step the user has not
|
||||
* seen yet, or to the finish page if all steps are done.
|
||||
*
|
||||
* The WizardSubscriber already intercepts this route on kernel.request,
|
||||
* but the action is kept as a defensive fallback (the subscriber returns
|
||||
* early when all steps are seen).
|
||||
*/
|
||||
#[Route(path: '/next/', name: 'wizard_next', methods: ['GET'])]
|
||||
public function next(WizardManager $wizardManager): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$step = $wizardManager->getFirstUnseenStep($user);
|
||||
|
||||
if ($step !== null) {
|
||||
return $this->redirectToRoute($step->route);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('wizard_finish');
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual "backward" route. The caller passes its own step id as
|
||||
* {@code from} so that the previous step can be resolved without the
|
||||
* caller needing to know who comes before it.
|
||||
*/
|
||||
#[Route(path: '/previous/{from}', name: 'wizard_previous', requirements: ['from' => '[a-zA-Z0-9_-]+'], methods: ['GET'])]
|
||||
public function previous(string $from, WizardManager $wizardManager): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$step = $wizardManager->getPreviousStep($user, $from);
|
||||
|
||||
if ($step !== null) {
|
||||
return $this->redirectToRoute($step->route);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('wizard_intro');
|
||||
}
|
||||
|
||||
#[Route(path: '/intro', name: 'wizard_intro', methods: ['GET'])]
|
||||
public function intro(UserService $userService, WizardManager $wizardManager): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
if ($wizard === 'intro') {
|
||||
$user->setWizardAsSeen('intro');
|
||||
$user->setWizardAsSeen('intro');
|
||||
$userService->saveUser($user);
|
||||
|
||||
return $this->render(
|
||||
'wizard/intro.html.twig',
|
||||
$wizardManager->getNavigation($user, 'intro')
|
||||
);
|
||||
}
|
||||
|
||||
#[Route(path: '/profile', name: 'wizard_profile', methods: ['GET', 'POST'])]
|
||||
public function profile(Request $request, UserService $userService, WizardManager $wizardManager): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
$data = [
|
||||
UserPreference::LANGUAGE => $user->getPreferenceValue(UserPreference::LANGUAGE, $request->getLocale(), false),
|
||||
UserPreference::LOCALE => $user->getPreferenceValue(UserPreference::LOCALE, $request->getLocale(), false),
|
||||
UserPreference::TIMEZONE => $user->getTimezone(),
|
||||
UserPreference::SKIN => $user->getSkin(),
|
||||
'reload' => '0',
|
||||
];
|
||||
|
||||
$form = $this->createFormBuilder($data)
|
||||
->add(UserPreference::LANGUAGE, UserLanguageType::class)
|
||||
->add(UserPreference::LOCALE, UserLocaleType::class, ['help' => null])
|
||||
->add(UserPreference::TIMEZONE, TimezoneType::class)
|
||||
->add(UserPreference::SKIN, SkinType::class)
|
||||
->add('reload', HiddenType::class)
|
||||
->setAction($this->generateUrl('wizard_profile'))
|
||||
->setMethod('POST')
|
||||
->getForm();
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
/** @var array<string, string> $data */
|
||||
$data = $form->getData();
|
||||
$user->setLanguage($data[UserPreference::LANGUAGE]);
|
||||
$user->setLocale($data[UserPreference::LOCALE]);
|
||||
$user->setTimezone($data[UserPreference::TIMEZONE]);
|
||||
$user->setPreferenceValue(UserPreference::SKIN, $data[UserPreference::SKIN]);
|
||||
$user->setWizardAsSeen('profile');
|
||||
$userService->saveUser($user);
|
||||
|
||||
return $this->render('wizard/intro.html.twig', [
|
||||
'percent' => 0,
|
||||
'next' => 'profile',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($wizard === 'profile') {
|
||||
$data = [
|
||||
UserPreference::LANGUAGE => $user->getPreferenceValue(UserPreference::LANGUAGE, $request->getLocale(), false),
|
||||
UserPreference::LOCALE => $user->getPreferenceValue(UserPreference::LOCALE, $request->getLocale(), false),
|
||||
UserPreference::TIMEZONE => $user->getTimezone(),
|
||||
UserPreference::SKIN => $user->getSkin(),
|
||||
'reload' => '0',
|
||||
];
|
||||
|
||||
$form = $this->createFormBuilder($data)
|
||||
->add(UserPreference::LANGUAGE, UserLanguageType::class)
|
||||
->add(UserPreference::LOCALE, UserLocaleType::class, ['help' => null])
|
||||
->add(UserPreference::TIMEZONE, TimezoneType::class)
|
||||
->add(UserPreference::SKIN, SkinType::class)
|
||||
->add('reload', HiddenType::class)
|
||||
->setAction($this->generateUrl('wizard', ['wizard' => 'profile']))
|
||||
->setMethod('POST')
|
||||
->getForm();
|
||||
|
||||
$next = 'done';
|
||||
if ($user->requiresPasswordReset()) {
|
||||
$next = 'password';
|
||||
if ($data['reload'] === '1') {
|
||||
return $this->redirectToRoute('wizard_profile', ['_locale' => $user->getLanguage()]);
|
||||
}
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
/** @var array<string, string> $data */
|
||||
$data = $form->getData();
|
||||
$user->setLanguage($data[UserPreference::LANGUAGE]);
|
||||
$user->setLocale($data[UserPreference::LOCALE]);
|
||||
$user->setTimezone($data[UserPreference::TIMEZONE]);
|
||||
$user->setPreferenceValue(UserPreference::SKIN, $data[UserPreference::SKIN]);
|
||||
$user->setWizardAsSeen('profile');
|
||||
$userService->saveUser($user);
|
||||
|
||||
if ($data['reload'] === '1') {
|
||||
return $this->redirectToRoute('wizard', ['wizard' => 'profile', '_locale' => $user->getLanguage()]);
|
||||
} else {
|
||||
return $this->redirectToRoute('wizard', ['wizard' => $next, '_locale' => $user->getLanguage()]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('wizard/profile.html.twig', [
|
||||
'percent' => \intval(100 / \count(User::WIZARDS) * 1),
|
||||
'previous' => 'intro',
|
||||
'next' => $next,
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
// Delegate "what comes next" to the WizardManager so optional steps
|
||||
// (e.g. the password step, or plugin-contributed steps) are picked
|
||||
// up automatically.
|
||||
return $this->redirectToRoute('wizard_next', ['_locale' => $user->getLanguage()]);
|
||||
}
|
||||
|
||||
if ($wizard === 'password' || $user->requiresPasswordReset()) {
|
||||
$form = $this->createForm(UserPasswordType::class, $user, [
|
||||
'action' => $this->generateUrl('wizard', ['wizard' => 'password']),
|
||||
'method' => 'POST',
|
||||
]);
|
||||
return $this->render(
|
||||
'wizard/profile.html.twig',
|
||||
array_merge(
|
||||
['form' => $form->createView()],
|
||||
$wizardManager->getNavigation($user, 'profile')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$form->handleRequest($request);
|
||||
#[Route(path: '/password', name: 'wizard_password', methods: ['GET', 'POST'])]
|
||||
public function password(Request $request, UserService $userService, WizardManager $wizardManager): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$user->setRequiresPasswordReset(false);
|
||||
$userService->saveUser($user);
|
||||
$form = $this->createForm(UserPasswordType::class, $user, [
|
||||
'action' => $this->generateUrl('wizard_password'),
|
||||
'method' => 'POST',
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('wizard', ['wizard' => 'done']);
|
||||
}
|
||||
$form->handleRequest($request);
|
||||
|
||||
$previous = 'profile';
|
||||
$percent = \intval(100 / \count(User::WIZARDS) * 1);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$user->setRequiresPasswordReset(false);
|
||||
// do not set wizard as seen, as password reset utilizes the wizard framework but lives outside its flow
|
||||
$userService->saveUser($user);
|
||||
|
||||
if ($user->requiresPasswordReset()) {
|
||||
$previous = null;
|
||||
$percent = null;
|
||||
}
|
||||
|
||||
return $this->render('wizard/password.html.twig', [
|
||||
'percent' => $percent,
|
||||
'previous' => $previous,
|
||||
'next' => 'done',
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
return $this->redirectToRoute('wizard_next');
|
||||
}
|
||||
|
||||
// this is a virtual step that is not registered as wizard, but instead should be shown every time
|
||||
// a new wizard is introduced: so we do not register it as "seen"
|
||||
if ($wizard === 'done') {
|
||||
return $this->render('wizard/done.html.twig', [
|
||||
'percent' => 100,
|
||||
'previous' => 'profile',
|
||||
]);
|
||||
// this has no wizard navigation, as it lives outside the normal wizard flow
|
||||
return $this->render('wizard/password.html.twig', ['form' => $form->createView()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual finish step. Not registered as a wizard step, so it is shown
|
||||
* every time a new wizard step is introduced and never marked as "seen".
|
||||
*/
|
||||
#[Route(path: '/finish', name: 'wizard_finish', methods: ['GET'])]
|
||||
public function finish(WizardManager $wizardManager): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
// Previous link points back to the last registered step in the sequence.
|
||||
$previousStep = null;
|
||||
foreach ($wizardManager->getSteps($user) as $step) {
|
||||
$previousStep = $step;
|
||||
}
|
||||
|
||||
throw $this->createNotFoundException('Unknown wizard');
|
||||
return $this->render('wizard/done.html.twig', [
|
||||
'previous' => $previousStep?->route,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
final class CustomerService
|
||||
{
|
||||
private int $generatedNumbers = 0;
|
||||
|
||||
public function __construct(
|
||||
private readonly CustomerRepository $repository,
|
||||
private readonly SystemConfiguration $configuration,
|
||||
@@ -155,7 +157,8 @@ final class CustomerService
|
||||
}
|
||||
|
||||
// we cannot use max(number) because a varchar column returns unexpected results
|
||||
$start = $this->repository->countCustomer();
|
||||
$count = $this->repository->countCustomer();
|
||||
$start = $count + $this->generatedNumbers;
|
||||
$i = 0;
|
||||
$createDate = new \DateTimeImmutable();
|
||||
|
||||
@@ -187,6 +190,10 @@ final class CustomerService
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remember how far we advanced — including iterations spent skipping numbers that
|
||||
// already exist — so the next call on this instance starts beyond the issued number.
|
||||
$this->generatedNumbers = $start - $count;
|
||||
|
||||
return $number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,8 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
#[Serializer\Groups(['User_Entity'])]
|
||||
#[OA\Property(ref: '#/components/schemas/User')]
|
||||
private ?User $supervisor = null;
|
||||
#[ORM\Column(name: 'signature_date', type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $signatureDate = null;
|
||||
|
||||
use ColorTrait;
|
||||
|
||||
@@ -699,6 +701,21 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return $this->getRoles() === [static::DEFAULT_ROLE];
|
||||
}
|
||||
|
||||
public function getSignatureDate(): string
|
||||
{
|
||||
return $this->signatureDate?->format(\DateTimeInterface::ATOM) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* This will reset all security signatures and therefor invalidate:
|
||||
* - login links
|
||||
* - remember me cookies
|
||||
*/
|
||||
public function resetSecuritySignature(): void
|
||||
{
|
||||
$this->signatureDate = new \DateTimeImmutable('now', new \DateTimeZone($this->getTimezone()));
|
||||
}
|
||||
|
||||
/**
|
||||
* List of all teams, this user is part of
|
||||
*
|
||||
|
||||
123
src/Event/WizardEvent.php
Normal file
123
src/Event/WizardEvent.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?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\Event;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Wizard\WizardStep;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
/**
|
||||
* Dispatched once per request whenever Kimai needs to know which wizard
|
||||
* steps exist for a given user. Listeners (Kimai core and plugins) add
|
||||
* their steps via {@see self::addStep()} and may inspect the user to
|
||||
* decide whether their step applies (e.g. only when a password reset
|
||||
* is required).
|
||||
*
|
||||
* Steps do not need to know about each other — order and navigation are
|
||||
* resolved by {@see \App\Wizard\WizardManager} based on the {@see WizardStep::$order}
|
||||
* value.
|
||||
*/
|
||||
final class WizardEvent extends Event
|
||||
{
|
||||
/**
|
||||
* @var array<string, WizardStep>
|
||||
*/
|
||||
private array $steps = [];
|
||||
|
||||
public function __construct(private readonly User $user)
|
||||
{
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a wizard step. If a step with the same id was already
|
||||
* registered, it is replaced — listeners with a higher priority win.
|
||||
*/
|
||||
public function addStep(WizardStep $step): void
|
||||
{
|
||||
$this->steps[$step->id] = $step;
|
||||
}
|
||||
|
||||
public function hasStep(string $id): bool
|
||||
{
|
||||
return \array_key_exists($id, $this->steps);
|
||||
}
|
||||
|
||||
public function getStep(string $id): ?WizardStep
|
||||
{
|
||||
return $this->steps[$id] ?? null;
|
||||
}
|
||||
|
||||
public function removeStep(string $id): void
|
||||
{
|
||||
if (\array_key_exists($id, $this->steps)) {
|
||||
unset($this->steps[$id]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return WizardStep[] steps sorted by their order (ascending)
|
||||
*/
|
||||
public function getSteps(): array
|
||||
{
|
||||
$steps = array_values($this->steps);
|
||||
usort($steps, static fn (WizardStep $a, WizardStep $b): int => $a->order <=> $b->order);
|
||||
|
||||
return $steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper around {@see self::addStep()} that creates a
|
||||
* {@see WizardStep} from an id and route, appending it to the end of
|
||||
* the current step list.
|
||||
*/
|
||||
public function addWizard(string $wizard, string $route): void
|
||||
{
|
||||
$this->addStep(new WizardStep($wizard, $route, (\count($this->steps) + 1) * 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> map of step id => route name, in order
|
||||
*/
|
||||
public function getWizards(): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($this->getSteps() as $step) {
|
||||
$result[$step->id] = $step->route;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the route name of the step that follows $wizard in the
|
||||
* configured order, or the fallback route 'wizard_finish' if $wizard
|
||||
* is unknown or is the last step.
|
||||
*/
|
||||
public function getNextWizard(string $wizard): string
|
||||
{
|
||||
$steps = $this->getSteps();
|
||||
$found = false;
|
||||
foreach ($steps as $step) {
|
||||
if ($found) {
|
||||
return $step->route;
|
||||
}
|
||||
if ($step->id === $wizard) {
|
||||
$found = true;
|
||||
}
|
||||
}
|
||||
|
||||
return 'wizard_finish';
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ class PasswordResetSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => 'password']));
|
||||
$response = new RedirectResponse($this->urlGenerator->generate('wizard_password'));
|
||||
$event->setResponse($response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\EventSubscriber;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\User;
|
||||
use App\Wizard\WizardManager;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
@@ -19,13 +20,17 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Responsible for displaying the correct wizard
|
||||
*/
|
||||
class WizardSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly AuthorizationCheckerInterface $security,
|
||||
private readonly TokenStorageInterface $storage,
|
||||
private readonly SystemConfiguration $systemConfiguration
|
||||
private readonly SystemConfiguration $systemConfiguration,
|
||||
private readonly WizardManager $wizardManager,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -45,9 +50,20 @@ class WizardSubscriber implements EventSubscriberInterface
|
||||
|
||||
$uri = $event->getRequest()->getRequestUri();
|
||||
|
||||
// never trigger wizard on API calls
|
||||
// TODO 3.0 remove /register/
|
||||
if (str_starts_with($uri, '/api/') || stripos($uri, '/register/') !== false || stripos($uri, '/wizard/') !== false) {
|
||||
if (stripos($uri, '/register/') !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// never trigger wizard on API calls
|
||||
if (str_starts_with($uri, '/api/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// never trigger on wizard routes themselves — except the virtual /next/
|
||||
// route, which intentionally re-enters this subscriber so that the user
|
||||
// is redirected to the first step they have not seen yet.
|
||||
if (stripos($uri, '/wizard/') !== false && stripos($uri, '/wizard/next/') === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,13 +81,15 @@ class WizardSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (User::WIZARDS as $wizard) {
|
||||
if (!$user->hasSeenWizard($wizard)) {
|
||||
$response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => $wizard]));
|
||||
$event->setResponse($response);
|
||||
$step = $this->wizardManager->getFirstUnseenStep($user);
|
||||
|
||||
return;
|
||||
}
|
||||
if ($step === null) {
|
||||
// All registered steps have been seen — fall through to the regular
|
||||
// application response. If we were intercepting /wizard/next/, the
|
||||
// controller will redirect to wizard_finish.
|
||||
return;
|
||||
}
|
||||
|
||||
$event->setResponse(new RedirectResponse($this->urlGenerator->generate($step->route)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,13 +183,13 @@ final class ColumnConverter
|
||||
$columns[$column] = (new Column('duration', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'break') {
|
||||
// TODO remove method_exists with 3.0
|
||||
$columns[$column] = (new Column('break', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
|
||||
$columns[$column] = (new Column('break', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'break_decimal') {
|
||||
// TODO remove method_exists with 3.0
|
||||
$columns[$column] = (new Column('break', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
|
||||
$columns[$column] = (new Column('break', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'break_seconds') {
|
||||
// TODO remove method_exists with 3.0
|
||||
$columns[$column] = (new Column('break', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
|
||||
$columns[$column] = (new Column('break', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'currency' && $showRates) {
|
||||
$columns[$column] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'rate' && $showRates) {
|
||||
|
||||
@@ -19,16 +19,20 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
* Defines the form used for filtering timesheet entries for invoices.
|
||||
* @extends AbstractType<InvoiceArchiveQuery>
|
||||
*/
|
||||
final class InvoiceArchiveForm extends AbstractType
|
||||
final class InvoiceArchiveToolbarForm extends AbstractType
|
||||
{
|
||||
use ToolbarFormTrait;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$this->addSearchTermInputField($builder);
|
||||
$this->addDateRange($builder, ['timezone' => $options['timezone']]);
|
||||
$this->addCustomerMultiChoice($builder, ['required' => false, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => ''], true);
|
||||
$builder->add('status', InvoiceStatusType::class, ['required' => false]);
|
||||
$this->addUsersChoice($builder);
|
||||
$this->addPageSizeChoice($builder);
|
||||
$this->addHiddenPagination($builder);
|
||||
$this->addOrder($builder);
|
||||
@@ -12,6 +12,7 @@ namespace App\Form\Toolbar;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\User;
|
||||
use App\Form\Type\ActivityType;
|
||||
use App\Form\Type\BillableSearchType;
|
||||
use App\Form\Type\CustomerType;
|
||||
@@ -45,6 +46,9 @@ use Symfony\Component\Form\FormEvents;
|
||||
*/
|
||||
trait ToolbarFormTrait
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
protected function addUsersChoice(FormBuilderInterface $builder, string $field = 'users', array $options = []): void
|
||||
{
|
||||
$builder->add($field, UserType::class, array_merge([
|
||||
@@ -59,6 +63,9 @@ trait ToolbarFormTrait
|
||||
], $options));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
protected function addTeamsChoice(FormBuilderInterface $builder, string $field = 'teams', array $options = []): void
|
||||
{
|
||||
$builder->add($field, TeamType::class, array_merge([
|
||||
@@ -73,11 +80,17 @@ trait ToolbarFormTrait
|
||||
], $options));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
protected function addCustomerMultiChoice(FormBuilderInterface $builder, array $options = [], bool $multiProject = false): void
|
||||
{
|
||||
$this->addCustomerSelect($builder, $options, true, $multiProject);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function addCustomerSelect(FormBuilderInterface $builder, array $options, bool $multiCustomer, bool $multiProject): void
|
||||
{
|
||||
$name = 'customer';
|
||||
@@ -110,7 +123,10 @@ trait ToolbarFormTrait
|
||||
'start_date_param' => '%daterange%',
|
||||
'query_builder' => function (CustomerRepository $repo) use ($builder, $data, $name) {
|
||||
$query = new CustomerFormTypeQuery();
|
||||
$query->setUser($builder->getOption('user'));
|
||||
$tu = $builder->getOption('user');
|
||||
if ($tu instanceof User) {
|
||||
$query->setUser($tu);
|
||||
}
|
||||
|
||||
if (\array_key_exists($name, $data) && $data[$name] !== null && $data[$name] !== '') {
|
||||
$customers = \is_array($data[$name]) ? $data[$name] : [$data[$name]];
|
||||
@@ -149,6 +165,9 @@ trait ToolbarFormTrait
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
protected function addDateRange(FormBuilderInterface $builder, array $options, bool $allowEmpty = true): void
|
||||
{
|
||||
$params = [
|
||||
@@ -163,11 +182,17 @@ trait ToolbarFormTrait
|
||||
$builder->add('daterange', DateRangeType::class, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
protected function addProjectMultiChoice(FormBuilderInterface $builder, array $options = [], bool $multiCustomer = false, bool $multiActivity = false): void
|
||||
{
|
||||
$this->addProjectSelect($builder, $options, true, $multiCustomer, $multiActivity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function addProjectSelect(FormBuilderInterface $builder, array $options, bool $multiProject, bool $multiCustomer, bool $multiActivity): void
|
||||
{
|
||||
$name = 'project';
|
||||
@@ -197,7 +222,10 @@ trait ToolbarFormTrait
|
||||
'activity_select' => $multiActivity ? 'activities' : 'activity',
|
||||
'query_builder' => function (ProjectRepository $repo) use ($builder, $data, $options, $multiCustomer, $multiProject) {
|
||||
$query = new ProjectFormTypeQuery();
|
||||
$query->setUser($builder->getOption('user'));
|
||||
$tu = $builder->getOption('user');
|
||||
if ($tu instanceof User) {
|
||||
$query->setUser($tu);
|
||||
}
|
||||
|
||||
$name = $multiCustomer ? 'customers' : 'customer';
|
||||
if (\array_key_exists($name, $data) && $data[$name] !== null && $data[$name] !== '') {
|
||||
@@ -234,11 +262,17 @@ trait ToolbarFormTrait
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
protected function addActivityMultiChoice(FormBuilderInterface $builder, array $options = [], bool $multiProject = false): void
|
||||
{
|
||||
$this->addActivitySelect($builder, $options, true, $multiProject);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function addActivitySelect(FormBuilderInterface $builder, array $options = [], bool $multiActivity = false, bool $multiProject = false, bool $autoFill = true): void
|
||||
{
|
||||
$name = $multiActivity ? 'activities' : 'activity';
|
||||
@@ -330,6 +364,9 @@ trait ToolbarFormTrait
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, string> $allowedColumns
|
||||
*/
|
||||
protected function addOrderBy(FormBuilderInterface $builder, array $allowedColumns): void
|
||||
{
|
||||
$all = [];
|
||||
|
||||
@@ -102,6 +102,7 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
|
||||
'entry.user_title' => $user->getTitle() ?? '',
|
||||
'entry.user_alias' => $user->getAlias() ?? '',
|
||||
'entry.user_display' => $user->getDisplayName(),
|
||||
'entry.user_account' => $user->getAccountNumber() ?? '',
|
||||
]);
|
||||
|
||||
foreach ($user->getVisiblePreferences() as $pref) {
|
||||
@@ -135,6 +136,7 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
|
||||
}
|
||||
}
|
||||
|
||||
// @deprecated since 2.59.0 - invoices have one global customer - removed from the docs 2026-05-27
|
||||
if (null !== $customer) {
|
||||
$values = array_merge($values, [
|
||||
'entry.customer' => $customer->getName(),
|
||||
|
||||
@@ -28,6 +28,7 @@ final class InvoiceModelUserHydrator implements InvoiceModelHydrator
|
||||
'user.title' => $user->getTitle() ?? '',
|
||||
'user.alias' => $user->getAlias() ?? '',
|
||||
'user.display' => $user->getDisplayName(),
|
||||
'user.account' => $user->getAccountNumber() ?? '',
|
||||
];
|
||||
|
||||
foreach ($user->getPreferences() as $metaField) {
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Pdf;
|
||||
use Mpdf\Http\ClientInterface;
|
||||
use Mpdf\PsrHttpMessageShim\Response;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpClientExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
@@ -33,16 +34,20 @@ final class SafeRemoteContentClient implements ClientInterface
|
||||
*/
|
||||
private const TIMEOUT = 10;
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $client)
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $client,
|
||||
private readonly LoggerInterface $logger
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function sendRequest(RequestInterface $request): Response
|
||||
{
|
||||
$url = (string) $request->getUri();
|
||||
try {
|
||||
$response = $this->client->request(
|
||||
$request->getMethod(),
|
||||
(string) $request->getUri(),
|
||||
$url,
|
||||
[
|
||||
'headers' => $this->flattenHeaders($request),
|
||||
'timeout' => self::TIMEOUT,
|
||||
@@ -55,8 +60,10 @@ final class SafeRemoteContentClient implements ClientInterface
|
||||
[],
|
||||
$response->getContent(false)
|
||||
);
|
||||
} catch (HttpClientExceptionInterface) {
|
||||
} catch (HttpClientExceptionInterface $e) {
|
||||
// Request blocked (private network), DNS failure, timeout, etc.
|
||||
$this->logger->error(\sprintf('Failed fetching from URL "%s" with : %s', $url, $e->getMessage()));
|
||||
|
||||
return new Response(502);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
*/
|
||||
final class ProjectService
|
||||
{
|
||||
private int $generatedNumbers = 0;
|
||||
|
||||
public function __construct(
|
||||
private readonly ProjectRepository $repository,
|
||||
private readonly SystemConfiguration $configuration,
|
||||
@@ -150,7 +152,8 @@ final class ProjectService
|
||||
}
|
||||
|
||||
// we cannot use max(number) because a varchar column returns unexpected results
|
||||
$start = $this->repository->countProject();
|
||||
$count = $this->repository->countProject();
|
||||
$start = $count + $this->generatedNumbers;
|
||||
$i = 0;
|
||||
$createDate = new \DateTimeImmutable();
|
||||
|
||||
@@ -182,6 +185,10 @@ final class ProjectService
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remember how far we advanced — including iterations spent skipping numbers that
|
||||
// already exist — so the next call on this instance starts beyond the issued number.
|
||||
$this->generatedNumbers = $start - $count;
|
||||
|
||||
return $number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +207,11 @@ class InvoiceRepository extends EntityRepository
|
||||
$qb->setParameter('status', $query->getStatus());
|
||||
}
|
||||
|
||||
if ($query->hasUsers()) {
|
||||
$qb->andWhere($qb->expr()->in('i.user', ':users'));
|
||||
$qb->setParameter('users', $query->getUsers());
|
||||
}
|
||||
|
||||
$orderBy = $query->getOrderBy();
|
||||
switch ($orderBy) {
|
||||
case 'date':
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Form\Model\DateRange;
|
||||
class InvoiceArchiveQuery extends BaseQuery implements DateRangeInterface
|
||||
{
|
||||
use DateRangeTrait;
|
||||
use UsersTrait;
|
||||
|
||||
public const INVOICE_ARCHIVE_ORDER_ALLOWED = [
|
||||
'date', 'invoice.number', 'status', 'total_rate'
|
||||
|
||||
48
src/Repository/Query/UsersTrait.php
Normal file
48
src/Repository/Query/UsersTrait.php
Normal file
@@ -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\Repository\Query;
|
||||
|
||||
use App\Entity\User;
|
||||
|
||||
trait UsersTrait
|
||||
{
|
||||
/**
|
||||
* @var array<User>
|
||||
*/
|
||||
protected array $users = [];
|
||||
|
||||
public function addUser(User $user): void
|
||||
{
|
||||
$this->users[$user->getId()] = $user;
|
||||
}
|
||||
|
||||
public function removeUser(User $user): void
|
||||
{
|
||||
if (isset($this->users[$user->getId()])) {
|
||||
unset($this->users[$user->getId()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return User[]
|
||||
*/
|
||||
public function getUsers(): array
|
||||
{
|
||||
return array_values($this->users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is one or more users in the query
|
||||
*/
|
||||
public function hasUsers(): bool
|
||||
{
|
||||
return \count($this->users) > 0;
|
||||
}
|
||||
}
|
||||
@@ -33,28 +33,30 @@ final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSucces
|
||||
|
||||
// we use only the path part of the URL to prevent external redirects
|
||||
$path = null;
|
||||
if (\is_array($values) && \array_key_exists('path', $values)) {
|
||||
$path = $values['path'];
|
||||
}
|
||||
|
||||
if (\is_string($path)
|
||||
&& $path !== ''
|
||||
&& str_starts_with($path, '/')
|
||||
&& !str_starts_with($path, '//')
|
||||
&& !str_contains($path, '\\')
|
||||
) {
|
||||
$target = $this->httpUtils->generateUri($request, $path);
|
||||
$loginUrl = $this->httpUtils->generateUri($request, (string) $this->options['login_path']);
|
||||
|
||||
if (\array_key_exists('scheme', $values) && str_starts_with($values['scheme'], 'http')) {
|
||||
if (\array_key_exists('host', $values) && !str_starts_with($target, $values['scheme'] . '://' . $values['host'])) {
|
||||
$target = null;
|
||||
}
|
||||
if (\is_array($values)) {
|
||||
if (\array_key_exists('path', $values)) {
|
||||
$path = $values['path'];
|
||||
}
|
||||
|
||||
// make sure that the login URL is not the target, which would be an endless loop for the user
|
||||
if ($target !== null && $target !== $loginUrl) {
|
||||
return $target;
|
||||
if (\is_string($path)
|
||||
&& $path !== ''
|
||||
&& str_starts_with($path, '/')
|
||||
&& !str_starts_with($path, '//')
|
||||
&& !str_contains($path, '\\')
|
||||
) {
|
||||
$target = $this->httpUtils->generateUri($request, $path);
|
||||
$loginUrl = $this->httpUtils->generateUri($request, (string) $this->options['login_path']);
|
||||
|
||||
if (\array_key_exists('scheme', $values) && str_starts_with($values['scheme'], 'http')) {
|
||||
if (\array_key_exists('host', $values) && !str_starts_with($target, $values['scheme'] . '://' . $values['host'])) {
|
||||
$target = null;
|
||||
}
|
||||
}
|
||||
|
||||
// make sure that the login URL is not the target, which would be an endless loop for the user
|
||||
if ($target !== null && $target !== $loginUrl) {
|
||||
return $target;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ namespace App\Voter;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Security\RolePermissionManager;
|
||||
use Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorTokenInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
@@ -21,7 +23,10 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
*/
|
||||
final class ApiVoter extends Voter
|
||||
{
|
||||
public function __construct(private readonly RolePermissionManager $permissionManager)
|
||||
public function __construct(
|
||||
private readonly RolePermissionManager $permissionManager,
|
||||
private readonly AuthorizationCheckerInterface $authorizationChecker,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -48,6 +53,22 @@ final class ApiVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
// this check does not work, because remember_me sessions would not pass this check
|
||||
// as the frontend uses the API, the user need to be able to use the API via session, even if not "fully authenticated"
|
||||
// !$this->authorizationChecker->isGranted('IS_AUTHENTICATED_FULLY', $user)
|
||||
|
||||
// the instanceof check is not mentioned in the official docs https://symfony.com/bundles/SchebTwoFactorBundle/8.x/index.html
|
||||
// but it should be a tiny bit faster than asking the TwoFactorInProgressVoter, which does the same ...
|
||||
// as we rely on internal bundle knowledge, we keep the defense-in-depth branch here as well
|
||||
|
||||
if (
|
||||
$token instanceof TwoFactorTokenInterface ||
|
||||
$this->authorizationChecker->isGranted('IS_AUTHENTICATED_2FA_IN_PROGRESS', $user)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// derived from AccessTokenSuccessHandler
|
||||
if ($token->hasAttribute('api-token')) {
|
||||
return $this->permissionManager->hasRolePermission($user, 'api_access');
|
||||
}
|
||||
|
||||
113
src/Wizard/WizardManager.php
Normal file
113
src/Wizard/WizardManager.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?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\Wizard;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Event\WizardEvent;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* Central entry point for working with the user onboarding wizard.
|
||||
*
|
||||
* Dispatches the {@see WizardEvent} so every registered listener (Kimai
|
||||
* core and plugins) can contribute steps, then answers navigation questions
|
||||
* on top of the resulting ordered step list. This is the only place that
|
||||
* needs to know how the steps relate to each other — individual step
|
||||
* controllers never reference their neighbours by name.
|
||||
*/
|
||||
final class WizardManager
|
||||
{
|
||||
public function __construct(private readonly EventDispatcherInterface $dispatcher)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return WizardStep[] all registered steps for $user, sorted by order
|
||||
*/
|
||||
public function getSteps(User $user): array
|
||||
{
|
||||
$event = new WizardEvent($user);
|
||||
// these stes
|
||||
$event->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
$event->addStep(new WizardStep('profile', 'wizard_profile', 200));
|
||||
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
return $event->getSteps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first step the user has not seen yet, or null if
|
||||
* everything is done.
|
||||
*/
|
||||
public function getFirstUnseenStep(User $user): ?WizardStep
|
||||
{
|
||||
foreach ($this->getSteps($user) as $step) {
|
||||
if (!$user->hasSeenWizard($step->id)) {
|
||||
return $step;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the step that comes after $currentStepId in the configured
|
||||
* order, or null if $currentStepId is unknown or is the last step.
|
||||
*/
|
||||
public function getNextStep(User $user, string $currentStepId): ?WizardStep
|
||||
{
|
||||
$found = false;
|
||||
foreach ($this->getSteps($user) as $step) {
|
||||
if ($found) {
|
||||
return $step;
|
||||
}
|
||||
if ($step->id === $currentStepId) {
|
||||
$found = true;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the step that comes before $currentStepId in the configured
|
||||
* order, or null if $currentStepId is unknown or is the first step.
|
||||
*/
|
||||
public function getPreviousStep(User $user, string $currentStepId): ?WizardStep
|
||||
{
|
||||
$previous = null;
|
||||
foreach ($this->getSteps($user) as $step) {
|
||||
if ($step->id === $currentStepId) {
|
||||
return $previous;
|
||||
}
|
||||
$previous = $step;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve previous/next route names for a step via the WizardManager so
|
||||
* step controllers never reference their neighbours by name.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getNavigation(User $user, string $currentStepId): array
|
||||
{
|
||||
$previous = $this->getPreviousStep($user, $currentStepId);
|
||||
$next = $this->getNextStep($user, $currentStepId);
|
||||
|
||||
return [
|
||||
'previous' => $previous?->route,
|
||||
'next' => $next?->route ?? 'wizard_finish', // @phpstan-ignore nullsafe.neverNull
|
||||
];
|
||||
}
|
||||
}
|
||||
31
src/Wizard/WizardStep.php
Normal file
31
src/Wizard/WizardStep.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?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\Wizard;
|
||||
|
||||
/**
|
||||
* A single step inside the user onboarding wizard.
|
||||
*
|
||||
* Plugins can register their own steps via the {@see \App\Event\WizardEvent}.
|
||||
* A step is identified by its id (used for the "seen" flag on {@see \App\Entity\User})
|
||||
* and points to an existing Symfony route that renders the step.
|
||||
*
|
||||
* The order property defines the position inside the wizard sequence — lower
|
||||
* numbers come first. Built-in steps use round numbers (100, 200, 300, ...),
|
||||
* leaving plenty of room for plugins to insert in between.
|
||||
*/
|
||||
final class WizardStep
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly string $route,
|
||||
public readonly int $order = 0,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
{% if form_theme is defined %}
|
||||
{% form_theme form form_theme %}
|
||||
{% endif %}
|
||||
{% set bodyClasses = 'card-body' %}
|
||||
{% if fullsize ?? false %}
|
||||
{% set bodyClasses = bodyClasses ~ ' p-0' %}
|
||||
{% endif %}
|
||||
{% set _back = back is defined and back is not same as (false) ? back : false %}
|
||||
{% set _reset = reset is defined and reset is same as (false) ? false : true %}
|
||||
<div class="card mb-3">
|
||||
@@ -14,7 +18,7 @@
|
||||
{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="{{ bodyClasses }}">
|
||||
{% block form_body_outer %}
|
||||
{% block form_body_pre %}
|
||||
{{ form_errors(form) }}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
{% set bodyClasses = 'modal-body' %}
|
||||
{% if fullsize ?? false %}
|
||||
{% set bodyClasses = bodyClasses ~ ' p-0' %}
|
||||
{% endif %}
|
||||
<div class="modal {% block modal_class %}{% endblock %}" id="{% block modal_id %}form_modal{% endblock %}" tabindex="-1" role="dialog" aria-labelledby="{{ block('modal_id') }}_label">
|
||||
<div class="modal-dialog {% block modal_size %}modal-lg{% endblock %}" role="document">
|
||||
<div class="modal-content">
|
||||
@@ -14,7 +18,7 @@
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ 'action.close'|trans }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="{{ bodyClasses }}">
|
||||
{% block modal_body %}
|
||||
{% block form_body_outer %}
|
||||
{% block form_body_pre %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
{% block wizard_content %}
|
||||
<div class="card-body text-center py-4 p-sm-7">
|
||||
<img src="{{ asset('touch-icon-512x512.png') }}" height="120" class="mb-n2" alt="Illustration by Katerina Limpitsouni from https://undraw.co/">
|
||||
<img src="{{ asset('touch-icon-512x512.png') }}" style="height: 200px" alt="Kimai Logo">
|
||||
<h1 class="mt-5">{{ 'wizard.done.title'|trans({}, 'wizard') }}</h1>
|
||||
<p class="text-body-secondary">{{ 'wizard.done.description'|trans({}, 'wizard') }}</p>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
{% block wizard_previous_button %}
|
||||
{% if previous is defined and previous is not null %}
|
||||
{{ button(false, { title : 'Previous'|trans({}, 'TablerBundle'), combined: true, url: path('wizard', {'wizard': previous}) }, 'link') }}
|
||||
{{ button(false, { title : 'Previous'|trans({}, 'TablerBundle'), combined: true, url: path(previous) }, 'link') }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
{{ submit_button(false, { title : 'Next'|trans({}, 'TablerBundle'), combined: true }, 'primary') }}
|
||||
{% else %}
|
||||
{% if next is defined and next is not null %}
|
||||
{{ button(false, { title : 'Next'|trans({}, 'TablerBundle'), combined: true, url: path('wizard', {'wizard': next}) }, 'primary') }}
|
||||
{{ button(false, { title : 'Next'|trans({}, 'TablerBundle'), combined: true, url: path(next) }, 'primary') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -153,6 +153,21 @@ class ActivityServiceTest extends TestCase
|
||||
self::assertEquals((string) $expected, $activity->getNumber());
|
||||
}
|
||||
|
||||
public function testActivityNumberIncrementsForMultipleCreateCallsOnSameInstance(): void
|
||||
{
|
||||
$sut = $this->getSut(null, null, null, ['number_format' => '{ac,1}']);
|
||||
|
||||
$activity1 = $sut->createNewActivity();
|
||||
$activity2 = $sut->createNewActivity();
|
||||
$activity3 = $sut->createNewActivity();
|
||||
|
||||
// countActivity() is mocked and returns 0, the formatter normalizes increaseBy=0 to 1,
|
||||
// so the first generated number is 2. The in-instance counter must bump subsequent calls.
|
||||
self::assertEquals('2', $activity1->getNumber());
|
||||
self::assertEquals('3', $activity2->getNumber());
|
||||
self::assertEquals('4', $activity3->getNumber());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<int, string|\DateTime|int>>
|
||||
*/
|
||||
|
||||
@@ -39,6 +39,27 @@ class WizardControllerTest extends AbstractControllerBaseTestCase
|
||||
$this->assertAccessIsGranted($client, '/wizard/profile');
|
||||
}
|
||||
|
||||
public function testPasswordWizard(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$this->assertAccessIsGranted($client, '/wizard/password');
|
||||
}
|
||||
|
||||
public function testFinishWizard(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
// mark all wizards as seen so the WizardSubscriber does not interfere
|
||||
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
|
||||
$user->setWizardAsSeen('intro');
|
||||
$user->setWizardAsSeen('profile');
|
||||
$this->getEntityManager()->persist($user);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->assertAccessIsGranted($client, '/wizard/finish');
|
||||
}
|
||||
|
||||
public function testWizardDoesNotAppearOnFirstLoginIfDisabled(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
@@ -72,12 +93,13 @@ class WizardControllerTest extends AbstractControllerBaseTestCase
|
||||
$this->assertIsRedirect($client, '/wizard/intro');
|
||||
}
|
||||
|
||||
public function testProfileWizardSubmitRedirectsToDoneAndMarksSeen(): void
|
||||
public function testProfileWizardSubmitMarksSeenAndRedirectsToNext(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
|
||||
$user->setPreferenceValue('__wizards__', null);
|
||||
$user->setWizardAsSeen('intro');
|
||||
$user->setRequiresPasswordReset(false);
|
||||
$this->getEntityManager()->persist($user);
|
||||
$this->getEntityManager()->flush();
|
||||
@@ -92,7 +114,11 @@ class WizardControllerTest extends AbstractControllerBaseTestCase
|
||||
$values['form'][UserPreference::SKIN] = 'auto';
|
||||
$client->submit($form, $values);
|
||||
|
||||
$this->assertIsRedirect($client, '/wizard/done');
|
||||
// After a successful profile submit, the controller always redirects to
|
||||
// the virtual /wizard/next/ route — the WizardManager decides where
|
||||
// that ultimately lands. The route name and the _locale query string
|
||||
// make for a stable assertion target.
|
||||
$this->assertIsRedirect($client, '/wizard/next/', false);
|
||||
|
||||
$this->getEntityManager()->clear();
|
||||
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
|
||||
@@ -112,7 +138,7 @@ class WizardControllerTest extends AbstractControllerBaseTestCase
|
||||
$this->assertIsRedirect($client, '/wizard/profile');
|
||||
}
|
||||
|
||||
public function testProfileWizardSubmitRedirectsToPasswordIfResetRequired(): void
|
||||
public function testPasswordWizardSubmitClearsResetFlagAndRedirectsToNext(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
@@ -121,19 +147,80 @@ class WizardControllerTest extends AbstractControllerBaseTestCase
|
||||
$this->getEntityManager()->persist($user);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$crawler = $this->request($client, '/wizard/profile');
|
||||
$form = $crawler->filter('form[name=form]')->form();
|
||||
$crawler = $this->request($client, '/wizard/password');
|
||||
$form = $crawler->filter('form[name=user_password]')->form();
|
||||
$values = $form->getPhpValues();
|
||||
$values['form']['reload'] = '0';
|
||||
$values['user_password']['plainPassword']['first'] = 'new-pa$$word-123';
|
||||
$values['user_password']['plainPassword']['second'] = 'new-pa$$word-123';
|
||||
$client->submit($form, $values);
|
||||
|
||||
$this->assertIsRedirect($client, '/wizard/password');
|
||||
$this->assertIsRedirect($client, '/wizard/next/', false);
|
||||
|
||||
$this->getEntityManager()->clear();
|
||||
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
|
||||
self::assertFalse($user->requiresPasswordReset());
|
||||
}
|
||||
|
||||
public function testDoneWizard(): void
|
||||
public function testNextRedirectsToFirstUnseenStep(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$this->assertAccessIsGranted($client, '/wizard/done');
|
||||
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
|
||||
$user->setPreferenceValue('__wizards__', null);
|
||||
$user->setRequiresPasswordReset(false);
|
||||
$this->getEntityManager()->persist($user);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->request($client, '/wizard/next/');
|
||||
|
||||
// The WizardSubscriber intercepts /wizard/next/ on kernel.request and
|
||||
// redirects to the first unseen step (intro, since we just cleared it).
|
||||
$this->assertIsRedirect($client, '/wizard/intro');
|
||||
}
|
||||
|
||||
public function testNextRedirectsToFinishWhenAllStepsSeen(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
|
||||
$user->setWizardAsSeen('intro');
|
||||
$user->setWizardAsSeen('profile');
|
||||
$user->setRequiresPasswordReset(false);
|
||||
$this->getEntityManager()->persist($user);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->request($client, '/wizard/next/');
|
||||
|
||||
// With nothing left to see the subscriber returns early and the
|
||||
// controller falls back to the finish page.
|
||||
$this->assertIsRedirect($client, '/wizard/finish');
|
||||
}
|
||||
|
||||
public function testPreviousRedirectsToPreviousStep(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$this->request($client, '/wizard/previous/profile');
|
||||
|
||||
$this->assertIsRedirect($client, '/wizard/intro');
|
||||
}
|
||||
|
||||
public function testPreviousFallsBackToIntroWhenNoPreviousStep(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
// intro is the very first step, so there is nothing before it
|
||||
$this->request($client, '/wizard/previous/intro');
|
||||
|
||||
$this->assertIsRedirect($client, '/wizard/intro');
|
||||
}
|
||||
|
||||
public function testPreviousFallsBackToIntroForUnknownStep(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$this->request($client, '/wizard/previous/does-not-exist');
|
||||
|
||||
$this->assertIsRedirect($client, '/wizard/intro');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,34 @@ class CustomerServiceTest extends TestCase
|
||||
self::assertEquals($expected($date), $customer->getNumber());
|
||||
}
|
||||
|
||||
public function testCustomerNumberIncrementsForMultipleCreateCallsOnSameInstance(): void
|
||||
{
|
||||
$configuration = SystemConfigurationFactory::createStub([
|
||||
'defaults' => [
|
||||
'customer' => [
|
||||
'timezone' => 'Europe/Vienna',
|
||||
'country' => 'IN',
|
||||
'currency' => 'RUB',
|
||||
]
|
||||
],
|
||||
'customer' => [
|
||||
'number_format' => '{cc,1}',
|
||||
]
|
||||
]);
|
||||
|
||||
$sut = $this->getSut(null, null, $configuration);
|
||||
|
||||
$customer1 = $sut->createNewCustomer('A');
|
||||
$customer2 = $sut->createNewCustomer('B');
|
||||
$customer3 = $sut->createNewCustomer('C');
|
||||
|
||||
// countCustomer() is mocked and returns 0, the formatter normalizes increaseBy=0 to 1,
|
||||
// so the first generated number is 2. The in-instance counter must bump subsequent calls.
|
||||
self::assertEquals('2', $customer1->getNumber());
|
||||
self::assertEquals('3', $customer2->getNumber());
|
||||
self::assertEquals('4', $customer3->getNumber());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{0: string, 1: \Closure(\DateTimeInterface): string}>
|
||||
*/
|
||||
|
||||
@@ -777,6 +777,16 @@ class UserTest extends TestCase
|
||||
self::assertTrue($user->isPasswordRequestNonExpired(7200));
|
||||
}
|
||||
|
||||
public function testSignatureDate(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
self::assertEquals('', $user->getSignatureDate());
|
||||
$user->resetSecuritySignature();
|
||||
// shortest possible result: 2026-05-31T01:18:19Z
|
||||
self::assertGreaterThanOrEqual(20, \strlen($user->getSignatureDate()));
|
||||
}
|
||||
|
||||
private static function userWithId(int $id): User
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
162
tests/Event/WizardEventTest.php
Normal file
162
tests/Event/WizardEventTest.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?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\Event;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Event\WizardEvent;
|
||||
use App\Wizard\WizardStep;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(WizardEvent::class)]
|
||||
class WizardEventTest extends TestCase
|
||||
{
|
||||
public function testGetUserReturnsConstructorArgument(): void
|
||||
{
|
||||
$user = new User();
|
||||
$sut = new WizardEvent($user);
|
||||
|
||||
self::assertSame($user, $sut->getUser());
|
||||
}
|
||||
|
||||
public function testInitiallyEmpty(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
|
||||
self::assertSame([], $sut->getSteps());
|
||||
self::assertSame([], $sut->getWizards());
|
||||
self::assertFalse($sut->hasStep('intro'));
|
||||
self::assertNull($sut->getStep('intro'));
|
||||
}
|
||||
|
||||
public function testAddStepAndAccessors(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$step = new WizardStep('intro', 'wizard_intro', 100);
|
||||
|
||||
$sut->addStep($step);
|
||||
|
||||
self::assertTrue($sut->hasStep('intro'));
|
||||
self::assertSame($step, $sut->getStep('intro'));
|
||||
self::assertSame([$step], $sut->getSteps());
|
||||
}
|
||||
|
||||
public function testAddStepReplacesStepWithSameId(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
$replacement = new WizardStep('intro', 'wizard_intro_v2', 100);
|
||||
|
||||
$sut->addStep($replacement);
|
||||
|
||||
self::assertSame($replacement, $sut->getStep('intro'));
|
||||
self::assertCount(1, $sut->getSteps());
|
||||
}
|
||||
|
||||
public function testGetStepsIsSortedByOrderAscending(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$intro = new WizardStep('intro', 'wizard_intro', 100);
|
||||
$profile = new WizardStep('profile', 'wizard_profile', 200);
|
||||
$plugin = new WizardStep('plugin', 'plugin_step', 150);
|
||||
|
||||
// intentionally added out of order
|
||||
$sut->addStep($profile);
|
||||
$sut->addStep($intro);
|
||||
$sut->addStep($plugin);
|
||||
|
||||
self::assertSame([$intro, $plugin, $profile], $sut->getSteps());
|
||||
}
|
||||
|
||||
public function testRemoveStep(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
$sut->addStep(new WizardStep('profile', 'wizard_profile', 200));
|
||||
|
||||
$sut->removeStep('intro');
|
||||
|
||||
self::assertFalse($sut->hasStep('intro'));
|
||||
self::assertTrue($sut->hasStep('profile'));
|
||||
self::assertCount(1, $sut->getSteps());
|
||||
}
|
||||
|
||||
public function testRemoveUnknownStepIsNoop(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
|
||||
$sut->removeStep('does-not-exist');
|
||||
|
||||
self::assertCount(1, $sut->getSteps());
|
||||
}
|
||||
|
||||
public function testAddWizardCreatesStepsInInsertionOrder(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
|
||||
$sut->addWizard('intro', 'wizard_intro');
|
||||
$sut->addWizard('profile', 'wizard_profile');
|
||||
|
||||
$steps = $sut->getSteps();
|
||||
self::assertCount(2, $steps);
|
||||
self::assertSame('intro', $steps[0]->id);
|
||||
self::assertSame('wizard_intro', $steps[0]->route);
|
||||
self::assertSame('profile', $steps[1]->id);
|
||||
self::assertSame('wizard_profile', $steps[1]->route);
|
||||
// addWizard must assign ascending order values so insertion order is preserved
|
||||
self::assertLessThan($steps[1]->order, $steps[0]->order);
|
||||
}
|
||||
|
||||
public function testGetWizardsReturnsIdToRouteMapInOrder(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$sut->addStep(new WizardStep('profile', 'wizard_profile', 200));
|
||||
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
|
||||
self::assertSame(
|
||||
['intro' => 'wizard_intro', 'profile' => 'wizard_profile'],
|
||||
$sut->getWizards()
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetNextWizardReturnsRouteOfFollowingStep(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
$sut->addStep(new WizardStep('profile', 'wizard_profile', 200));
|
||||
|
||||
self::assertSame('wizard_profile', $sut->getNextWizard('intro'));
|
||||
}
|
||||
|
||||
public function testGetNextWizardReturnsFinishForLastStep(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
$sut->addStep(new WizardStep('profile', 'wizard_profile', 200));
|
||||
|
||||
self::assertSame('wizard_finish', $sut->getNextWizard('profile'));
|
||||
}
|
||||
|
||||
public function testGetNextWizardReturnsFinishForUnknownStep(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
|
||||
self::assertSame('wizard_finish', $sut->getNextWizard('does-not-exist'));
|
||||
}
|
||||
|
||||
public function testGetNextWizardOnEmptyEventReturnsFinish(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
|
||||
self::assertSame('wizard_finish', $sut->getNextWizard('anything'));
|
||||
}
|
||||
}
|
||||
@@ -179,7 +179,7 @@ class PasswordResetSubscriberTest extends TestCase
|
||||
$urlGenerator
|
||||
->expects($this->once())
|
||||
->method('generate')
|
||||
->with('wizard', ['wizard' => 'password'])
|
||||
->with('wizard_password')
|
||||
->willReturn('/wizard/password');
|
||||
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
|
||||
@@ -10,11 +10,15 @@
|
||||
namespace App\Tests\EventSubscriber;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Event\WizardEvent;
|
||||
use App\EventSubscriber\WizardSubscriber;
|
||||
use App\Tests\Mocks\SystemConfigurationFactory;
|
||||
use App\Wizard\WizardManager;
|
||||
use App\Wizard\WizardStep;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
@@ -41,7 +45,7 @@ class WizardSubscriberTest extends TestCase
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->never())->method('getToken');
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub(), $this->createWizardManager());
|
||||
$event = $this->createRequestEvent('/dashboard', false);
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
@@ -58,7 +62,7 @@ class WizardSubscriberTest extends TestCase
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn(null);
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub(), $this->createWizardManager());
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
@@ -89,7 +93,7 @@ class WizardSubscriberTest extends TestCase
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub(), $this->createWizardManager());
|
||||
$event = $this->createRequestEvent($uri);
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
@@ -113,7 +117,7 @@ class WizardSubscriberTest extends TestCase
|
||||
'user' => [
|
||||
'wizard' => true,
|
||||
]
|
||||
]));
|
||||
]), $this->createWizardManager());
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
@@ -137,7 +141,7 @@ class WizardSubscriberTest extends TestCase
|
||||
'user' => [
|
||||
'wizard' => true,
|
||||
]
|
||||
]));
|
||||
]), $this->createWizardManager());
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
@@ -163,7 +167,7 @@ class WizardSubscriberTest extends TestCase
|
||||
'user' => [
|
||||
'wizard' => false,
|
||||
]
|
||||
]));
|
||||
]), $this->createWizardManager());
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
@@ -181,7 +185,7 @@ class WizardSubscriberTest extends TestCase
|
||||
$urlGenerator
|
||||
->expects($this->once())
|
||||
->method('generate')
|
||||
->with('wizard', ['wizard' => 'profile'])
|
||||
->with('wizard_profile')
|
||||
->willReturn('/wizard/profile');
|
||||
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
@@ -190,11 +194,16 @@ class WizardSubscriberTest extends TestCase
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$manager = $this->createWizardManager(static function (WizardEvent $event): void {
|
||||
$event->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
$event->addStep(new WizardStep('profile', 'wizard_profile', 200));
|
||||
});
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
|
||||
'user' => [
|
||||
'wizard' => true,
|
||||
]
|
||||
]));
|
||||
]), $manager);
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
@@ -204,6 +213,39 @@ class WizardSubscriberTest extends TestCase
|
||||
self::assertSame('/wizard/profile', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testOnKernelRequestDoesNotRedirectWhenAllStepsSeen(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setWizardAsSeen('intro');
|
||||
$user->setWizardAsSeen('profile');
|
||||
$token = $this->createUserToken($user);
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator->expects($this->never())->method('generate');
|
||||
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(true);
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$manager = $this->createWizardManager(static function (WizardEvent $event): void {
|
||||
$event->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
$event->addStep(new WizardStep('profile', 'wizard_profile', 200));
|
||||
});
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
|
||||
'user' => [
|
||||
'wizard' => true,
|
||||
]
|
||||
]), $manager);
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
private function createUserToken(User $user): TokenInterface
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
@@ -219,4 +261,21 @@ class WizardSubscriberTest extends TestCase
|
||||
|
||||
return new RequestEvent($kernel, $request, $mainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param (callable(WizardEvent): void)|null $stepRegistrar
|
||||
*/
|
||||
private function createWizardManager(?callable $stepRegistrar = null): WizardManager
|
||||
{
|
||||
$dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$dispatcher->method('dispatch')->willReturnCallback(static function (object $event) use ($stepRegistrar): object {
|
||||
if ($stepRegistrar !== null && $event instanceof WizardEvent) {
|
||||
$stepRegistrar($event);
|
||||
}
|
||||
|
||||
return $event;
|
||||
});
|
||||
|
||||
return new WizardManager($dispatcher);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ class InvoiceItemDefaultHydratorTest extends TestCase
|
||||
'entry.user_alias',
|
||||
'entry.user_display',
|
||||
'entry.user_title',
|
||||
'entry.user_account',
|
||||
'entry.user_preference.foo',
|
||||
'entry.user_preference.mad',
|
||||
'entry.activity',
|
||||
|
||||
@@ -37,6 +37,7 @@ class InvoiceModelUserHydratorTest extends TestCase
|
||||
'user.email',
|
||||
'user.name',
|
||||
'user.title',
|
||||
'user.account',
|
||||
'user.meta.hello',
|
||||
'user.meta.kitty',
|
||||
];
|
||||
|
||||
@@ -205,6 +205,7 @@ class DebugRendererTest extends TestCase
|
||||
'activity.budget_open_plain',
|
||||
'activity.time_budget_open',
|
||||
'activity.time_budget_open_plain',
|
||||
'user.account',
|
||||
'user.alias',
|
||||
'user.display',
|
||||
'user.email',
|
||||
@@ -341,6 +342,7 @@ class DebugRendererTest extends TestCase
|
||||
'entry.user_display',
|
||||
'entry.user_alias',
|
||||
'entry.user_title',
|
||||
'entry.user_account',
|
||||
'entry.user_preference.foo',
|
||||
'entry.user_preference.mad',
|
||||
'entry.activity',
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Pdf\SafeRemoteContentClient;
|
||||
use Mpdf\PsrHttpMessageShim\Request;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpClient\Exception\TransportException;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
|
||||
@@ -27,7 +28,7 @@ class SafeRemoteContentClientTest extends TestCase
|
||||
new MockResponse('image-bytes', ['http_code' => 200])
|
||||
);
|
||||
|
||||
$sut = new SafeRemoteContentClient($client);
|
||||
$sut = new SafeRemoteContentClient($client, $this->createStub(LoggerInterface::class));
|
||||
$response = $sut->sendRequest(new Request('GET', 'https://example.com/logo.png'));
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
@@ -40,7 +41,7 @@ class SafeRemoteContentClientTest extends TestCase
|
||||
new MockResponse('not found', ['http_code' => 404])
|
||||
);
|
||||
|
||||
$sut = new SafeRemoteContentClient($client);
|
||||
$sut = new SafeRemoteContentClient($client, $this->createStub(LoggerInterface::class));
|
||||
$response = $sut->sendRequest(new Request('GET', 'https://example.com/missing.png'));
|
||||
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
@@ -55,7 +56,10 @@ class SafeRemoteContentClientTest extends TestCase
|
||||
throw new TransportException('IP blocked');
|
||||
});
|
||||
|
||||
$sut = new SafeRemoteContentClient($client);
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects(self::once())->method('error');
|
||||
|
||||
$sut = new SafeRemoteContentClient($client, $logger);
|
||||
$response = $sut->sendRequest(new Request('GET', 'http://127.0.0.1/internal'));
|
||||
|
||||
self::assertSame(502, $response->getStatusCode());
|
||||
@@ -68,7 +72,10 @@ class SafeRemoteContentClientTest extends TestCase
|
||||
$inner = new MockHttpClient(new MockResponse('should-not-be-reached'));
|
||||
$safe = new NoPrivateNetworkHttpClient($inner);
|
||||
|
||||
$sut = new SafeRemoteContentClient($safe);
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects(self::once())->method('error');
|
||||
|
||||
$sut = new SafeRemoteContentClient($safe, $logger);
|
||||
$response = $sut->sendRequest(new Request('GET', 'http://127.0.0.1/internal'));
|
||||
|
||||
self::assertSame(502, $response->getStatusCode());
|
||||
|
||||
@@ -193,6 +193,65 @@ class ProjectServiceTest extends TestCase
|
||||
self::assertEquals($expected($date), $project->getNumber());
|
||||
}
|
||||
|
||||
public function testProjectNumberIncrementsForMultipleCreateCallsOnSameInstance(): void
|
||||
{
|
||||
$configuration = SystemConfigurationFactory::createStub([
|
||||
'project' => [
|
||||
'copy_teams_on_create' => false,
|
||||
'number_format' => '{pc,1}',
|
||||
]
|
||||
]);
|
||||
|
||||
$sut = $this->getSut(null, null, $configuration);
|
||||
|
||||
$project1 = $sut->createNewProject();
|
||||
$project2 = $sut->createNewProject();
|
||||
$project3 = $sut->createNewProject();
|
||||
|
||||
// countProject() is mocked and returns 0, the formatter normalizes increaseBy=0 to 1,
|
||||
// so the first generated number is 2. Without the in-instance counter all three
|
||||
// calls would re-use "2" — which is exactly the bug reported by the importer.
|
||||
self::assertEquals('2', $project1->getNumber());
|
||||
self::assertEquals('3', $project2->getNumber());
|
||||
self::assertEquals('4', $project3->getNumber());
|
||||
}
|
||||
|
||||
public function testProjectNumberSkipsAlreadyExistingNumbers(): void
|
||||
{
|
||||
$configuration = SystemConfigurationFactory::createStub([
|
||||
'project' => [
|
||||
'copy_teams_on_create' => false,
|
||||
'number_format' => '{pc,1}',
|
||||
]
|
||||
]);
|
||||
|
||||
$repository = $this->createMock(ProjectRepository::class);
|
||||
$repository->method('countProject')->willReturn(0);
|
||||
// Pretend the database already contains projects with numbers 2 and 3 — the
|
||||
// service must skip them and only return the next unused number.
|
||||
$repository->method('findOneBy')->willReturnCallback(function (array $criteria): ?Project {
|
||||
if (\in_array($criteria['number'] ?? null, ['2', '3'], true)) {
|
||||
return new Project();
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$dispatcher->method('dispatch')->willReturnCallback(static fn ($event) => $event);
|
||||
|
||||
$validator = $this->createMock(ValidatorInterface::class);
|
||||
$validator->method('validate')->willReturn(new ConstraintViolationList());
|
||||
|
||||
$sut = new ProjectService($repository, $configuration, $dispatcher, $validator);
|
||||
|
||||
$project1 = $sut->createNewProject();
|
||||
$project2 = $sut->createNewProject();
|
||||
|
||||
self::assertEquals('4', $project1->getNumber());
|
||||
self::assertEquals('5', $project2->getNumber());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{0: string, 1: \Closure(\DateTimeInterface): string}>
|
||||
*/
|
||||
|
||||
48
tests/Security/ApiAccessControlTest.php
Normal file
48
tests/Security/ApiAccessControlTest.php
Normal file
@@ -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\Tests\Security;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Security\Http\AccessMapInterface;
|
||||
|
||||
/**
|
||||
* Regression test for the 2FA-API-bypass security advisory.
|
||||
*
|
||||
* The firewall-level access_control for ^/api was raised from IS_AUTHENTICATED
|
||||
* to IS_AUTHENTICATED_REMEMBERED so that a TwoFactorToken (which only satisfies
|
||||
* IS_AUTHENTICATED) can no longer reach any /api/* route, while remember_me
|
||||
* sessions used by the web frontend continue to work.
|
||||
*/
|
||||
#[Group('integration')]
|
||||
class ApiAccessControlTest extends KernelTestCase
|
||||
{
|
||||
public function testApiRouteRequiresAuthenticatedRemembered(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$accessMap = self::getContainer()->get('security.access_map');
|
||||
self::assertInstanceOf(AccessMapInterface::class, $accessMap);
|
||||
|
||||
[$attributes] = $accessMap->getPatterns(Request::create('/api/users/me'));
|
||||
|
||||
self::assertIsArray($attributes);
|
||||
self::assertContains(
|
||||
'IS_AUTHENTICATED_REMEMBERED',
|
||||
$attributes,
|
||||
'API access_control must require IS_AUTHENTICATED_REMEMBERED to keep a TwoFactorToken from reaching /api/*'
|
||||
);
|
||||
self::assertNotContains(
|
||||
'IS_AUTHENTICATED',
|
||||
$attributes,
|
||||
'API access_control must not fall back to IS_AUTHENTICATED, which a TwoFactorToken satisfies'
|
||||
);
|
||||
}
|
||||
}
|
||||
139
tests/Voter/ApiVoterTest.php
Normal file
139
tests/Voter/ApiVoterTest.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?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\Voter;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Voter\ApiVoter;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorToken;
|
||||
use Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorTokenInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||
|
||||
#[CoversClass(ApiVoter::class)]
|
||||
class ApiVoterTest extends AbstractVoterTestCase
|
||||
{
|
||||
private function createApiVoter(bool $twoFactorInProgress = false, array $rolePermissions = []): ApiVoter
|
||||
{
|
||||
$checker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$checker->method('isGranted')->willReturnCallback(
|
||||
static fn (string $attribute): bool => $attribute === 'IS_AUTHENTICATED_2FA_IN_PROGRESS' && $twoFactorInProgress
|
||||
);
|
||||
|
||||
$permissionManager = $rolePermissions === []
|
||||
? $this->getRolePermissionManager()
|
||||
: $this->getRolePermissionManager($rolePermissions, true);
|
||||
|
||||
return new ApiVoter($permissionManager, $checker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for the 2FA-bypass security advisory: the session cookie
|
||||
* issued after the password step (which still carries a TwoFactorToken) must
|
||||
* not grant access to any #[IsGranted('API')] endpoint.
|
||||
*/
|
||||
public function testTwoFactorTokenIsDenied(): void
|
||||
{
|
||||
$user = self::getUser(1, User::ROLE_USER);
|
||||
$inner = new UsernamePasswordToken($user, 'secured_area', $user->getRoles());
|
||||
$token = new TwoFactorToken($inner, null, 'secured_area', ['totp']);
|
||||
|
||||
self::assertInstanceOf(TwoFactorTokenInterface::class, $token);
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
$this->createApiVoter()->vote($token, null, ['API'])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defense-in-depth branch: even if a future Scheb release stopped using a
|
||||
* TwoFactorTokenInterface, the IS_AUTHENTICATED_2FA_IN_PROGRESS role check
|
||||
* must still deny.
|
||||
*/
|
||||
public function testTwoFactorInProgressFromAuthCheckerIsDenied(): void
|
||||
{
|
||||
$user = self::getUser(1, User::ROLE_USER);
|
||||
$token = new UsernamePasswordToken($user, 'secured_area', $user->getRoles());
|
||||
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
$this->createApiVoter(twoFactorInProgress: true)->vote($token, null, ['API'])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for the security.yaml change to IS_AUTHENTICATED_REMEMBERED:
|
||||
* a remember_me-backed session (the frontend uses the API this way because
|
||||
* always_remember_me is enabled) must still pass the voter.
|
||||
*/
|
||||
public function testRememberMeSessionIsGranted(): void
|
||||
{
|
||||
$user = self::getUser(1, User::ROLE_USER);
|
||||
$token = new RememberMeToken($user, 'secured_area', 'secret');
|
||||
|
||||
self::assertNotInstanceOf(TwoFactorTokenInterface::class, $token); // @phpstan-ignore staticMethod.alreadyNarrowedType
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_GRANTED,
|
||||
$this->createApiVoter()->vote($token, null, ['API'])
|
||||
);
|
||||
}
|
||||
|
||||
public function testRegularSessionIsGranted(): void
|
||||
{
|
||||
$user = self::getUser(1, User::ROLE_USER);
|
||||
$token = new UsernamePasswordToken($user, 'secured_area', $user->getRoles());
|
||||
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_GRANTED,
|
||||
$this->createApiVoter()->vote($token, null, ['API'])
|
||||
);
|
||||
}
|
||||
|
||||
public function testApiTokenWithoutPermissionIsDenied(): void
|
||||
{
|
||||
$user = self::getUser(1, User::ROLE_USER);
|
||||
$token = new UsernamePasswordToken($user, 'api', $user->getRoles());
|
||||
$token->setAttribute('api-token', true);
|
||||
|
||||
// ROLE_USER does not carry 'api_access' in the default permission map
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
$this->createApiVoter()->vote($token, null, ['API'])
|
||||
);
|
||||
}
|
||||
|
||||
public function testApiTokenWithPermissionIsGranted(): void
|
||||
{
|
||||
$user = self::getUser(1, User::ROLE_USER);
|
||||
$token = new UsernamePasswordToken($user, 'api', $user->getRoles());
|
||||
$token->setAttribute('api-token', true);
|
||||
|
||||
$voter = $this->createApiVoter(rolePermissions: ['ROLE_USER' => ['api_access']]);
|
||||
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_GRANTED,
|
||||
$voter->vote($token, null, ['API'])
|
||||
);
|
||||
}
|
||||
|
||||
public function testNonUserSubjectIsDenied(): void
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn(null);
|
||||
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
$this->createApiVoter()->vote($token, null, ['API'])
|
||||
);
|
||||
}
|
||||
}
|
||||
198
tests/Wizard/WizardManagerTest.php
Normal file
198
tests/Wizard/WizardManagerTest.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?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\Wizard;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Event\WizardEvent;
|
||||
use App\Wizard\WizardManager;
|
||||
use App\Wizard\WizardStep;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
#[CoversClass(WizardManager::class)]
|
||||
class WizardManagerTest extends TestCase
|
||||
{
|
||||
public function testGetStepsIncludesBuiltInIntroAndProfile(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
$steps = $sut->getSteps(new User());
|
||||
|
||||
self::assertCount(2, $steps);
|
||||
self::assertSame('intro', $steps[0]->id);
|
||||
self::assertSame('wizard_intro', $steps[0]->route);
|
||||
self::assertSame('profile', $steps[1]->id);
|
||||
self::assertSame('wizard_profile', $steps[1]->route);
|
||||
}
|
||||
|
||||
public function testGetStepsIncludesPluginStepsInSortedOrder(): void
|
||||
{
|
||||
$sut = $this->createManager(static function (WizardEvent $event): void {
|
||||
// plugin step inserted between intro (100) and profile (200)
|
||||
$event->addStep(new WizardStep('plugin', 'plugin_route', 150));
|
||||
});
|
||||
|
||||
$ids = array_map(static fn (WizardStep $s): string => $s->id, $sut->getSteps(new User()));
|
||||
|
||||
self::assertSame(['intro', 'plugin', 'profile'], $ids);
|
||||
}
|
||||
|
||||
public function testGetStepsDispatchesEventOncePerCall(): void
|
||||
{
|
||||
$dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$dispatcher->expects($this->exactly(2))
|
||||
->method('dispatch')
|
||||
->willReturnArgument(0);
|
||||
|
||||
$sut = new WizardManager($dispatcher);
|
||||
$sut->getSteps(new User());
|
||||
$sut->getSteps(new User());
|
||||
}
|
||||
|
||||
public function testGetFirstUnseenStepReturnsFirstStepInitially(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
$step = $sut->getFirstUnseenStep(new User());
|
||||
|
||||
self::assertNotNull($step);
|
||||
self::assertSame('intro', $step->id);
|
||||
}
|
||||
|
||||
public function testGetFirstUnseenStepSkipsSeenSteps(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
$user = new User();
|
||||
$user->setWizardAsSeen('intro');
|
||||
|
||||
$step = $sut->getFirstUnseenStep($user);
|
||||
|
||||
self::assertNotNull($step);
|
||||
self::assertSame('profile', $step->id);
|
||||
}
|
||||
|
||||
public function testGetFirstUnseenStepReturnsNullWhenAllSeen(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
$user = new User();
|
||||
$user->setWizardAsSeen('intro');
|
||||
$user->setWizardAsSeen('profile');
|
||||
|
||||
self::assertNull($sut->getFirstUnseenStep($user));
|
||||
}
|
||||
|
||||
public function testGetNextStepReturnsFollowingStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
$step = $sut->getNextStep(new User(), 'intro');
|
||||
|
||||
self::assertNotNull($step);
|
||||
self::assertSame('profile', $step->id);
|
||||
}
|
||||
|
||||
public function testGetNextStepReturnsNullForLastStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
self::assertNull($sut->getNextStep(new User(), 'profile'));
|
||||
}
|
||||
|
||||
public function testGetNextStepReturnsNullForUnknownStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
self::assertNull($sut->getNextStep(new User(), 'does-not-exist'));
|
||||
}
|
||||
|
||||
public function testGetPreviousStepReturnsPrecedingStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
$step = $sut->getPreviousStep(new User(), 'profile');
|
||||
|
||||
self::assertNotNull($step);
|
||||
self::assertSame('intro', $step->id);
|
||||
}
|
||||
|
||||
public function testGetPreviousStepReturnsNullForFirstStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
self::assertNull($sut->getPreviousStep(new User(), 'intro'));
|
||||
}
|
||||
|
||||
public function testGetPreviousStepReturnsNullForUnknownStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
self::assertNull($sut->getPreviousStep(new User(), 'does-not-exist'));
|
||||
}
|
||||
|
||||
public function testGetNavigationReturnsPreviousAndNextRouteNames(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
self::assertSame(
|
||||
['previous' => 'wizard_intro', 'next' => 'wizard_finish'],
|
||||
$sut->getNavigation(new User(), 'profile')
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetNavigationFallsBackToWizardFinishForLastStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
$nav = $sut->getNavigation(new User(), 'profile');
|
||||
|
||||
self::assertSame('wizard_finish', $nav['next']);
|
||||
}
|
||||
|
||||
public function testGetNavigationReturnsNullPreviousForFirstStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
$nav = $sut->getNavigation(new User(), 'intro');
|
||||
|
||||
self::assertNull($nav['previous']);
|
||||
self::assertSame('wizard_profile', $nav['next']);
|
||||
}
|
||||
|
||||
public function testGetNavigationResolvesPluginNeighbours(): void
|
||||
{
|
||||
$sut = $this->createManager(static function (WizardEvent $event): void {
|
||||
// inserts itself between intro (100) and profile (200)
|
||||
$event->addStep(new WizardStep('plugin', 'plugin_route', 150));
|
||||
});
|
||||
|
||||
self::assertSame(
|
||||
['previous' => 'wizard_intro', 'next' => 'wizard_profile'],
|
||||
$sut->getNavigation(new User(), 'plugin')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param (callable(WizardEvent): void)|null $stepRegistrar
|
||||
*/
|
||||
private function createManager(?callable $stepRegistrar = null): WizardManager
|
||||
{
|
||||
$dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$dispatcher->method('dispatch')->willReturnCallback(static function (object $event) use ($stepRegistrar): object {
|
||||
if ($stepRegistrar !== null && $event instanceof WizardEvent) {
|
||||
$stepRegistrar($event);
|
||||
}
|
||||
|
||||
return $event;
|
||||
});
|
||||
|
||||
return new WizardManager($dispatcher);
|
||||
}
|
||||
}
|
||||
34
tests/Wizard/WizardStepTest.php
Normal file
34
tests/Wizard/WizardStepTest.php
Normal 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\Wizard;
|
||||
|
||||
use App\Wizard\WizardStep;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(WizardStep::class)]
|
||||
class WizardStepTest extends TestCase
|
||||
{
|
||||
public function testConstructorAssignsAllProperties(): void
|
||||
{
|
||||
$step = new WizardStep('intro', 'wizard_intro', 100);
|
||||
|
||||
self::assertSame('intro', $step->id);
|
||||
self::assertSame('wizard_intro', $step->route);
|
||||
self::assertSame(100, $step->order);
|
||||
}
|
||||
|
||||
public function testOrderDefaultsToZero(): void
|
||||
{
|
||||
$step = new WizardStep('intro', 'wizard_intro');
|
||||
|
||||
self::assertSame(0, $step->order);
|
||||
}
|
||||
}
|
||||
@@ -2082,6 +2082,34 @@
|
||||
<source>count_as_expected_time_help</source>
|
||||
<target>Wenn diese Option aktiviert ist, wird %type% als gearbeitete Stunden auf die erwarteten täglichen Stunden angerechnet. Wenn diese Option deaktiviert ist, werden die erwarteten Stunden für diese Tage auf 0 gesetzt. Beide Einstellungen sorgen für ein ausgeglichenes Stundenkonto, wirken sich jedoch unterschiedlich auf die Gesamtzahlen des Monats aus.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="WrNTzd7" resname="assign_timesheets">
|
||||
<source>assign_timesheets</source>
|
||||
<target>Zeiteinträge zuweisen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="zO0oxtw" resname="start">
|
||||
<source>start</source>
|
||||
<target>Starten</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="DAMFhpR" resname="lock">
|
||||
<source>lock</source>
|
||||
<target>Abschließen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="pNRR7CN" resname="release">
|
||||
<source>release</source>
|
||||
<target>Freigeben</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="kwC00lZ" resname="assign">
|
||||
<source>assign</source>
|
||||
<target>Zuweisen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2ajb.ZU" resname="assign_self">
|
||||
<source>assign_self</source>
|
||||
<target>Mir zuweisen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="LfO_otF" resname="reopen">
|
||||
<source>reopen</source>
|
||||
<target>Wieder öffnen</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -2082,6 +2082,34 @@
|
||||
<source>count_as_expected_time_help</source>
|
||||
<target>When enabled, %type% will count as worked hours towards the expected daily hours. When disabled, the expected hours for these days will be set to 0. Both settings maintain a neutral balance but affect monthly totals differently.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="WrNTzd7" resname="assign_timesheets">
|
||||
<source>assign_timesheets</source>
|
||||
<target>Assign timesheets</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="zO0oxtw" resname="start">
|
||||
<source>start</source>
|
||||
<target>Start</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="DAMFhpR" resname="lock">
|
||||
<source>lock</source>
|
||||
<target>Lock</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="pNRR7CN" resname="release">
|
||||
<source>release</source>
|
||||
<target>Release</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="kwC00lZ" resname="assign">
|
||||
<source>assign</source>
|
||||
<target>Assign</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="2ajb.ZU" resname="assign_self">
|
||||
<source>assign_self</source>
|
||||
<target>Assign to me</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="LfO_otF" resname="reopen">
|
||||
<source>reopen</source>
|
||||
<target>Reopen</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
Reference in New Issue
Block a user