Release 2.23.0 (#5075)

This commit is contained in:
Kevin Papst
2024-10-03 10:34:20 +02:00
committed by GitHub
parent 40154be8f9
commit fb9a0dc499
142 changed files with 855 additions and 910 deletions

1
.gitignore vendored
View File

@@ -3,7 +3,6 @@
/.env-*
/.idea/
.DS_Store
/phpstan.sh
# custom apache rules e.g. to deactivate ioncube loader
/public/.user.ini

View File

@@ -44,7 +44,7 @@ export default class KimaiFormTomselectPlugin extends KimaiFormPlugin {
let item = '<div class="list-group-item border-0 p-1 ps-2 text-nowrap">';
// if no color is set, do NOT add an empty placeholder
if (data.color !== undefined) {
item += '<span style="background-color:' + data.color + '" class="color-choice-item">&nbsp;</span>';
item += '<span style="background-color:' + data.color + '" class="color-choice-item me-2">&nbsp;</span>';
}
item += escape(data.text) + '</div>';
return item;
@@ -53,7 +53,7 @@ export default class KimaiFormTomselectPlugin extends KimaiFormPlugin {
let item = '<div class="text-nowrap">';
// if no color is set, do NOT add an empty placeholder
if (data.color !== undefined) {
item += '<span style="background-color:' + data.color + '" class="color-choice-item">&nbsp;</span>';
item += '<span style="background-color:' + data.color + '" class="color-choice-item me-2">&nbsp;</span>';
}
item += escape(data.text) + '</div>';
return item;

View File

@@ -109,12 +109,11 @@ export default class KimaiCalendar {
nextYear: this.options['icons']['nextYear'],
};
BootstrapTheme.prototype.rtlIconClasses = {
prev: this.options['icons']['next'],
next: this.options['icons']['previous'],
prevYear: this.options['icons']['nextYear'],
nextYear: this.options['icons']['previousYear'],
prev: this.options['icons']['previous'],
next: this.options['icons']['next'],
prevYear: this.options['icons']['previousYear'],
nextYear: this.options['icons']['nextYear'],
};
let calendarOptions = {
locales: [ enGbLocale, enUsLocale, arLocale, csLocale, daLocale, deLocale, deAtLocale, elLocale,
esLocale, euLocale, faLocale, fiLocale, frLocale, heLocale, hrLocale, huLocale, itLocale, jaLocale, koLocale,

View File

@@ -26,6 +26,9 @@
margin-bottom: 0;
}
}
.input-group {
width: unset;
}
}
}
@@ -33,7 +36,6 @@
width: 20px;
height: 20px;
display: inline-block;
margin-right: 10px;
border-radius: var(--tblr-border-radius);
}

95
composer.lock generated
View File

@@ -1587,16 +1587,16 @@
},
{
"name": "doctrine/sql-formatter",
"version": "1.4.1",
"version": "1.5.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/sql-formatter.git",
"reference": "7f83911cc5eba870de7ebb11283972483f7e2891"
"reference": "16ca9e39877369d664f06dacde468548298bdc40"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/7f83911cc5eba870de7ebb11283972483f7e2891",
"reference": "7f83911cc5eba870de7ebb11283972483f7e2891",
"url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/16ca9e39877369d664f06dacde468548298bdc40",
"reference": "16ca9e39877369d664f06dacde468548298bdc40",
"shasum": ""
},
"require": {
@@ -1604,6 +1604,7 @@
},
"require-dev": {
"doctrine/coding-standard": "^12",
"ergebnis/phpunit-slow-test-detector": "^2.14",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.5",
"vimeo/psalm": "^5.24"
@@ -1636,9 +1637,9 @@
],
"support": {
"issues": "https://github.com/doctrine/sql-formatter/issues",
"source": "https://github.com/doctrine/sql-formatter/tree/1.4.1"
"source": "https://github.com/doctrine/sql-formatter/tree/1.5.0"
},
"time": "2024-08-05T20:32:22+00:00"
"time": "2024-09-11T07:29:40+00:00"
},
{
"name": "egulias/email-validator",
@@ -2319,16 +2320,16 @@
},
{
"name": "kevinpapst/tabler-bundle",
"version": "1.5.1",
"version": "1.5.3",
"source": {
"type": "git",
"url": "https://github.com/kevinpapst/TablerBundle.git",
"reference": "72b36e5be2aa19e4b72b2cd1068c64304379fc19"
"reference": "ab9f32312a16cd4dc16d52d32a79178f5c7881f5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/kevinpapst/TablerBundle/zipball/72b36e5be2aa19e4b72b2cd1068c64304379fc19",
"reference": "72b36e5be2aa19e4b72b2cd1068c64304379fc19",
"url": "https://api.github.com/repos/kevinpapst/TablerBundle/zipball/ab9f32312a16cd4dc16d52d32a79178f5c7881f5",
"reference": "ab9f32312a16cd4dc16d52d32a79178f5c7881f5",
"shasum": ""
},
"require": {
@@ -2376,7 +2377,7 @@
"description": "Admin/Backend theme bundle for Symfony based on Tabler.io",
"support": {
"issues": "https://github.com/kevinpapst/TablerBundle/issues",
"source": "https://github.com/kevinpapst/TablerBundle/tree/1.5.1"
"source": "https://github.com/kevinpapst/TablerBundle/tree/1.5.3"
},
"funding": [
{
@@ -2388,7 +2389,7 @@
"type": "github"
}
],
"time": "2024-09-09T12:23:51+00:00"
"time": "2024-10-01T21:16:00+00:00"
},
{
"name": "league/csv",
@@ -3055,16 +3056,16 @@
},
{
"name": "nelmio/api-doc-bundle",
"version": "v4.30.0",
"version": "v4.32.0",
"source": {
"type": "git",
"url": "https://github.com/nelmio/NelmioApiDocBundle.git",
"reference": "277fa17b912be31b5170e49f0924b1028058c938"
"reference": "6ea702702ffb3382e9fd9776c868ff7386dbc772"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/277fa17b912be31b5170e49f0924b1028058c938",
"reference": "277fa17b912be31b5170e49f0924b1028058c938",
"url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/6ea702702ffb3382e9fd9776c868ff7386dbc772",
"reference": "6ea702702ffb3382e9fd9776c868ff7386dbc772",
"shasum": ""
},
"require": {
@@ -3165,7 +3166,7 @@
],
"support": {
"issues": "https://github.com/nelmio/NelmioApiDocBundle/issues",
"source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v4.30.0"
"source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v4.32.0"
},
"funding": [
{
@@ -3173,7 +3174,7 @@
"type": "github"
}
],
"time": "2024-09-14T10:54:47+00:00"
"time": "2024-09-30T13:06:21+00:00"
},
{
"name": "nelmio/cors-bundle",
@@ -3750,16 +3751,16 @@
},
{
"name": "phpoffice/phpspreadsheet",
"version": "2.2.2",
"version": "2.3.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "ffbcee68069b073bff07a71eb321dcd9f2763513"
"reference": "c972c146ddd5e8350ea839355b9bb0ce6a8fa33e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/ffbcee68069b073bff07a71eb321dcd9f2763513",
"reference": "ffbcee68069b073bff07a71eb321dcd9f2763513",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/c972c146ddd5e8350ea839355b9bb0ce6a8fa33e",
"reference": "c972c146ddd5e8350ea839355b9bb0ce6a8fa33e",
"shasum": ""
},
"require": {
@@ -3848,9 +3849,9 @@
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/2.2.2"
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/2.3.0"
},
"time": "2024-08-08T02:31:26+00:00"
"time": "2024-09-29T07:06:02+00:00"
},
{
"name": "phpoffice/phpword",
@@ -3963,16 +3964,16 @@
},
{
"name": "phpstan/phpdoc-parser",
"version": "1.31.0",
"version": "1.32.0",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "249f15fb843bf240cf058372dad29e100cee6c17"
"reference": "6ca22b154efdd9e3c68c56f5d94670920a1c19a4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/249f15fb843bf240cf058372dad29e100cee6c17",
"reference": "249f15fb843bf240cf058372dad29e100cee6c17",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/6ca22b154efdd9e3c68c56f5d94670920a1c19a4",
"reference": "6ca22b154efdd9e3c68c56f5d94670920a1c19a4",
"shasum": ""
},
"require": {
@@ -4004,9 +4005,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/1.31.0"
"source": "https://github.com/phpstan/phpdoc-parser/tree/1.32.0"
},
"time": "2024-09-22T11:32:18+00:00"
"time": "2024-09-26T07:23:32+00:00"
},
{
"name": "psr/cache",
@@ -11108,16 +11109,16 @@
},
{
"name": "nikic/php-parser",
"version": "v5.2.0",
"version": "v5.3.0",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
"reference": "23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb"
"reference": "3abf7425cd284141dc5d8d14a9ee444de3345d1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb",
"reference": "23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3abf7425cd284141dc5d8d14a9ee444de3345d1a",
"reference": "3abf7425cd284141dc5d8d14a9ee444de3345d1a",
"shasum": ""
},
"require": {
@@ -11160,9 +11161,9 @@
],
"support": {
"issues": "https://github.com/nikic/PHP-Parser/issues",
"source": "https://github.com/nikic/PHP-Parser/tree/v5.2.0"
"source": "https://github.com/nikic/PHP-Parser/tree/v5.3.0"
},
"time": "2024-09-15T16:40:33+00:00"
"time": "2024-09-29T13:56:26+00:00"
},
{
"name": "phar-io/manifest",
@@ -11284,16 +11285,16 @@
},
{
"name": "phpstan/phpstan",
"version": "1.12.4",
"version": "1.12.5",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
"reference": "ffa517cb918591b93acc9b95c0bebdcd0e4538bd"
"reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/ffa517cb918591b93acc9b95c0bebdcd0e4538bd",
"reference": "ffa517cb918591b93acc9b95c0bebdcd0e4538bd",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17",
"reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17",
"shasum": ""
},
"require": {
@@ -11338,7 +11339,7 @@
"type": "github"
}
],
"time": "2024-09-19T07:58:01+00:00"
"time": "2024-09-26T12:45:22+00:00"
},
{
"name": "phpstan/phpstan-deprecation-rules",
@@ -11562,16 +11563,16 @@
},
{
"name": "phpstan/phpstan-symfony",
"version": "1.4.9",
"version": "1.4.10",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-symfony.git",
"reference": "51ab2438fb2695467cf96b58d2f8f28d4dd1e3e9"
"reference": "f7d5782044bedf93aeb3f38e09c91148ee90e5a1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/51ab2438fb2695467cf96b58d2f8f28d4dd1e3e9",
"reference": "51ab2438fb2695467cf96b58d2f8f28d4dd1e3e9",
"url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/f7d5782044bedf93aeb3f38e09c91148ee90e5a1",
"reference": "f7d5782044bedf93aeb3f38e09c91148ee90e5a1",
"shasum": ""
},
"require": {
@@ -11628,9 +11629,9 @@
"description": "Symfony Framework extensions and rules for PHPStan",
"support": {
"issues": "https://github.com/phpstan/phpstan-symfony/issues",
"source": "https://github.com/phpstan/phpstan-symfony/tree/1.4.9"
"source": "https://github.com/phpstan/phpstan-symfony/tree/1.4.10"
},
"time": "2024-09-05T16:15:09+00:00"
"time": "2024-09-26T18:14:50+00:00"
},
{
"name": "phpunit/php-code-coverage",

View File

@@ -25,6 +25,8 @@ doctrine:
datetime_immutable: App\Doctrine\UTCDateTimeImmutableType
orm:
controller_resolver:
# FIXME this is causing a deprecation and needs to be changed
# auto_mapping: false
auto_mapping: true
auto_generate_proxy_classes: '%kernel.debug%'
default_entity_manager: default

View File

@@ -105,7 +105,7 @@ kimai:
TEAMS: ['view_team','create_team','edit_team','delete_team']
LOCKDOWN: ['lockdown_grace_timesheet','lockdown_override_timesheet']
REPORTING: ['view_reporting','view_other_reporting','project_reporting','customer_reporting']
EVERYONE: ['api_access']
EVERYONE: ['api_access','hours_own_profile']
# permissions which are deactivated, as these features are hidden for now
# brave users can try to activate them and be surprised what happens
REGISTER_BETA: []
@@ -117,10 +117,10 @@ kimai:
ROLE_SUPER_ADMIN: ['ACTIVITIES','PROJECTS','CUSTOMERS','INVOICE','INVOICE_ADMIN','TIMESHEET','TIMESHEET_OTHER','PROFILE','PROFILE_OTHER','USER','TEAMS','RATE','RATE_OTHER','EXPORT','BILLABLE','TAGS','LOCKDOWN','REPORTING', 'EVERYONE']
# mapping a "role name" to an array of "permission names"
roles:
ROLE_USER: ['view_team_member','time_team_project','create_tag','view_reporting','hours_own_profile']
ROLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member','hours_other_profile','hours_own_profile']
ROLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data','contract_other_profile','hours_other_profile','hours_own_profile']
ROLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','supervisor_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data','contract_other_profile','hours_other_profile','hours_own_profile']
ROLE_USER: ['view_team_member','time_team_project','create_tag','view_reporting']
ROLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member','hours_other_profile']
ROLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data','contract_other_profile','hours_other_profile']
ROLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','supervisor_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data','contract_other_profile','hours_other_profile']
# --------------------------------------------------------------------------------

View File

@@ -6,11 +6,16 @@ when@prod:
handlers:
main:
type: fingers_crossed
action_level: notice
handler: nested
excluded_http_codes: [403, 404]
action_level: error
handler: file_log
excluded_http_codes:
- {400: ['^/api/']}
- {401: ['^/api/']}
- 403
- 404
- {405: ['/homepage$', '/login_check$', '/export/data$']}
channels: ["!deprecation"]
nested:
file_log:
type: stream
level: info
path: "%kernel.logs_dir%/%kernel.environment%.log"
@@ -24,10 +29,20 @@ when@dev:
monolog:
handlers:
main:
type: fingers_crossed
action_level: notice
handler: file_log
excluded_http_codes:
- {400: ['^/api/']}
- {401: ['^/api/']}
- 403
- 404
- {405: ['/homepage$', '/login_check$', '/export/data$']}
channels: ["!event", "!deprecation"]
file_log:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event", "!deprecation"]
formatter: monolog.formatter.kimai
console:
type: console

View File

@@ -3,12 +3,7 @@ parameters:
nelmio_cors:
defaults:
allow_credentials: false
# allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
# allow_headers: ['Content-Type', 'Authorization', 'X-AUTH-USER', 'X-AUTH-TOKEN']
# allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
# expose_headers: ['Link']
# max_age: 3600
# for security reasons we do not allow CORS by default
allow_origin: []
allow_headers: []
allow_methods: []
@@ -17,10 +12,18 @@ nelmio_cors:
hosts: []
origin_regex: true
forced_allow_origin_value: ~
# allow_private_network: true
paths:
# only the API endpoints are accessible
'^/api/':
# allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_origin: ['*']
allow_headers: ['X-AUTH-USER', 'X-AUTH-TOKEN', 'Content-Type', 'Authorization']
allow_headers: ['Content-Type', 'Authorization', 'X-AUTH-USER', 'X-AUTH-TOKEN']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
# expose_headers: ['Link']
max_age: 3600
when@dev:
nelmio_cors:
paths:
'^/api/':
allow_private_network: true

View File

@@ -5,3 +5,17 @@ framework:
limit: 250
interval: '1 hour'
lock_factory: null
reset_password:
policy: 'fixed_window'
limit: 10
interval: '1 hour'
lock_factory: null
when@test:
framework:
rate_limiter:
reset_password:
policy: 'fixed_window'
limit: 100
interval: '1 minute'
lock_factory: null

View File

@@ -22,6 +22,7 @@ services:
exclude:
- '../src/API/Model/'
- '../src/DependencyInjection/'
- '../src/Doctrine/Extensions/'
- '../src/Entity/'
- '../src/Event/'
- '../src/Form/Model/'

View File

@@ -48,9 +48,4 @@ final class Version20240214061246 extends AbstractMigration
$schema->dropTable('kimai2_access_token');
}
public function isTransactional(): bool
{
return false;
}
}

View File

@@ -0,0 +1,40 @@
<?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.23
*/
final class Version20240926111739 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds created_at and break duration column';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE kimai2_activities ADD created_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE kimai2_customers ADD created_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE kimai2_projects ADD created_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE kimai2_timesheet ADD break INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE kimai2_timesheet DROP break');
$this->addSql('ALTER TABLE kimai2_projects DROP created_at');
$this->addSql('ALTER TABLE kimai2_customers DROP created_at');
$this->addSql('ALTER TABLE kimai2_activities DROP created_at');
}
}

42
php-cs-fixer.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
# ----------------------------------------------------------------------
# Helper script to analyze plugins with PHP Coding Standards Fixer
# ----------------------------------------------------------------------
# Usage:
#
# ./php-cs-fixer.sh [plugin] [params]
# [plugin] - name of the plugin to analyze (without "Bundle")
# [params] - additional parameters for PHPCsFixer, e.g. --dry-run
# ----------------------------------------------------------------------
phpcsfixer() {
vendor/bin/php-cs-fixer fix --config var/plugins/$1Bundle/.php-cs-fixer.dist.php ${ARGS}
}
if [[ -n $2 ]]; then
export ARGS=$2
else
export ARGS=""
fi
if [[ -n $1 ]]; then
if [ -d "var/plugins/$1Bundle/" ]; then
phpcsfixer $1
exit
elif [ "$1" == 'core' ]; then
vendor/bin/php-cs-fixer fix --config .php-cs-fixer.dist.php ${ARGS}
exit
else
echo "Plugin $1 not found"
exit 1
fi
fi
vendor/bin/php-cs-fixer fix --config .php-cs-fixer.dist.php ${ARGS}
for dir in var/plugins/*Bundle/
do
echo ""
vendor/bin/php-cs-fixer fix --config ${dir}.php-cs-fixer.dist.php ${ARGS}
done

View File

@@ -724,16 +724,6 @@ parameters:
count: 1
path: src/Configuration/SystemConfiguration.php
-
message: "#^Method App\\\\Controller\\\\AbstractController\\:\\:getLastSearch\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Controller/AbstractController.php
-
message: "#^Method App\\\\Controller\\\\AbstractController\\:\\:getLastSearch\\(\\) should return array\\|null but returns mixed\\.$#"
count: 1
path: src/Controller/AbstractController.php
-
message: "#^Method App\\\\Controller\\\\AbstractController\\:\\:redirectToRouteAfterCreate\\(\\) has parameter \\$parameters with no value type specified in iterable type array\\.$#"
count: 1
@@ -759,31 +749,6 @@ parameters:
count: 1
path: src/Controller/Auth/SamlController.php
-
message: "#^Parameter \\#1 \\$dataTable of method App\\\\Utils\\\\ProfileManager\\:\\:getDatatableName\\(\\) expects string, bool\\|float\\|int\\|string given\\.$#"
count: 2
path: src/Controller/BookmarkController.php
-
message: "#^Parameter \\#1 \\$profile of method App\\\\Utils\\\\ProfileManager\\:\\:isValidProfile\\(\\) expects string, bool\\|float\\|int\\|string given\\.$#"
count: 3
path: src/Controller/BookmarkController.php
-
message: "#^Parameter \\#2 \\$profile of method App\\\\Utils\\\\ProfileManager\\:\\:getDatatableName\\(\\) expects string\\|null, bool\\|float\\|int\\|string given\\.$#"
count: 2
path: src/Controller/BookmarkController.php
-
message: "#^Parameter \\#2 \\$profile of method App\\\\Utils\\\\ProfileManager\\:\\:setProfile\\(\\) expects string, bool\\|float\\|int\\|string given\\.$#"
count: 2
path: src/Controller/BookmarkController.php
-
message: "#^Parameter \\#2 \\$value of class Symfony\\\\Component\\\\Security\\\\Csrf\\\\CsrfToken constructor expects string\\|null, bool\\|float\\|int\\|string given\\.$#"
count: 3
path: src/Controller/BookmarkController.php
-
message: "#^Cannot access offset 'user' on mixed\\.$#"
count: 1
@@ -1044,11 +1009,6 @@ parameters:
count: 1
path: src/Controller/Security/PasswordResetController.php
-
message: "#^Parameter \\#1 \\$token of method App\\\\User\\\\UserService\\:\\:findUserByConfirmationToken\\(\\) expects string, string\\|null given\\.$#"
count: 1
path: src/Controller/Security/PasswordResetController.php
-
message: "#^Method App\\\\Controller\\\\Security\\\\SelfRegistrationController\\:\\:getTargetUrlFromSession\\(\\) should return string\\|null but returns mixed\\.$#"
count: 1

48
phpstan.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/bin/bash
# ----------------------------------------------------------------------
# Helper script to analyze plugins with PHPStan
# ----------------------------------------------------------------------
# Usage:
#
# ./phpstan.sh [plugin] [params]
# [plugin] - name of the plugin to analyze (without "Bundle")
# [params] - additional parameters for PHPStan, e.g. --level=7 --pro
# ----------------------------------------------------------------------
phpstan() {
vendor/bin/phpstan analyse -c var/plugins/$1Bundle/phpstan.neon var/plugins/$1Bundle/ ${ARGS}
}
if [[ -n $2 ]]; then
export ARGS=$2
else
export ARGS=""
fi
if [[ -n $1 ]]; then
if [ -d "var/plugins/$1Bundle/" ]; then
phpstan $1
exit
elif [ "$1" == 'core' ]; then
vendor/bin/phpstan analyse -c phpstan.neon ${ARGS}
exit
elif [ "$1" == 'test' ]; then
vendor/bin/phpstan analyse -c tests/phpstan.neon ${ARGS}
exit
else
echo "Plugin $1 not found"
exit 1
fi
fi
vendor/bin/phpstan analyse -c phpstan.neon ${ARGS}
vendor/bin/phpstan analyse -c tests/phpstan.neon ${ARGS}
for dir in var/plugins/*Bundle/
do
echo ""
echo "=======> $dir <======="
echo ""
vendor/bin/phpstan analyse -c ${dir}phpstan.neon ${dir} ${ARGS}
done

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,10 @@
"app": {
"js": [
"/build/runtime.74179306.js",
"/build/app.7d74be47.js"
"/build/app.3463e2a4.js"
],
"css": [
"/build/app.452916c2.css"
"/build/app.75450933.css"
]
},
"app-rtl": {
@@ -15,7 +15,7 @@
"/build/app-rtl.97153087.js"
],
"css": [
"/build/app-rtl.eda88853.css"
"/build/app-rtl.dbee41f9.css"
]
},
"export-pdf": {
@@ -54,7 +54,7 @@
"calendar": {
"js": [
"/build/runtime.74179306.js",
"/build/calendar.5d5b9a76.js"
"/build/calendar.be20e8db.js"
],
"css": [
"/build/calendar.bb473428.css"
@@ -72,10 +72,10 @@
},
"integrity": {
"/build/runtime.74179306.js": "sha384-OC1hTNUXUalKJcvmzrZ0TMCOIwnhxCxgG9dQkbcwR7WcBBCkl2H8bs3giiT2pAwG",
"/build/app.7d74be47.js": "sha384-94tzNQ/1PEQ16Pt35MXKF07tFDY9hs6r6qYk+JLtXj+n/XSxyvK+LPUpNMwrmN2B",
"/build/app.452916c2.css": "sha384-45FujGXgORzo52pPLulUyi+6ESSLWOm6odL+fzTawHg+AXS+lLmNSKVRUwCfMSH0",
"/build/app.3463e2a4.js": "sha384-8Kq7fZHUdtxWd3CVdPUapwwO8qjw3/N2mP4w14TczsDI3jQ3WlJZeUyL0WPZP8QD",
"/build/app.75450933.css": "sha384-tW+c6BIV2/3j9k6AiygV7qMVFqcsYG8dNY2CzCcIuMjOf3inAYoZlGvrV8VgfR3+",
"/build/app-rtl.97153087.js": "sha384-jX7jRUAa8rH29Eg8jLIUKGfGcOT6RBz/P90plXmZPadf2CXKUBdcNGrspaejCHkr",
"/build/app-rtl.eda88853.css": "sha384-gf9zuASxTSiMOw7VlJxUcngrzF9vppfwU/VwA0bth5gtiNZj4bz0Ww2e7YGjYc7I",
"/build/app-rtl.dbee41f9.css": "sha384-52I26CWbi5SCx6Qr+s+xsgmaVX2Ybd4TVVVoEMP87laXeLbu6jtJ/4ZGwo7YDUQ0",
"/build/export-pdf.1442bee7.js": "sha384-C6agvjJnQUsMCxaZ/J7dUZU3cpC7uHKI9ZtnfGpRqYbjyw91YLy1oV3iNYgwYyCF",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.7ef8a0c8.js": "sha384-z4lZ1Ig3+NPigrRyGPZoff0gG3n5PnCjaDJ73ATnzdUYk0lOCEtNV9fg35VRD0vG",
@@ -83,7 +83,7 @@
"/build/invoice-pdf.6091a528.js": "sha384-yJz/MDBgBpNHNxF7DuSyFbJN9EL7oDJjraoPRB7VYlHMdb1T0ZfTcI0L8VMDB46U",
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
"/build/chart.80ee190c.js": "sha384-KrQszq859PR0u+KHpGy+RO6K1rcMqCFZx+sNiLVNVZHwEi7RwTXqnYbu8zvfWzUI",
"/build/calendar.5d5b9a76.js": "sha384-+3MYMJYXGkzNB5Z+Xpi7hXoOo4ZtcVFam8ibUy+wH+XjA9MskRg6JQkIr6rcZN+1",
"/build/calendar.be20e8db.js": "sha384-BBc6jbH1DUl1h85Wg8wUwbxxuQQo6geeKJMciwTF5Y3LfOgp6T2Vmv/AjPz9BP63",
"/build/calendar.bb473428.css": "sha384-900W9o8666Qpw21rLP7hn5Ql8tWd40k0IcE3QpFm7E7V6bKva7xQgGlyzVuuh4GK",
"/build/dashboard.10118fb0.js": "sha384-pasZR1GYf8/7lMFS9OrsxX8nJ9tSlRYr32fWKUhGoF2mNAGrokLEWdgtbYMRHgFB",
"/build/dashboard.18f5a8b7.css": "sha384-PBD8ftb2yBoSjRe2sN5Xb4dEz045kOEUfcIufdSis+tWoWdAx5j4Yic+F/6CYbrP"

View File

@@ -1,7 +1,7 @@
{
"build/app.css": "/build/app.452916c2.css",
"build/app.js": "/build/app.7d74be47.js",
"build/app-rtl.css": "/build/app-rtl.eda88853.css",
"build/app.css": "/build/app.75450933.css",
"build/app.js": "/build/app.3463e2a4.js",
"build/app-rtl.css": "/build/app-rtl.dbee41f9.css",
"build/app-rtl.js": "/build/app-rtl.97153087.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.1442bee7.js",
@@ -11,7 +11,7 @@
"build/invoice-pdf.js": "/build/invoice-pdf.6091a528.js",
"build/chart.js": "/build/chart.80ee190c.js",
"build/calendar.css": "/build/calendar.bb473428.css",
"build/calendar.js": "/build/calendar.5d5b9a76.js",
"build/calendar.js": "/build/calendar.be20e8db.js",
"build/dashboard.css": "/build/dashboard.18f5a8b7.css",
"build/dashboard.js": "/build/dashboard.10118fb0.js",
"build/runtime.js": "/build/runtime.74179306.js",

View File

@@ -19,7 +19,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:list', description: 'List all users')]
final class ListUserCommand extends Command
{
public function __construct(private UserRepository $repository)
public function __construct(private readonly UserRepository $repository)
{
parent::__construct();
}
@@ -35,12 +35,12 @@ final class ListUserCommand extends Command
$user->getUserIdentifier(),
$user->getEmail(),
implode(', ', $user->getRoles()),
$user->isEnabled() ? 'X' : '',
$user->getPasswordRequestedAt()?->format('Y-m-d H:i:s')
$user->isEnabled() ? 'Yes' : '-',
$user->getAuth() ?? '',
];
}
$header = ['Username', 'Email', 'Roles', 'Active', 'PW Reset'];
$header = ['Username', 'Email', 'Roles', 'Active', 'Authenticator'];
$output->table($header, $data);

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.22.0';
public const VERSION = '2.23.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 22200;
public const VERSION_ID = 22300;
/**
* The software name
*/

View File

@@ -22,7 +22,6 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -229,35 +228,15 @@ abstract class AbstractController extends BaseAbstractController implements Serv
return $this->container->get(BookmarkRepository::class);
}
private function getLastSearch(SessionInterface $session, BaseQuery $query): ?array
{
$name = 'search_' . $this->getSearchName($query);
if (!$session->has($name)) {
return null;
}
return $session->get($name);
}
private function removeLastSearch(SessionInterface $session, BaseQuery $query): void
{
$name = 'search_' . $this->getSearchName($query);
if ($session->has($name)) {
$session->remove($name);
}
}
private function getSearchName(BaseQuery $query): string
{
return substr($query->getName(), 0, 50);
}
/**
* Use "performSearch=1" to skip loading session searches.
* Use "performSearch=1" to skip loading bookmarked searches.
*
* @param array<string> $filterParams parameter names, which should not be saved (neither session, nor database)
* @param array<string> $filterParams parameter names, which should not be persisted with bookmark
* @throws \Exception
*/
protected function handleSearch(FormInterface $form, Request $request, array $filterParams = []): bool
@@ -267,7 +246,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
throw new \InvalidArgumentException('handleSearchForm() requires an instanceof BaseQuery as form data');
}
$actions = ['resetSearchFilter', 'removeDefaultQuery', 'setDefaultQuery'];
$actions = ['removeDefaultQuery', 'setDefaultQuery'];
foreach ($actions as $action) {
if ($request->query->has($action)) {
if (!$this->isCsrfTokenValid('search', $request->query->get('_token'))) {
@@ -280,13 +259,6 @@ abstract class AbstractController extends BaseAbstractController implements Serv
$request->query->remove('_token');
if ($request->query->has('resetSearchFilter')) {
$data->resetFilter();
$this->removeLastSearch($request->getSession(), $data);
return true;
}
$queryKey = null;
if (!empty($formName = $form->getConfig()->getName()) && $request->request->has($formName)) {
// allow using forms with block-prefix
@@ -300,40 +272,14 @@ abstract class AbstractController extends BaseAbstractController implements Serv
}
$searchName = $this->getSearchName($data);
$bookmarkRepo = $this->getBookmark();
$bookmark = $bookmarkRepo->getSearchDefaultOptions($this->getUser(), $searchName);
if ($bookmark !== null) {
if ($request->query->has('removeDefaultQuery')) {
if ($request->query->has('removeDefaultQuery')) {
$bookmarkRepo = $this->getBookmark();
$bookmark = $bookmarkRepo->getSearchDefaultOptions($this->getUser(), $searchName);
if ($bookmark !== null) {
$bookmarkRepo->deleteBookmark($bookmark);
$bookmark = null;
return true;
} else {
$data->setBookmark($bookmark);
}
}
// apply persisted search data ONLY if search form was not submitted manually
if (!$request->query->has('performSearch')) {
$sessionSearch = $this->getLastSearch($request->getSession(), $data);
if ($sessionSearch !== null) {
$submitData = array_merge($sessionSearch, $submitData);
} elseif ($bookmark !== null && !$request->query->has('setDefaultQuery')) {
$bookContent = $bookmark->getContent();
$isBookmarkSearch = true;
foreach ($submitData as $key => $value) {
if (!\array_key_exists($key, $bookContent) || $value !== $bookContent[$key]) {
$isBookmarkSearch = false;
break;
}
}
if ($isBookmarkSearch) {
$data->flagAsBookmarkSearch();
}
$submitData = array_merge($bookContent, $submitData);
}
return true;
}
// clean up parameters from unknown search values
@@ -343,6 +289,26 @@ abstract class AbstractController extends BaseAbstractController implements Serv
}
}
if (\count($submitData) === 0) {
$bookmark = $this->getBookmark()->getSearchDefaultOptions($this->getUser(), $searchName);
$data->setBookmark($bookmark);
// apply persisted search data ONLY if search form was not submitted manually
if ($bookmark !== null && !$request->query->has('performSearch') && !$request->query->has('setDefaultQuery')) {
$bookContent = $bookmark->getContent();
$data->flagAsBookmarkSearch();
// clean up parameters from unknown search values that were stored in an old bookmark
foreach ($bookContent as $name => $values) {
if (!$form->has($name)) {
unset($bookContent[$name]);
}
}
$submitData = $bookContent;
}
}
$form->submit($submitData, false);
if (!$form->isValid()) {
@@ -357,19 +323,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
}
// these should NEVER be saved
$filter = array_merge(['setDefaultQuery', 'removeDefaultQuery', 'performSearch'], $filterParams);
foreach ($filter as $name) {
if (isset($params[$name])) {
unset($params[$name]);
}
}
if ($request->query->has('performSearch')) {
$request->getSession()->set('search_' . $searchName, $params);
}
// filter stuff, that does not belong in a bookmark
$filter = ['page'];
$filter = array_merge(['setDefaultQuery', 'removeDefaultQuery', 'performSearch', 'page'], $filterParams);
foreach ($filter as $name) {
if (isset($params[$name])) {
unset($params[$name]);
@@ -377,7 +331,8 @@ abstract class AbstractController extends BaseAbstractController implements Serv
}
if ($request->query->has('setDefaultQuery')) {
$this->removeLastSearch($request->getSession(), $data);
$bookmark = $this->getBookmark()->getSearchDefaultOptions($this->getUser(), $searchName);
if ($bookmark === null) {
$bookmark = new Bookmark();
$bookmark->setType(Bookmark::SEARCH_DEFAULT);
@@ -386,7 +341,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
}
$bookmark->setContent($params);
$bookmarkRepo->saveBookmark($bookmark);
$this->getBookmark()->saveBookmark($bookmark);
return true;
}

View File

@@ -30,7 +30,7 @@ final class BookmarkController extends AbstractController
public const PARAM_DATATABLE = 'datatable_name';
public const PARAM_PROFILE = 'datatable_profile';
public function __construct(private BookmarkRepository $bookmarkRepository, private ProfileManager $profileManager)
public function __construct(private readonly BookmarkRepository $bookmarkRepository, private readonly ProfileManager $profileManager)
{
}
@@ -41,11 +41,11 @@ final class BookmarkController extends AbstractController
throw $this->createNotFoundException('Missing CSRF Token');
}
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, $request->request->get(self::PARAM_TOKEN_NAME)))) {
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, (string) $request->request->get(self::PARAM_TOKEN_NAME)))) {
throw $this->createAccessDeniedException('Invalid CSRF Token');
}
$profile = $request->request->get(self::PARAM_PROFILE);
$profile = (string) $request->request->get(self::PARAM_PROFILE);
if (!$this->profileManager->isValidProfile($profile)) {
throw $this->createNotFoundException('Invalid profile given');
}
@@ -63,16 +63,16 @@ final class BookmarkController extends AbstractController
throw $this->createNotFoundException('Missing data: csrf token, datatable name or profile');
}
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, $request->request->get(self::PARAM_TOKEN_NAME)))) {
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, (string) $request->request->get(self::PARAM_TOKEN_NAME)))) {
throw $this->createAccessDeniedException('Invalid CSRF Token');
}
$profile = $request->request->get(self::PARAM_PROFILE);
$profile = (string) $request->request->get(self::PARAM_PROFILE);
if (!$this->profileManager->isValidProfile($profile)) {
throw $this->createNotFoundException('Invalid profile given');
}
$datatableName = $request->request->get(self::PARAM_DATATABLE);
$datatableName = (string) $request->request->get(self::PARAM_DATATABLE);
$datatableName = $this->profileManager->getDatatableName($datatableName, $profile);
if (empty($datatableName) || mb_strlen($datatableName) > 50) {
@@ -116,16 +116,16 @@ final class BookmarkController extends AbstractController
throw $this->createNotFoundException('Missing data: csrf token, datatable name or profile');
}
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, $request->request->get(self::PARAM_TOKEN_NAME)))) {
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, (string) $request->request->get(self::PARAM_TOKEN_NAME)))) {
throw $this->createAccessDeniedException('Invalid CSRF Token');
}
$profile = $request->request->get(self::PARAM_PROFILE);
$profile = (string) $request->request->get(self::PARAM_PROFILE);
if (!$this->profileManager->isValidProfile($profile)) {
throw $this->createNotFoundException('Invalid profile given');
}
$datatableName = $request->request->get(self::PARAM_DATATABLE);
$datatableName = (string) $request->request->get(self::PARAM_DATATABLE);
$datatableName = $this->profileManager->getDatatableName($datatableName, $profile);
$bookmark = $this->bookmarkRepository->findBookmark($this->getUser(), Bookmark::COLUMN_VISIBILITY, $datatableName);

View File

@@ -40,6 +40,7 @@ use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeMargin;
use Endroid\QrCode\Writer\PngWriter;
use Psr\EventDispatcher\EventDispatcherInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
@@ -73,7 +74,12 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}', name: 'user_profile', methods: ['GET'])]
#[IsGranted('view', 'profile')]
public function indexAction(User $profile, TimesheetRepository $repository, TimesheetStatisticService $statisticService): Response
public function indexAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
TimesheetRepository $repository,
TimesheetStatisticService $statisticService
): Response
{
$dateFactory = $this->getDateTimeFactory();
$userStats = $repository->getUserStatistics($profile);
@@ -105,7 +111,12 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/edit', name: 'user_profile_edit', methods: ['GET', 'POST'])]
#[IsGranted('edit', 'profile')]
public function editAction(User $profile, Request $request, UserRepository $userRepository): Response
public function editAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserRepository $userRepository
): Response
{
$form = $this->createEditForm($profile);
$form->handleRequest($request);
@@ -134,7 +145,12 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/password', name: 'user_profile_password', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('password', 'profile')]
public function passwordAction(User $profile, Request $request, UserService $userService): Response
public function passwordAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserService $userService
): Response
{
$form = $this->createPasswordForm($profile);
$form->handleRequest($request);
@@ -158,7 +174,12 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/create-access-token', name: 'user_profile_access_token', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('api-token', 'profile')]
public function createAccessToken(User $profile, Request $request, AccessTokenRepository $accessTokenRepository): Response
public function createAccessToken(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
AccessTokenRepository $accessTokenRepository
): Response
{
$accessToken = new AccessToken($profile, substr(bin2hex(random_bytes(100)), 0, 25));
@@ -187,7 +208,13 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/api-token', name: 'user_profile_api_token', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('api-token', 'profile')]
public function apiTokenAction(User $profile, Request $request, UserService $userService, AccessTokenRepository $accessTokenRepository): Response
public function apiTokenAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserService $userService,
AccessTokenRepository $accessTokenRepository
): Response
{
$form = $this->createForm(UserApiPasswordType::class, $profile, [
'action' => $this->generateUrl('user_profile_api_token', ['username' => $profile->getUserIdentifier()]),
@@ -234,7 +261,12 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/roles', name: 'user_profile_roles', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('roles', 'profile')]
public function rolesAction(User $profile, Request $request, UserRepository $userRepository): Response
public function rolesAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserRepository $userRepository
): Response
{
$isSuperAdmin = $profile->isSuperAdmin();
@@ -266,7 +298,12 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/contract', name: 'user_profile_contract', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('contract', 'profile')]
public function contractAction(User $profile, Request $request, UserRepository $userRepository): Response
public function contractAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserRepository $userRepository
): Response
{
$form = $this->createForm(UserContractType::class, new UserContractModel($profile), [
'action' => $this->generateUrl('user_profile_contract', ['username' => $profile->getUserIdentifier()]),
@@ -292,7 +329,13 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/teams', name: 'user_profile_teams', methods: ['GET', 'POST'])]
#[IsGranted('teams', 'profile')]
public function teamsAction(User $profile, Request $request, UserRepository $userRepository, TeamRepository $teamRepository): Response
public function teamsAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserRepository $userRepository,
TeamRepository $teamRepository
): Response
{
$originalMembers = new ArrayCollection();
foreach ($profile->getMemberships() as $member) {
@@ -328,7 +371,13 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/prefs', name: 'user_profile_preferences', methods: ['GET', 'POST'])]
#[IsGranted('preferences', 'profile')]
public function preferencesAction(User $profile, Request $request, EventDispatcherInterface $dispatcher, UserRepository $userRepository): Response
public function preferencesAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
EventDispatcherInterface $dispatcher,
UserRepository $userRepository
): Response
{
// we need to prepare the user preferences, which is done via an EventSubscriber
$event = new PrepareUserEvent($profile, false);
@@ -449,7 +498,13 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/2fa', name: 'user_profile_2fa', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('2fa', 'profile')]
public function twoFactorAction(User $profile, Request $request, UserService $userService, TotpAuthenticatorInterface $totpAuthenticator): Response
public function twoFactorAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserService $userService,
TotpAuthenticatorInterface $totpAuthenticator
): Response
{
if (!$profile->hasTotpSecret()) {
$profile->setTotpSecret($totpAuthenticator->generateSecret());
@@ -509,7 +564,12 @@ final class ProfileController extends AbstractController
#[Route(path: '/{username}/2fa_deactivate', name: 'user_profile_2fa_deactivate', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('2fa', 'profile')]
public function deactivateTwoFactorAction(User $profile, Request $request, UserService $userService, TotpAuthenticatorInterface $totpAuthenticator): Response
public function deactivateTwoFactorAction(
#[MapEntity(mapping: ['username' => 'username'])]
User $profile,
Request $request,
UserService $userService
): Response
{
if ($profile->hasTotpSecret()) {
$form = $this->getTwoFactorDeactivationForm($profile);

View File

@@ -14,23 +14,25 @@ use App\Controller\AbstractController;
use App\Entity\User;
use App\Event\EmailEvent;
use App\Event\EmailPasswordResetEvent;
use App\Form\PasswordResetForm;
use App\User\LoginManager;
use App\User\UserService;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[Route(path: '/resetting')]
final class PasswordResetController extends AbstractController
{
public const CSRF_TOKEN = 'password_reset';
public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly UserService $userService,
@@ -48,6 +50,10 @@ final class PasswordResetController extends AbstractController
throw $this->createNotFoundException();
}
if ($this->isGranted('IS_AUTHENTICATED')) {
return $this->redirectToRoute('homepage');
}
return $this->render('security/password-reset/request.html.twig');
}
@@ -55,126 +61,92 @@ final class PasswordResetController extends AbstractController
* Request reset user password: submit form and send email.
*/
#[Route(path: '/send-email', name: 'resetting_send_email', methods: ['POST'])]
public function sendEmailAction(Request $request, TranslatorInterface $translator): Response
public function sendEmailAction(
Request $request,
TranslatorInterface $translator,
CsrfTokenManagerInterface $csrfTokenManager,
LoginLinkHandlerInterface $loginLinkHandler,
RateLimiterFactory $resetPasswordLimiter
): Response
{
if (!$this->configuration->isPasswordResetActive()) {
throw $this->createNotFoundException();
}
if ($this->isGranted('IS_AUTHENTICATED')) {
return $this->redirectToRoute('homepage');
}
$limiter = $resetPasswordLimiter->create($request->getClientIp());
$limit = $limiter->consume();
if (!$limit->isAccepted()) {
return new Response(null, Response::HTTP_TOO_MANY_REQUESTS);
}
$username = $request->request->get('username');
if (!\is_string($username) || trim($username) === '') {
throw $this->createAccessDeniedException('Username cannot be empty');
}
$token = $request->request->get('_csrf_token');
$user = $this->userService->findUserByUsernameOrEmail($username);
try {
$user = null;
if (!$user->isPasswordRequestNonExpired($this->configuration->getPasswordResetRetryLifetime())) {
if (!$user->isInternalUser()) {
throw $this->createAccessDeniedException(
\sprintf('The user "%s" tried to reset the password, but it is registered as "%s" auth-type.', $user->getUserIdentifier(), $user->getAuth())
);
if (\is_string($token) && $csrfTokenManager->isTokenValid(new CsrfToken(self::CSRF_TOKEN, $token))) {
if (\is_string($username) && $username !== '') {
$user = $this->userService->findUserByUsernameOrEmail($username);
}
}
if (null === $user->getConfirmationToken()) {
$user->setConfirmationToken($this->userService->generateSecurityToken());
$csrfTokenManager->refreshToken(self::CSRF_TOKEN);
// do not leak the information that this user is not registered OR cannot use this type of login
if ($user === null || !$user->isInternalUser()) {
return $this->redirectToRoute('resetting_check_email');
}
$mail = $this->generateResettingEmailMessage($user, $translator);
$event = new EmailPasswordResetEvent($user, $mail);
$this->eventDispatcher->dispatch($event);
if (!$user->isPasswordRequestNonExpired($this->configuration->getPasswordResetRetryLifetime())) {
$loginLinkDetails = $loginLinkHandler->createLoginLink($user, $request, $this->configuration->getPasswordResetRetryLifetime()); // @phpstan-ignore-line
$loginLink = $loginLinkDetails->getUrl();
// this will finally send the email
$this->eventDispatcher->dispatch(new EmailEvent($event->getEmail()));
$mail = $this->generateResettingEmailMessage($user, $translator, $loginLink);
$event = new EmailPasswordResetEvent($user, $mail);
$this->eventDispatcher->dispatch($event);
$user->markPasswordRequested();
$this->userService->saveUser($user);
// this will send the email
$this->eventDispatcher->dispatch(new EmailEvent($event->getEmail()));
$user->markPasswordRequested();
$user->setRequiresPasswordReset(true);
$this->userService->saveUser($user);
}
} catch (\Exception $ex) {
// this is an expected exception: do not log this attempt
}
return $this->redirectToRoute('resetting_check_email', ['username' => $username]);
return $this->redirectToRoute('resetting_check_email');
}
/**
* Tell the user to check his email provider.
* Tell the user to check his emails.
*/
#[Route(path: '/check-email', name: 'resetting_check_email', methods: ['GET'])]
public function checkEmailAction(Request $request): Response
public function checkEmailAction(): Response
{
if (!$this->configuration->isPasswordResetActive()) {
throw $this->createNotFoundException();
}
$username = $request->query->get('username');
if (empty($username)) {
// the user does not come from the sendEmail action
return $this->redirectToRoute('resetting_request');
}
return $this->render('security/password-reset/check_email.html.twig', [
'tokenLifetime' => $this->configuration->getPasswordResetRetryLifetime(),
]);
}
/**
* Reset user password.
*/
#[Route(path: '/reset/{token}', name: 'resetting_reset', methods: ['GET', 'POST'])]
public function resetAction(Request $request, LoginManager $loginManager, ?string $token): Response
{
if (!$this->configuration->isPasswordResetActive()) {
throw $this->createNotFoundException();
}
$user = $this->userService->findUserByConfirmationToken($token);
if (null === $user) {
return $this->redirectToRoute('login');
}
if (!$user->isPasswordRequestNonExpired($this->configuration->getPasswordResetTokenLifetime())) {
$this->flashWarning('This link has already expired');
return $this->redirectToRoute('resetting_request');
}
$form = $this->createResetForm();
$form->setData($user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user->markPasswordResetted();
$user->setEnabled(true);
$this->userService->saveUser($user);
$response = $this->redirectToRoute('my_profile');
$loginManager->logInUser($user, $response);
return $response;
}
return $this->render('security/password-reset/reset.html.twig', [
'token' => $token,
'form' => $form->createView(),
]);
}
private function createResetForm(): FormInterface
{
$options = ['validation_groups' => ['ResetPassword', 'Default']];
return $this->createFormBuilder()->create('resetting_form', PasswordResetForm::class, $options)->getForm();
}
private function generateResettingEmailMessage(User $user, TranslatorInterface $translator): Email
private function generateResettingEmailMessage(User $user, TranslatorInterface $translator, string $url): Email
{
$username = $user->getDisplayName();
$language = $user->getLanguage();
$url = $this->generateUrl('resetting_reset', ['token' => $user->getConfirmationToken()], UrlGeneratorInterface::ABSOLUTE_URL);
return (new TemplatedEmail())
->locale($language)
->to(new Address($user->getEmail()))
->subject(
$translator->trans('reset.subject', ['%username%' => $username], 'email', $language)

View File

@@ -552,7 +552,7 @@ final class Configuration implements ConfigurationInterface
->defaultTrue()
->end()
->integerNode('password_reset_retry_ttl')
->defaultValue(7200)
->defaultValue(3600)
->end()
->integerNode('password_reset_token_ttl')
->defaultValue(86400)

View File

@@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Doctrine\Behavior;
/**
* @internal
*/
interface CreatedAt
{
public function getCreatedAt(): ?\DateTimeImmutable;
public function setCreatedAt(\DateTimeImmutable $dateTime): void;
}

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Doctrine\Behavior;
use Doctrine\ORM\Mapping as ORM;
trait CreatedTrait
{
#[ORM\Column(name: 'created_at', type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $createdAt = null;
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $dateTime): void
{
$this->createdAt = $dateTime;
}
}

View File

@@ -7,9 +7,11 @@
* file that was distributed with this source code.
*/
namespace App\Doctrine;
namespace App\Doctrine\Behavior;
interface ModifiedAt
{
public function getModifiedAt(): ?\DateTimeImmutable;
public function setModifiedAt(\DateTimeImmutable $dateTime): void;
}

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Doctrine\Behavior;
use Doctrine\ORM\Mapping as ORM;
trait ModifiedTrait
{
#[ORM\Column(name: 'modified_at', type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $modifiedAt = null;
public function getModifiedAt(): ?\DateTimeImmutable
{
return $this->modifiedAt;
}
public function setModifiedAt(\DateTimeImmutable $dateTime): void
{
$this->modifiedAt = $dateTime;
}
}

View File

@@ -9,6 +9,8 @@
namespace App\Doctrine;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\ModifiedAt;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
@@ -36,12 +38,18 @@ final class ModifiedSubscriber implements EventSubscriber, DataSubscriberInterfa
if ($entity instanceof ModifiedAt) {
$entity->setModifiedAt($now);
}
if ($entity instanceof CreatedAt && $entity->getCreatedAt() === null) {
$entity->setCreatedAt($now);
}
}
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof ModifiedAt) {
$entity->setModifiedAt($now);
}
if ($entity instanceof CreatedAt && $entity->getCreatedAt() === null) {
$entity->setCreatedAt($now);
}
}
}
}

View File

@@ -9,6 +9,8 @@
namespace App\Entity;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\CreatedTrait;
use App\Export\Annotation as Exporter;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
@@ -30,10 +32,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[Exporter\Order(['id', 'name', 'project', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable', 'number'])]
#[Exporter\Expose(name: 'project', label: 'project', exp: 'object.getProject() === null ? null : object.getProject().getName()')]
#[Constraints\Activity]
class Activity implements EntityWithMetaFields, EntityWithBudget
class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
{
use BudgetTrait;
use ColorTrait;
use CreatedTrait;
/**
* Unique activity ID
@@ -122,6 +125,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
{
$this->meta = new ArrayCollection();
$this->teams = new ArrayCollection();
$this->setCreatedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
}
public function getId(): ?int
@@ -298,6 +302,8 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
$this->id = null;
}
$this->setCreatedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
$currentTeams = $this->teams;
$this->teams = new ArrayCollection();
/** @var Team $team */
@@ -305,6 +311,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
$this->addTeam($team);
}
$this->number = null;
$currentMeta = $this->meta;
$this->meta = new ArrayCollection();
/** @var ActivityMeta $meta */

View File

@@ -9,6 +9,8 @@
namespace App\Entity;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\CreatedTrait;
use App\Export\Annotation as Exporter;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
@@ -25,12 +27,13 @@ use Symfony\Component\Validator\Constraints as Assert;
#[Serializer\ExclusionPolicy('all')]
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]
#[Constraints\Customer]
class Customer implements EntityWithMetaFields, EntityWithBudget
class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
{
public const DEFAULT_CURRENCY = 'EUR';
use BudgetTrait;
use ColorTrait;
use CreatedTrait;
#[ORM\Column(name: 'id', type: 'integer')]
#[ORM\Id]
@@ -191,6 +194,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
$this->name = $name;
$this->meta = new ArrayCollection();
$this->teams = new ArrayCollection();
$this->setCreatedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
}
public function getId(): ?int
@@ -479,6 +483,8 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
$this->id = null;
}
$this->setCreatedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
$currentTeams = $this->teams;
$this->teams = new ArrayCollection();
/** @var Team $team */
@@ -486,6 +492,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
$this->addTeam($team);
}
$this->number = null;
$currentMeta = $this->meta;
$this->meta = new ArrayCollection();
/** @var CustomerMeta $meta */

View File

@@ -9,6 +9,8 @@
namespace App\Entity;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\CreatedTrait;
use App\Export\Annotation as Exporter;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
@@ -29,10 +31,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[Exporter\Order(['id', 'name', 'customer', 'orderNumber', 'orderDate', 'start', 'end', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable', 'number'])]
#[Exporter\Expose(name: 'customer', label: 'customer', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()')]
#[Constraints\Project]
class Project implements EntityWithMetaFields, EntityWithBudget
class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
{
use BudgetTrait;
use ColorTrait;
use CreatedTrait;
/**
* Unique Project ID
@@ -178,6 +181,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
{
$this->meta = new ArrayCollection();
$this->teams = new ArrayCollection();
$this->setCreatedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
}
public function getId(): ?int
@@ -474,6 +478,8 @@ class Project implements EntityWithMetaFields, EntityWithBudget
$this->id = null;
}
$this->setCreatedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
$currentTeams = $this->teams;
$this->teams = new ArrayCollection();
/** @var Team $team */

View File

@@ -9,7 +9,8 @@
namespace App\Entity;
use App\Doctrine\ModifiedAt;
use App\Doctrine\Behavior\ModifiedAt;
use App\Doctrine\Behavior\ModifiedTrait;
use App\Validator\Constraints as Constraints;
use DateTime;
use DateTimeZone;
@@ -48,6 +49,8 @@ use Symfony\Component\Validator\Constraints as Assert;
#[Constraints\TimesheetDeactivated]
class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
{
use ModifiedTrait;
/**
* Category: Normal work-time (default category)
*/
@@ -127,6 +130,8 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?int $duration = 0;
#[ORM\Column(name: 'break', type: 'integer', nullable: true)]
private ?int $break = 0;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: '`user`', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
@@ -190,8 +195,6 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
#[ORM\Column(name: 'category', type: 'string', length: 10, nullable: false, options: ['default' => 'work'])]
#[Assert\NotNull]
private ?string $category = self::WORK;
#[ORM\Column(name: 'modified_at', type: 'datetime_immutable', nullable: true)]
private \DateTimeImmutable $modifiedAt;
/**
* Tags
*
@@ -220,7 +223,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
{
$this->tags = new ArrayCollection();
$this->meta = new ArrayCollection();
$this->modifiedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
}
/**
@@ -318,12 +321,22 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
public function getCalculatedDuration(): ?int
{
if ($this->begin !== null && $this->end !== null) {
return $this->end->getTimestamp() - $this->begin->getTimestamp();
return $this->end->getTimestamp() - $this->begin->getTimestamp() - $this->getBreak();
}
return null;
}
public function getBreak(): int
{
return $this->break ?? 0;
}
public function setBreak(?int $break): void
{
$this->break = $break ?? 0;
}
public function setUser(?User $user): Timesheet
{
$this->user = $user;
@@ -550,16 +563,6 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
return $this;
}
public function getModifiedAt(): \DateTimeImmutable
{
return $this->modifiedAt;
}
public function setModifiedAt(\DateTimeImmutable $dateTime): void
{
$this->modifiedAt = $dateTime;
}
/**
* @return Collection|MetaTableTypeInterface[]
*/
@@ -607,7 +610,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
{
// this needs to be done, otherwise doctrine will not see the item as changed
// and the calculators will not run
$this->modifiedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
if (null === ($current = $this->getMetaField($meta->getName()))) {
$meta->setEntity($this);
@@ -655,8 +658,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
$this->id = null;
}
// field will not be set, if it contains a value
$this->modifiedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
$this->exported = false;
$currentMeta = $this->meta;

View File

@@ -986,31 +986,12 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
public function markPasswordRequested(): void
{
$this->setPasswordRequestedAt(new \DateTimeImmutable('now', new \DateTimeZone($this->getTimezone())));
}
public function markPasswordResetted(): void
{
$this->setConfirmationToken(null);
$this->setPasswordRequestedAt(null);
}
public function setPasswordRequestedAt(?\DateTimeImmutable $date): void
{
$this->passwordRequestedAt = $date;
}
/**
* Gets the timestamp that the user requested a password reset.
*/
public function getPasswordRequestedAt(): ?\DateTimeImmutable
{
return $this->passwordRequestedAt;
$this->passwordRequestedAt = new \DateTimeImmutable('now', new \DateTimeZone($this->getTimezone()));
}
public function isPasswordRequestNonExpired(int $seconds): bool
{
$date = $this->getPasswordRequestedAt();
$date = $this->passwordRequestedAt;
if (!($date instanceof \DateTimeInterface)) {
return false;
@@ -1159,6 +1140,10 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
public function setRequiresPasswordReset(bool $require = true): void
{
$this->setPreferenceValue('__pw_reset__', ($require ? '1' : '0'));
if (!$require) {
$this->passwordRequestedAt = null;
}
}
public function hasSeenWizard(string $wizard): bool

View File

@@ -1,48 +0,0 @@
<?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\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class PasswordResetForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'options' => [
'attr' => [
'autocomplete' => 'new-password',
],
],
'first_options' => ['label' => 'password'],
'second_options' => ['label' => 'password_repeat'],
'invalid_message' => 'The entered passwords don\'t match.',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
'csrf_token_id' => 'resetting',
]);
}
public function getBlockPrefix(): string
{
return 'password_resetting';
}
}

View File

@@ -17,7 +17,7 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class ReportSumType extends AbstractType
{
public function __construct(private AuthorizationCheckerInterface $authorizationChecker)
public function __construct(private readonly AuthorizationCheckerInterface $authorizationChecker)
{
}
@@ -30,11 +30,11 @@ final class ReportSumType extends AbstractType
]);
$resolver->setDefault('choices', function (Options $options) {
$choices = ['stats.durationTotal' => 'duration'];
$choices = ['stats.workingTime' => 'duration'];
if ($this->authorizationChecker->isGranted('view_rate_other_timesheet')) {
$choices['stats.amountTotal'] = 'rate';
$choices['internalRate'] = 'internalRate';
$choices['revenue'] = 'rate';
$choices['costs'] = 'internalRate';
}
return $choices;

View File

@@ -285,7 +285,7 @@ class BaseQuery
return $this;
}
public function setBookmark(Bookmark $bookmark): void
public function setBookmark(?Bookmark $bookmark): void
{
$this->bookmark = $bookmark;
}

View File

@@ -128,18 +128,6 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface, DateRan
$this->timesheetUser = $user;
}
/**
* @return array<int>
*/
public function getActivityIds(): array
{
return array_values(array_filter(array_unique(array_map(function (Activity $activity) {
return $activity->getId();
}, $this->activities)), function ($id) {
return $id !== null;
}));
}
/**
* @return array<Activity>
*/

View File

@@ -620,7 +620,7 @@ class TimesheetRepository extends EntityRepository
if ($query->hasActivities()) {
$qb->andWhere($qb->expr()->in('t.activity', ':activity'))
->setParameter('activity', $query->getActivityIds());
->setParameter('activity', $query->getActivities());
}
if ($query->hasProjects()) {

View File

@@ -18,7 +18,7 @@ use App\Timesheet\RoundingService;
*/
final class DurationCalculator implements CalculatorInterface
{
public function __construct(private RoundingService $roundings)
public function __construct(private readonly RoundingService $roundings)
{
}

View File

@@ -182,9 +182,8 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
return $this->getFormatter()->dayName($dateTime, $short);
}
public function getJavascriptConfiguration(?User $user = null): array
public function getJavascriptConfiguration(?User $user = null, ?string $language = null): array
{
$language = User::DEFAULT_LANGUAGE;
$browserTitle = false;
$id = null;
$name = 'anonymous';
@@ -194,7 +193,7 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
if ($user !== null) {
$browserTitle = (bool) $user->getPreferenceValue('update_browser_title');
$language = $user->getLanguage();
$language ??= $user->getLanguage();
$id = $user->getId();
$name = $user->getDisplayName();
$admin = $user->isAdmin();
@@ -202,6 +201,8 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
$timezone = $user->getTimezone();
}
$language ??= $this->locale ?? User::DEFAULT_LANGUAGE;
return [
'locale' => $this->locale,
'language' => $language,

View File

@@ -17,12 +17,11 @@ final class DateTimeFormatValidator extends ConstraintValidator
{
/**
* @param string|mixed|null $value
* @param Constraint $constraint
*/
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof DateTimeFormat)) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\DateTimeFormat');
throw new UnexpectedTypeException($constraint, DateTimeFormat::class);
}
if (!\is_string($value)) {

View File

@@ -18,7 +18,7 @@ final class HexColorValidator extends ConstraintValidator
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof HexColor) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\HexColor');
throw new UnexpectedTypeException($constraint, HexColor::class);
}
$color = $value;

View File

@@ -16,14 +16,14 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class RoleValidator extends ConstraintValidator
{
public function __construct(private RoleService $service)
public function __construct(private readonly RoleService $service)
{
}
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof Role) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\Role');
throw new UnexpectedTypeException($constraint, Role::class);
}
$roles = $value;

View File

@@ -18,12 +18,11 @@ final class TimeFormatValidator extends ConstraintValidator
{
/**
* @param string|mixed $value
* @param Constraint $constraint
*/
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimeFormat)) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\TimeFormat');
throw new UnexpectedTypeException($constraint, TimeFormat::class);
}
if (null === $value || '' === $value) {

View File

@@ -20,12 +20,11 @@ final class TimesheetMultiUpdateValidator extends ConstraintValidator
{
/**
* @param TimesheetMultiUpdateDTO|mixed $value
* @param Constraint $constraint
*/
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimesheetMultiUpdateConstraint)) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\TimesheetMultiUpdate');
throw new UnexpectedTypeException($constraint, TimesheetMultiUpdateConstraint::class);
}
if (!\is_object($value) || !($value instanceof TimesheetMultiUpdateDTO)) {
@@ -49,10 +48,6 @@ final class TimesheetMultiUpdateValidator extends ConstraintValidator
}
}
/**
* @param TimesheetMultiUpdateDTO $dto
* @param ExecutionContextInterface $context
*/
protected function validateActivityAndProject(TimesheetMultiUpdateDTO $dto, ExecutionContextInterface $context): void
{
$activity = $dto->getActivity();

View File

@@ -13,9 +13,5 @@
<spacer size="16"></spacer>
<p><small>{{ 'button_copy_url_to_browser'|trans({'%url%': confirmationUrl}, 'email') }}</small></p>
<spacer size="16"></spacer>
<p><small>{{ 'automated_email_dont_answer'|trans({}, 'email') }}</small></p>
{% endblock %}

View File

@@ -1,29 +1,15 @@
{% apply inky_to_html|inline_css(source('@css/emails.css')) %}
<style type="text/css">
body, html, .body {
background: #f3f3f3 !important;
}
.container.header {
background: #f3f3f3;
}
</style>
<spacer size="32"></spacer>
<container class="header">
<row>
<columns>
<h1 class="text-center">{% block title %}{% endblock %}</h1>
</columns>
</row>
</container>
<h5>{% block title %}{% endblock %}</h5>
<spacer size="32"></spacer>
{% block body %}{% endblock %}
<spacer size="32"></spacer>
<p><small>{{ 'automated_email_dont_answer'|trans({}, 'email') }}</small></p>
<container class="body-border">
<row>
<columns>
<spacer size="32"></spacer>
{% block body %}{% endblock %}
</columns>
</row>
</container>
{% endapply %}

View File

@@ -9,13 +9,5 @@
<spacer size="16"></spacer>
<button class="large primary" href="{{ confirmationUrl }}">{{ 'reset.button'|trans({'%url%': confirmationUrl}, 'email') }}</button>
<spacer size="16"></spacer>
<p><small>{{ 'button_copy_url_to_browser'|trans({'%url%': confirmationUrl}, 'email') }}</small></p>
<spacer size="16"></spacer>
<p><small>{{ 'automated_email_dont_answer'|trans({}, 'email') }}</small></p>
<button class="rounded primary" href="{{ confirmationUrl }}">{{ 'reset.button'|trans({'%url%': confirmationUrl}, 'email') }}</button>
{% endblock %}

View File

@@ -1,5 +1,5 @@
{% set timeBudgetVisible = is_granted('time', entity) %}
{% set durationTrans = entity.isMonthlyBudget() ? 'stats.durationMonth' : 'stats.durationTotal' %}
{% set durationTrans = entity.isMonthlyBudget() ? 'stats.durationMonth' : 'stats.workingTime' %}
{% if timeBudgetVisible %}
{% embed '@theme/embeds/card.html.twig' %}
{% import "macros/progressbar.html.twig" as progress %}
@@ -22,54 +22,29 @@
-
{% endif %}
</td>
<td class="text-nowrap text-end w-min">
{% if entity.timeBudget > 0 %}
100%
{% else %}
-
{% endif %}
</td>
</tr>
{% set totalPercentReached = 100 %}
{% if entity.timeBudget > 0 %}
{% set totalPercentReached = (stats.durationBillable / (entity.timeBudget / 100)) %}
{% endif %}
<tr>
<td>{{ durationTrans|trans }}</td>
<td class="text-nowrap text-end">{{ stats.duration|duration }}</td>
<td class="text-nowrap text-end">{{ totalPercentReached|number_format(2) }}%</td>
</tr>
{% set percentReached = 0 %}
{% if stats.duration > 0 %}
{% set percentReached = (stats.durationBillable / (stats.duration / 100)) %}
{% endif %}
<tr>
<td>{{ 'billable'|trans }}</td>
<td class="text-nowrap text-end">{{ stats.durationBillable|duration }}</td>
<td class="text-nowrap text-end">{{ percentReached|number_format(2) }}%</td>
</tr>
<tr>
<td>{{ 'not_exported'|trans }}</td>
<td class="text-nowrap text-end">
{% set not_exported = (stats.statisticTotal.durationBillable - stats.statisticTotal.durationBillableExported) %}
{% if export_url is defined and export_url is not null %}
<a href="{{ export_url }}">{{ not_exported|duration }}</a>
{% else %}
{{ not_exported|duration }}
{% endif %}
</td>
<td class="text-nowrap text-end">-</td>
</tr>
{% if entity.timeBudget > 0 %}
<tr>
<th colspan="3" class="text-nowrap text-end">
{% if totalPercentReached < 100 %}
{{ 'stats.percentUsedLeft'|trans({'%percent%': totalPercentReached|number_format(2), '%left%': (entity.timeBudget - stats.durationBillable)|duration}) }}
{% else %}
{{ 'stats.percentUsed'|trans({'%percent%': totalPercentReached|number_format(2)}) }}
{% endif %}
</th>
</tr>
{% set totalPercentReached = 100 %}
{% if entity.timeBudget > 0 %}
{% set totalPercentReached = (stats.durationBillable / (entity.timeBudget / 100)) %}
{% endif %}
<tr>
<th colspan="2" class="text-nowrap text-end">
{% if totalPercentReached < 100 %}
{{ 'stats.percentUsedLeft'|trans({'%percent%': totalPercentReached|number_format(2), '%left%': (entity.timeBudget - stats.durationBillable)|duration}) }}
{% else %}
{{ 'stats.percentUsed'|trans({'%percent%': totalPercentReached|number_format(2)}) }}
{% endif %}
</th>
</tr>
{% endif %}
</table>
</div>
@@ -93,7 +68,6 @@
{% block box_body_class %}p-0{% endblock %}
{% block box_attributes %}id="budget_box"{% endblock %}
{% block box_body %}
{% set rateTrans = entity.isMonthlyBudget() ? 'stats.amountMonth' : 'stats.amountTotal' %}
<div class="row">
<div class="col-12 col-md-6 order-1 order-md-0">
<table class="table table-hover dataTable">
@@ -106,80 +80,45 @@
-
{% endif %}
</td>
<td class="text-nowrap text-end w-min">
{% if entity.budget > 0 %}
100%
{% else %}
-
{% endif %}
</td>
</tr>
{% set totalPercentReached = 100 %}
{% if entity.budget > 0 %}
{% set totalPercentReached = (stats.rateBillable / (entity.budget / 100)) %}
{% endif %}
<tr>
<td>{{ rateTrans|trans }}</td>
<td class="text-nowrap text-end">{{ stats.rate|money(currency) }}</td>
<td class="text-nowrap text-end">{{ totalPercentReached|number_format(2) }}%</td>
</tr>
{% set percentReached = 0 %}
{% if stats.rate > 0 %}
{% set percentReached = (stats.rateBillable / (stats.rate / 100)) %}
{% endif %}
<tr>
<td>{{ 'billable'|trans }}</td>
<td>{{ 'revenue'|trans }}</td>
<td class="text-nowrap text-end">{{ stats.rateBillable|money(currency) }}</td>
<td class="text-nowrap text-end">{{ percentReached|number_format(2) }}%</td>
</tr>
<tr>
<td>{{ 'not_invoiced'|trans }}</td>
<td class="text-nowrap text-end">
{% set not_exported = (stats.statisticTotal.rateBillable - stats.statisticTotal.rateBillableExported) %}
{% if invoice_url is defined and invoice_url is not null %}
<a href="{{ invoice_url }}">{{ not_exported|money(currency) }}</a>
{% else %}
{{ not_exported|money(currency) }}
{% endif %}
</td>
<td class="text-nowrap text-end">-</td>
</tr>
{% set percentReached = 0 %}
{% if stats.rateBillable > 0 %}
{% set percentReached = (stats.internalRate / (stats.rateBillable / 100)) %}
{% endif %}
<tr>
<td>{{ 'internalRate'|trans }}</td>
<td>{{ 'costs'|trans }}</td>
<td class="text-nowrap text-end">{{ stats.internalRate|money(currency) }}</td>
<td class="text-nowrap text-end">{{ percentReached|number_format(2) }}%</td>
</tr>
{% if not entity.isMonthlyBudget() and timeBudgetVisible and stats.duration > 0 and stats.duration|chart_duration > 0.00 %}
{% set revenueTotal = stats.rateBillable - stats.internalRate %}
{% if stats.internalRate > 0 %}
{% set revenueTotal = stats.rateBillable - stats.internalRate %}
<tr>
<td>{{ 'stats.revenue'|trans }} ({{ 'billable'|trans }} - {{ 'internalRate'|trans }})</td>
<td>{{ 'profit'|trans }} ({{ 'revenue'|trans }} - {{ 'costs'|trans }})</td>
<td class="text-nowrap text-end">{{ revenueTotal|money(currency) }}</td>
<td class="text-nowrap text-end">-</td>
</tr>
{% endif %}
{% set realHourlyRate = stats.rateBillable / stats.duration|chart_duration %}
<tr>
<td>{{ 'hourlyRate'|trans }} ({{ 'billable'|trans }} / {{ durationTrans|trans }})</td>
<td class="text-nowrap text-end">{{ realHourlyRate|money(currency) }}</td>
<td class="text-nowrap text-end">-</td>
</tr>
{% if stats.internalRate > 0 %}
{% set revenueHourlyRate = revenueTotal / stats.duration|chart_duration %}
{% if stats.duration > 0 %}
{% set realHourlyRate = stats.rateBillable / stats.duration|chart_duration %}
<tr>
<td>{{ 'hourlyRate'|trans }} ({{ 'stats.revenue'|trans }} / {{ durationTrans|trans }})</td>
<td class="text-nowrap text-end">{{ revenueHourlyRate|money(currency) }}</td>
<td class="text-nowrap text-end">-</td>
<td>{{ 'hourlyRate'|trans }} ({{ 'revenue'|trans }} / {{ durationTrans|trans }})</td>
<td class="text-nowrap text-end">{{ realHourlyRate|money(currency) }}</td>
</tr>
{% if revenueTotal != 0.0 %}
{% set revenueHourlyRate = revenueTotal / stats.duration|chart_duration %}
<tr>
<td>{{ 'hourlyRate'|trans }} ({{ 'profit'|trans }} / {{ durationTrans|trans }})</td>
<td class="text-nowrap text-end">{{ revenueHourlyRate|money(currency) }}</td>
</tr>
{% endif %}
{% endif %}
{% endif %}
{% if entity.budget > 0 %}
{% set totalPercentReached = 100 %}
{% if entity.budget > 0 %}
{% set totalPercentReached = (stats.rateBillable / (entity.budget / 100)) %}
{% endif %}
<tr>
<th colspan="3" class="text-nowrap text-end">
<th colspan="2" class="text-nowrap text-end">
{% if totalPercentReached < 100 %}
{{ 'stats.percentUsedLeft'|trans({'%percent%': totalPercentReached|number_format(2), '%left%': (entity.budget - stats.rateBillable)|money(currency)}) }}
{% else %}
@@ -192,10 +131,9 @@
</div>
<div class="col-12 col-md-6 order-0 order-md-1 p-3">
{% if entity.budget > 0 %}
<div class="mb-3">{{ progress.progressbar(entity.budget, stats.rateBillable, rateTrans|trans, stats.rateBillable|money(currency) ~ ' / ' ~ entity.budget|money(currency)) }}</div>
<div class="mb-3">{{ progress.progressbar(entity.budget, stats.rateBillable, 'revenue'|trans, stats.rateBillable|money(currency) ~ ' / ' ~ entity.budget|money(currency)) }}</div>
{% endif %}
<div class="mb-3">{{ progress.progressbar(stats.rate, stats.rateBillable, 'billable'|trans, stats.rateBillable|money(currency) ~ ' / ' ~ stats.rate|money(currency), true) }}</div>
<div class="mb-3">{{ progress.progressbar(stats.rateBillable, stats.internalRate, 'internalRate'|trans, stats.internalRate|money(currency) ~ ' / ' ~ stats.rateBillable|money(currency)) }}</div>
<div class="mb-3">{{ progress.progressbar(stats.rateBillable, stats.internalRate, 'costs'|trans, stats.internalRate|money(currency) ~ ' / ' ~ stats.rateBillable|money(currency)) }}</div>
</div>
</div>
{% endblock %}

View File

@@ -77,9 +77,9 @@
{% endif %}
{% if filterCount > 0 and not form.vars.data.isBookmarkSearch() %}
<button class="{{ searchBtnClass }} text-orange" type="submit" id="resetSearchFilter" name="resetSearchFilter" data-toggle="tooltip" title="{{ 'remove_filter'|trans }}">
<a class="{{ searchBtnClass }} text-orange" href="{{ path(app.request.attributes.get('_route')) }}" data-toggle="tooltip" title="{{ 'remove_filter'|trans }}">
{{ icon('cancel') }}
</button>
</a>
{% endif %}
</span>
</div>

View File

@@ -16,7 +16,7 @@
</button>
{% endif %}
{% if form.vars.data.countFilter() > 0 and not form.vars.data.isBookmarkSearch() %}
<button type="submit" id="resetSearchFilter" name="resetSearchFilter" class="btn btn-icon btn-outline-secondary" data-toggle="tooltip" title="{{ 'remove_filter'|trans }}">{{ icon('cancel', true) }}</button>
<a href="{{ path(app.request.attributes.get('_route')) }}" class="btn btn-icon btn-outline-secondary" data-toggle="tooltip" title="{{ 'remove_filter'|trans }}">{{ icon('cancel', true) }}</a>
{% endif %}
</div>
<button type="submit" name="performSearch" value="performSearch" class="performSearch btn btn-primary ms-auto" data-type="submit">{{ 'search'|trans }}</button>

View File

@@ -1,6 +1,6 @@
{% macro init_frontend_loader() %}
{% set fdow = app.user is not null ? app.user.firstDayOfWeek : 'monday' %}
{% set configurations = javascript_configurations(app.user)|merge({
{% set configurations = javascript_configurations(app.user, app.request.locale)|merge({
login: path('login'),
direction: tabler_bundle.rightToLeft ? 'rtl' : 'ltr',
first_dow_iso: iso_day_by_name(fdow),

View File

@@ -542,14 +542,6 @@
</div>
{% endmacro %}
{% macro percent(maximum, current) %}
{% if maximum > 0 %}
{{ (current / (maximum / 100))|number_format(2) }} %
{% else %}
{{ 0|number_format(2) }} %
{% endif %}
{% endmacro %}
{% macro page_reloader(events, full_reload) %}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {

View File

@@ -1,41 +1,30 @@
{% extends 'reporting/layout.html.twig' %}
{% import "macros/datatables.html.twig" as tables %}
{% set showMoneyBudget = is_granted('budget_money', 'project') %}
{% set showTimeBudget = is_granted('budget_time', 'project') %}
{% set viewRevenue = is_granted('view_rate_other_timesheet') %}
{% set columns = {
'name': {'class': 'alwaysVisible'},
'name': {'class': 'alwaysVisible'},
} %}
{% if showTimeBudget %}
{% if is_granted('budget_time', 'project') %}
{% set columns = columns|merge({
'timeBudget': {'class': 'd-none d-md-table-cell', 'title': 'timeBudget'|trans},
'timeBudget': {'class': 'd-none d-md-table-cell', 'title': 'timeBudget'|trans},
}) %}
{% endif %}
{% if showMoneyBudget %}
{% if is_granted('budget_money', 'project') %}
{% set columns = columns|merge({
'budget': {'class': 'd-none d-md-table-cell', 'title': 'budget'|trans},
'budget': {'class': 'd-none d-md-table-cell', 'title': 'budget'|trans},
}) %}
{% endif %}
{% set columns = columns|merge({
'duration': {'class': 'text-end hw-min w-min', 'title': 'stats.durationMonth'|trans, 'columnClass': 'w-min'},
'duration': {'class': 'text-center hw-min', 'title': 'duration'|trans, 'columnClass': 'w-min'},
}) %}
{% if showMoneyBudget %}
{% if is_granted('view_rate_other_timesheet') %}
{% set columns = columns|merge({
'rate': {'class': 'text-end hw-min w-min', 'title': 'stats.amountMonth'|trans, 'columnClass': 'w-min'},
'revenue': {'class': 'text-center hw-min', 'columnClass': 'w-min'},
'costs': {'class': 'text-center hw-min', 'columnClass': 'w-min'},
'profit': {'class': 'text-center hw-min', 'columnClass': 'w-min'},
}) %}
{% endif %}
{% set columns = columns|merge({
'duration': {'class': 'text-center hw-min', 'title': 'stats.durationMonth'|trans, 'columnClass': 'w-min'},
}) %}
{% if viewRevenue %}
{% set columns = columns|merge({
'rate': {'class': 'text-center hw-min', 'title': 'stats.amountMonth'|trans, 'columnClass': 'w-min'},
}) %}
{% endif %}
{% set columns = columns|merge({
'billable': {'class': 'd-none d-sm-table-cell text-center hw-min w-min', 'columnClass': 'w-min'},
'actions': {'class': 'actions alwaysVisible'},
}) %}
{% set tableName = 'project_daterange_reporting' %}
@@ -95,10 +84,12 @@
{{ widgets.label_project(project) }}
{% elseif name == 'duration' %}
{{ entry.statistic.duration|duration }}
{% elseif name == 'rate' %}
{{ entry.statistic.rate|money(currency) }}
{% elseif name == 'billable' %}
{{ widgets.percent(entry.statistic.rate, entry.statistic.rateBillable) }}
{% elseif name == 'revenue' %}
{{ entry.statistic.rateBillable|money(currency) }}
{% elseif name == 'costs' %}
{{ entry.statistic.internalRate|money(currency) }}
{% elseif name == 'profit' %}
{{ (entry.statistic.rateBillable - entry.statistic.internalRate)|money(currency) }}
{% elseif name == 'timeBudget' %}
{% if is_granted('time', project) %}
{{ progress.progressbar_timebudget(entry) }}

View File

@@ -98,7 +98,7 @@
<ul class="nav nav-pills" data-bs-toggle="tabs">
<li class="nav-item"><a class="nav-link active" data-chart="{{ chartPrefix }}Duration" href="#time-chart-{{ id }}" data-bs-toggle="tab">{{ 'stats.workingTime'|trans }}</a></li>
{% if view_revenue %}
<li class="nav-item"><a class="nav-link" data-chart="{{ chartPrefix }}Rate" href="#revenue-chart-{{ id }}" data-bs-toggle="tab">{{ 'stats.revenue'|trans }}</a></li>
<li class="nav-item"><a class="nav-link" data-chart="{{ chartPrefix }}Rate" href="#revenue-chart-{{ id }}" data-bs-toggle="tab">{{ 'revenue'|trans }}</a></li>
{% endif %}
{% if see_users %}
<li class="nav-item"><a class="nav-link" data-chart="{{ chartPrefix }}User" href="#user-chart-{{ id}}" data-bs-toggle="tab">{{ 'user'|trans }}</a></li>
@@ -307,7 +307,7 @@
<li class="nav-item"><a class="nav-link" data-chart="{{ chartPrefix }}Duration" href="#time-chart" data-bs-toggle="tab">{{ 'stats.workingTime'|trans }}</a></li>
{% endif %}
{% if showTotalRevenueChart %}
<li class="nav-item"><a class="nav-link" data-chart="{{ chartPrefix }}Rate" href="#revenue-chart" data-bs-toggle="tab">{{ 'stats.revenue'|trans }}</a></li>
<li class="nav-item"><a class="nav-link" data-chart="{{ chartPrefix }}Rate" href="#revenue-chart" data-bs-toggle="tab">{{ 'revenue'|trans }}</a></li>
{% endif %}
{% if see_users and project_details.userStats|length > 0 %}
<li class="nav-item"><a class="nav-link" data-chart="{{ chartPrefix }}User" href="#user-chart" data-bs-toggle="tab">{{ 'user'|trans }}</a></li>

View File

@@ -17,9 +17,9 @@
'amountTotal': {'title': 'stats.amountTotal'|trans},
'billableMoney': {'title': 'billable'|trans},
'internalRate': {'title': 'internalRate'|trans},
'revenue': {'title': ('stats.revenue'|trans ~ ' (' ~ 'billable'|trans ~ ' - ' ~ 'internalRate'|trans ~ ')')},
'revenue': {'title': ('revenue'|trans ~ ' (' ~ 'billable'|trans ~ ' - ' ~ 'internalRate'|trans ~ ')')},
'hourlyBillable': {'title': ('hourlyRate'|trans ~ ' (' ~ 'billable'|trans ~ ' / ' ~ 'stats.durationTotal'|trans ~ ')')},
'hourlyTotal': {'title': ('hourlyRate'|trans ~ ' (' ~ 'stats.revenue'|trans ~ ' / ' ~ 'stats.durationTotal'|trans ~ ')')},
'hourlyTotal': {'title': ('hourlyRate'|trans ~ ' (' ~ 'revenue'|trans ~ ' / ' ~ 'stats.durationTotal'|trans ~ ')')},
'invoiced': {'title': 'not_invoiced'|trans},
}) %}
{% endif %}

View File

@@ -1,4 +1,5 @@
{%- set with_avatar = avatar is defined ? avatar : true -%}
{%- set show_empty_rows = true -%}
{%- set absoluteDuration = 0 -%}
{%- set absoluteInternalRate = 0 -%}
{%- set absoluteRate = 0 -%}
@@ -38,7 +39,7 @@
{% set usersTotalDuration = 0 %}
{% set usersTotalInternalRate = 0 %}
{% set usersTotalRate = 0 %}
{% for period in attribute(userPeriod, period_attribute) %}
{% for period in userPeriod.data %}
{% set usersTotalDuration = usersTotalDuration + period.totalDuration %}
{% set absoluteDuration = absoluteDuration + period.totalDuration %}
{% set usersTotalInternalRate = usersTotalInternalRate + period.totalInternalRate %}
@@ -50,7 +51,7 @@
{% set totalsInternalRate = totalsInternalRate|merge({(reportDateKey): (totalsInternalRate[reportDateKey] + period.totalInternalRate)}) %}
{% set totalsRate = totalsRate|merge({(reportDateKey): (totalsRate[reportDateKey] + period.totalRate)}) %}
{% endfor %}
{% if userPeriod.user.enabled or usersTotalDuration != 0 %}
{% if (show_empty_rows and userPeriod.user.enabled) or usersTotalDuration != 0 %}
<tr class="user">
{% if with_avatar %}
<td class="w-avatar">
@@ -73,7 +74,7 @@
{% block total_duration_user %}{% endblock %}
{% endif %}
</td>
{% for period in attribute(userPeriod, period_attribute) %}
{% for period in userPeriod.data %}
<td class="text-nowrap text-center day-total{% block period_cell_class %}{% endblock %}"{% block period_cell_attribute %}{% endblock %}>
{% if period.totalDuration != 0 or period.totalRate != 0 or period.totalInternalRate != 0 %}
{% if dataType == 'rate' %}

View File

@@ -1,16 +0,0 @@
{% extends 'security/password-reset/layout.html.twig' %}
{% block login_form %}
{{ form_start(form, { 'action': path('resetting_reset', {'token': token}), 'attr': { 'class': 'resetting_reset' } }) }}
{{ form_widget(form) }}
<div class="row">
<div class="col-xs-12">
<button type="submit" class="btn btn-primary">{{ 'Reset your password'|trans({}, 'TablerBundle') }}</button>
</div>
</div>
{{ form_end(form) }}
{% endblock %}

View File

@@ -32,12 +32,6 @@
'stats.durationTotal' : stats.durationTotal|duration,
'stats.durationMonth' : stats.durationThisMonth|duration
}) %}
{#% if seeOwnRate %}
{% set datagrid = datagrid|merge({
'stats.amountMonth' : stats.rateThisMonthBillable|money,
'stats.amountTotal' : stats.rateTotalBillable|money
}) %}
{% endif %#}
{% endif %}
{% if not seeOwnRate and is_granted('hourly-rate', user) and user.preferenceValue('hourly_rate') is not null %}
{% set datagrid = datagrid|merge({

View File

@@ -25,8 +25,8 @@ class ListUserCommandTest extends KernelTestCase
{
$commandTester = $this->getCommandTester();
$output = $commandTester->getDisplay();
$this->assertStringContainsString('Username Email Roles Active PW Reset', $output);
$this->assertStringContainsString('---------- ------- ------- -------- ----------', $output);
$this->assertStringContainsString('Username Email Roles Active Authenticator', $output);
$this->assertStringContainsString('---------- ------- ------- -------- -------------', $output);
}
protected function getCommandTester(): CommandTester

View File

@@ -16,7 +16,7 @@ use App\Tests\Controller\ControllerBaseTest;
*/
class PasswordResetControllerTest extends ControllerBaseTest
{
private function testResetActionWithDeactivatedFeature(string $route, string $method = 'GET')
private function testResetActionWithDeactivatedFeature(string $route, string $method = 'GET'): void
{
$client = self::createClient();
$this->setSystemConfiguration('user.password_reset', false);
@@ -39,11 +39,6 @@ class PasswordResetControllerTest extends ControllerBaseTest
$this->testResetActionWithDeactivatedFeature('/resetting/check-email');
}
public function testResetWithDeactivatedFeature(): void
{
$this->testResetActionWithDeactivatedFeature('/resetting/reset/1234567890');
}
public function testResetRequestPageIsRendered(): void
{
$client = self::createClient();
@@ -55,10 +50,11 @@ class PasswordResetControllerTest extends ControllerBaseTest
$this->assertTrue($response->isSuccessful());
$content = $response->getContent();
$this->assertNotFalse($content);
$this->assertStringContainsString('<title>Kimai Time Tracking</title>', $content);
$this->assertStringContainsString('Reset your password', $content);
$this->assertStringContainsString('<form class="card-body security-password-reset" action="/en/resetting/send-email" method="post" autocomplete="off">', $content);
$this->assertStringContainsString('<input type="text"', $content);
$this->assertStringContainsString('<input autocomplete="username" type="text"', $content);
$this->assertStringContainsString('id="username" name="username" required="required"', $content);
$this->assertStringContainsString('Reset your password', $content);
@@ -67,14 +63,94 @@ class PasswordResetControllerTest extends ControllerBaseTest
'username' => 'john_user',
]);
$this->assertIsRedirect($client, $this->createUrl('/resetting/check-email?username=john_user'));
$this->assertIsRedirect($client, $this->createUrl('/resetting/check-email'));
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$user = $this->loadUserFromDatabase('john_user');
$token = $user->getConfirmationToken();
// TODO test the actual email and provided login link
$this->request($client, '/resetting/reset/' . $token);
$user = $this->loadUserFromDatabase('john_user');
$this->assertTrue($user->requiresPasswordReset());
}
public function testRequestAsLoggedInUserRedirects(): void
{
$client = $this->getClientForAuthenticatedUser();
$this->request($client, '/resetting/request');
$this->assertIsRedirect($client, $this->createUrl('/homepage'));
}
public function testResetAsLoggedInUserRedirects(): void
{
$client = $this->getClientForAuthenticatedUser();
$this->request($client, '/resetting/send-email', 'POST');
$this->assertIsRedirect($client, $this->createUrl('/homepage'));
}
public function testResetWithMissingUsername(): void
{
$client = self::createClient();
$this->request($client, '/resetting/send-email', 'POST');
$this->assertIsRedirect($client, $this->createUrl('/resetting/check-email'));
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
$this->assertNotFalse($content);
$this->assertStringContainsString('An email has been sent with a link to reset your password.', $content);
$this->assertStringContainsString('Note: You can only request a new password once every 1:00 hours.', $content);
}
public function testResetWithEmptyUsername(): void
{
$client = self::createClient();
$this->request($client, '/resetting/send-email', 'POST', ['username' => '']);
$this->assertIsRedirect($client, $this->createUrl('/resetting/check-email'));
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
$this->assertNotFalse($content);
$this->assertStringContainsString('An email has been sent with a link to reset your password.', $content);
$this->assertStringContainsString('Note: You can only request a new password once every 1:00 hours.', $content);
}
public function testResetWithUnknownUsername(): void
{
$client = self::createClient();
$this->request($client, '/resetting/send-email', 'POST', ['username' => 'foobar']);
$this->assertIsRedirect($client, $this->createUrl('/resetting/check-email'));
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
$this->assertNotFalse($content);
$this->assertStringContainsString('An email has been sent with a link to reset your password.', $content);
$this->assertStringContainsString('Note: You can only request a new password once every 1:00 hours.', $content);
}
public function testResetWithKnownUsername(): void
{
$client = self::createClient();
$user = $this->loadUserFromDatabase('john_user');
$this->assertFalse($user->requiresPasswordReset());
$this->request($client, '/resetting/request');
$this->assertTrue($client->getResponse()->isSuccessful());
$form = $client->getCrawler()->filter('form')->form();
$client->submit($form, [
'username' => 'john_user',
]);
$this->assertIsRedirect($client, $this->createUrl('/resetting/check-email'));
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$content = $client->getResponse()->getContent();
$this->assertNotFalse($content);
$this->assertStringContainsString('An email has been sent with a link to reset your password.', $content);
$this->assertStringContainsString('Note: You can only request a new password once every 1:00 hours.', $content);
// TODO test the actual email and provided login link
$user = $this->loadUserFromDatabase('john_user');
$this->assertTrue($user->requiresPasswordReset());
}
}

View File

@@ -46,8 +46,8 @@ class SecurityControllerTest extends ControllerBaseTest
$content = $response->getContent();
$this->assertStringContainsString('<title>Kimai Time Tracking</title>', $content);
$this->assertStringContainsString('<form action="/en/login_check" method="post"', $content);
$this->assertStringContainsString('<input type="text" id="username" name="_username"', $content);
$this->assertStringContainsString('<input id="password" name="_password" type="password"', $content);
$this->assertStringContainsString('<input autocomplete="username" type="text" id="username" name="_username"', $content);
$this->assertStringContainsString('<input autocomplete="new-password" id="password" name="_password" type="password"', $content);
$this->assertStringContainsString('">Log in</button>', $content);
$this->assertStringContainsString('<input type="hidden" name="_csrf_token" value="', $content);
$this->assertStringNotContainsString('<a href="/en/register/"', $content);

View File

@@ -298,7 +298,7 @@ class ConfigurationTest extends TestCase
'registration' => false,
'password_reset' => true,
'login' => true,
'password_reset_retry_ttl' => 7200,
'password_reset_retry_ttl' => 3600,
'password_reset_token_ttl' => 86400,
],
'invoice' => [

View File

@@ -41,7 +41,6 @@ class UserTest extends TestCase
self::assertNull($user->getAccountNumber());
self::assertNull($user->getApiToken());
self::assertNull($user->getPlainApiToken());
self::assertNull($user->getPasswordRequestedAt());
self::assertFalse($user->hasTotpSecret());
self::assertNull($user->getTotpSecret());
self::assertEquals(User::DEFAULT_LANGUAGE, $user->getLanguage());
@@ -50,6 +49,7 @@ class UserTest extends TestCase
self::assertFalse($user->canSeeAllData());
self::assertFalse($user->isExportDecimal());
self::assertFalse($user->isSystemAccount());
self::assertFalse($user->isPasswordRequestNonExpired(3599));
$user->setUserIdentifier('foo');
self::assertEquals('foo', $user->getUserIdentifier());
@@ -227,20 +227,6 @@ class UserTest extends TestCase
self::assertEquals($date, $user->getRegisteredAt());
}
public function testPasswordRequestedAt(): void
{
$date = new \DateTimeImmutable('-60 minutes');
$sut = new User();
self::assertFalse($sut->isPasswordRequestNonExpired(3599));
self::assertNull($sut->getPasswordRequestedAt());
$sut->setPasswordRequestedAt($date);
self::assertEquals($date, $sut->getPasswordRequestedAt());
self::assertFalse($sut->isPasswordRequestNonExpired(3599));
// 10 seconds just to make sure it doesn't expire by accident
self::assertTrue($sut->isPasswordRequestNonExpired(3610));
}
public function testPreferences(): void
{
$user = new User();

View File

@@ -225,7 +225,6 @@ class BaseQueryTest extends TestCase
$sut->addActivity($expected2);
$this->assertEquals([$expected, $expected2], $sut->getActivities());
$this->assertEquals([], $sut->getActivityIds());
$sut->setActivities([]);
$this->assertEquals([], $sut->getActivities());
@@ -247,7 +246,11 @@ class BaseQueryTest extends TestCase
$activity->method('getId')->willReturn(27);
$sut->addActivity($activity);
$this->assertEquals([13, 27], $sut->getActivityIds());
$ids = [];
foreach ($sut->getActivities() as $activity) {
$ids[] = $activity->getId();
}
$this->assertEquals([13, 27, null, 27], $ids);
}
protected function assertCustomer(BaseQuery $sut): void

View File

@@ -24,7 +24,7 @@ class ExportQueryTest extends TimesheetQueryTest
$this->assertPage($sut);
$this->assertPageSize($sut);
$this->assertOrderBy($sut, 'begin');
$this->assertOrder($sut, ExportQuery::ORDER_ASC);
$this->assertOrder($sut);
$this->assertUser($sut);
$this->assertCustomer($sut);

View File

@@ -24,7 +24,7 @@ class InvoiceQueryTest extends TimesheetQueryTest
$this->assertPage($sut);
$this->assertPageSize($sut);
$this->assertOrderBy($sut, 'begin');
$this->assertOrder($sut, InvoiceQuery::ORDER_ASC);
$this->assertOrder($sut);
$this->assertUser($sut);
$this->assertCustomer($sut);

View File

@@ -1269,16 +1269,6 @@ parameters:
count: 1
path: Controller/Reporting/ProjectViewControllerTest.php
-
message: "#^Method App\\\\Tests\\\\Controller\\\\Security\\\\PasswordResetControllerTest\\:\\:testResetActionWithDeactivatedFeature\\(\\) has no return type specified\\.$#"
count: 1
path: Controller/Security/PasswordResetControllerTest.php
-
message: "#^Parameter \\#2 \\$haystack of method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#"
count: 6
path: Controller/Security/PasswordResetControllerTest.php
-
message: "#^Parameter \\#2 \\$haystack of method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#"
count: 6

View File

@@ -18,10 +18,6 @@
<source>registration.button</source>
<target>فعل حسابك</target>
</trans-unit>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target state="translated">إذا كان الزر لا يعمل ، يرجى نسخ &amp; والصق عنوان URL التالي في متصفحك:%url%</target>
</trans-unit>
<trans-unit id="IkjnHoP" resname="registration.intro">
<source>registration.intro</source>
<target state="translated">لقد قمت بالتسجيل في متتبع الوقت Kimai بالبريد الإلكتروني%email%. يرجى تفعيل حسابك في الساعات القليلة القادمة ، قبل انتهاء صلاحية الرابط.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="cs" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target>V případě, že nefunguje tlačítko, zkopírujte prosím &amp; následující adresu URL do vašeho prohlížeče: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target>Toto je automaticky generovaný e-mail. Prosím neodpovídejte, e-mail příjemce není sledován.</target>

View File

@@ -30,10 +30,6 @@
<source>Don't worry, Kimai will help you create a new one!</source>
<target state="needs-translation">... så du har glemt dit kodeord? Intet problem: Kimai hjælper dig med at oprette et nyt:</target>
</trans-unit>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser" xml:space="preserve">
<source>button_copy_url_to_browser</source>
<target state="translated">Hvis knappen ikke virker så kopier følgende URL i og indsæt den i din browser: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer" xml:space="preserve">
<source>automated_email_dont_answer</source>
<target state="translated">Dette er en automatisk genereret e-mail. Svar venligst ikke på den, modtager postkassen bliver ikke læst.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="de" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" approved="yes" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target>Falls der Button nicht funktioniert, kopieren Sie bitte die folgende URL in Ihren Browser: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target>Dies ist eine automatisch generierte E-Mail. Bitte antworten Sie nicht, die Adresse wird nicht gelesen.</target>
@@ -28,19 +24,19 @@
</trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source>
<target>Passwort zurücksetzen</target>
<target>Anmelden bei Kimai</target>
</trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve" approved="yes">
<source>Forgot your password?</source>
<target state="final">Passwort vergessen?</target>
<target state="final">Bei Ihrem Konto anmelden</target>
</trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source>
<target>Passwort zurücksetzen</target>
<target>Anmelden</target>
</trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve" approved="yes">
<source>Don't worry, Kimai will help you create a new one!</source>
<target state="final">Keine Sorge: Kimai hilft dir, ein neues zu erstellen!</target>
<target>Dieser Link ist nur für die nächsten 15 Minuten gültig.</target>
</trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject" xml:space="preserve">
<source>absence_created_supervisor_subject</source>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="de-CH" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target state="translated">Wenn die Schaltfläche nicht funktioniert, kopieren Sie bitte die folgende URL und fügen sie in Ihren Browser ein: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target state="translated">Dies ist eine automatisch generierte E-Mail. Bitte nicht antworten, E-Mails an diese Adresse werden nicht gelesen.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="el" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target>Εάν το κουμπί δεν λειτουργεί, παρακαλούμε αντιγράψτε &amp; και επικολλήστε την ακόλουθη διεύθυνση URL στο πρόγραμμα περιήγησης: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target>Αυτό είναι ένα αυτοματοποιημένο e-mail. Παρακαλούμε μην απαντήσετε, το περιεχόμενο δεν θα αναγνωσθεί.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target>If the button doesn't work, please copy &amp; paste the following URL to your browser: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target>This is an auto generated e-mail. Please do not reply, the address will not be read.</target>
@@ -28,19 +24,19 @@
</trans-unit>
<trans-unit id="1ZLD_Y0" resname="reset.subject">
<source>reset.subject</source>
<target>Reset your password</target>
<target>Sign in to Kimai</target>
</trans-unit>
<trans-unit id="G2mhn_2" resname="reset.title" xml:space="preserve" approved="yes">
<source>Forgot your password?</source>
<target state="final">Forgot your password?</target>
<target state="final">Sign in to your account</target>
</trans-unit>
<trans-unit id="qbt.84Y" resname="reset.button">
<source>reset.button</source>
<target>Reset your password</target>
<target>Sign in</target>
</trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve" approved="yes">
<source>Don't worry, Kimai will help you create a new one!</source>
<target state="final">Don't worry, Kimai will help you create a new one!</target>
<target>This link will only be valid for the next 15 minutes.</target>
</trans-unit>
<trans-unit id="yohIAA_" resname="absence_created_supervisor_subject">
<source>absence_created_supervisor_subject</source>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="eo" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target>Se la butono ne funkcias, bv. kopii kaj alglui la jenan ligilon al via retumilo: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target>Tio estas aŭtomata mesaĝo. Bonvolu ne respondi, la mesaĝo ne estos legita.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="es" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target state="translated">Si el botón no funciona, copie &amp; pegue la siguiente URL en su navegador: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target state="translated">Este es un correo electrónico generado automáticamente. Por favor, no responda, la dirección no será leída.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fa" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target state="translated">اگر دکمه کار نکرد، لطفا آدرس زیر را در مروگر خود کپی &amp; جایگذاری نمایید : %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target>این یک ایمیلی است که به صورت اتوماتیک ساخته شده است. لطفا به آن پاسخ ندهید، این آدرس خوانده نمی شود.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fi" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target state="translated">Jos painike ei toimi, kopioi ja liitä seuraava osoite selaimeesi: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target state="translated">Tämä on automaattisesti generoitu sähköposti. Älä vastaa tähän, koska osoitteeseen lähetettyjä viestejä ei lueta.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target>Si le bouton ne fonctionne pas, veuillez copier-coller l'URL suivante dans votre navigateur : %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target>Ceci est un courriel généré automatiquement. Merci de ne pas répondre ; ladresse ne sera pas lue.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="he" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target>אם הכפתור לא עובד, אנא העתק והדבק את הכתובת הבאה בדפדפן שלך: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target>זהו מייל שנוצר אוטומטית. נא לא להשיב למייל זה.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="hr" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target>Ako gumb ne radi, kopiraj i umetni sljedeći URL u preglednik: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target>Ovo je automatski generirani e-mail. Nemoj odgovarati, adresa se neće čitati.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="hu" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target>Ha a gomb nem működik, másolja be a következő linket a böngészőjébe: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target>Ez egy automatikusan generált e-mail. Kérem ne válaszoljon erre a címre.</target>

View File

@@ -34,10 +34,6 @@
<source>Don't worry, Kimai will help you create a new one!</source>
<target state="needs-translation">Jadi, Anda lupa kata sandi Anda? Jangan khawatir: Kimai akan membantu Anda membuat yang baru:</target>
</trans-unit>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target state="translated">Jika tombol tidak berfungsi, tolong salin &amp; tempel URL berikut ke peramban Anda: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target state="translated">Ini adalah email yang dibuat secara otomatis. Tolong jangan balas, alamat ini tidak akan dibaca.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="it" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser" approved="yes">
<source>button_copy_url_to_browser</source>
<target state="final">Se il pulsante non funziona, copia e incolla il seguente URL nel tuo browser: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer" approved="yes">
<source>automated_email_dont_answer</source>
<target state="final">Questa è un'e-mail generata automaticamente. Non rispondere, questo indirizzo non verrà letto.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="ja" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target state="translated">ボタンが動作しない場合は、次の URL をブラウザにコピー &amp; ペーストしてください:%url%</target>
</trans-unit>
<trans-unit id="QtcznHC" resname="registration.title">
<source>registration.title</source>
<target state="translated">ようこそ %username% さん</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="ko" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target>버튼이 작동하지 않으면, 다음 URL을 복사하여 브라우저에 붙여넣으십시오: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target>이것은 자동 생성된 이메일입니다. 주소는 읽히지 않으니, 회신하지 마십시오.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="nb-NO" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target>Hvis knappen ikke virker, kopier og lim inn følgende nettadresse i nettleseren din: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target>Dette er en auto-generert e-post. Ikke svar, siden ingen av disse leses.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="nl" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target>Als de knop niet werkt, kopieer &amp; plak de volgende URL in uw browser: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target>Dit is een automatisch gegenereerde e-mail. Gelieve niet te antwoorden, het adres wordt niet gelezen.</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="pl" datatype="plaintext" original="email.en.xlf">
<body>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target state="translated">Jeśli przycisk nie działa, skopiuj i wklej następujący link do przeglądarki: %url%</target>
</trans-unit>
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
<source>automated_email_dont_answer</source>
<target state="translated">Ten e-mail został wygenerowany automatycznie. Prosimy na niego nie odpowiadać, ponieważ skrzynka odbiorcza nie jest obsługiwana.</target>

View File

@@ -14,10 +14,6 @@
<source>registration.button</source>
<target>Ative a sua conta</target>
</trans-unit>
<trans-unit id="d0XfN_m" resname="button_copy_url_to_browser">
<source>button_copy_url_to_browser</source>
<target>Se o botão não funcionar, por favor, copie e cole o seguinte URL no seu navegador: %url%</target>
</trans-unit>
<trans-unit id="rhkMAep" resname="reset.intro" xml:space="preserve">
<source>Don't worry, Kimai will help you create a new one!</source>
<target state="translated">Não se preocupe, o Kimai vai ajudá-lo a criar uma nova!</target>

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