Release 2.23.0 (#5075)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,7 +3,6 @@
|
||||
/.env-*
|
||||
/.idea/
|
||||
.DS_Store
|
||||
/phpstan.sh
|
||||
|
||||
# custom apache rules e.g. to deactivate ioncube loader
|
||||
/public/.user.ini
|
||||
|
||||
@@ -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"> </span>';
|
||||
item += '<span style="background-color:' + data.color + '" class="color-choice-item me-2"> </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"> </span>';
|
||||
item += '<span style="background-color:' + data.color + '" class="color-choice-item me-2"> </span>';
|
||||
}
|
||||
item += escape(data.text) + '</div>';
|
||||
return item;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
95
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
# --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
exclude:
|
||||
- '../src/API/Model/'
|
||||
- '../src/DependencyInjection/'
|
||||
- '../src/Doctrine/Extensions/'
|
||||
- '../src/Entity/'
|
||||
- '../src/Event/'
|
||||
- '../src/Form/Model/'
|
||||
|
||||
@@ -48,9 +48,4 @@ final class Version20240214061246 extends AbstractMigration
|
||||
|
||||
$schema->dropTable('kimai2_access_token');
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
40
migrations/Version20240926111739.php
Normal file
40
migrations/Version20240926111739.php
Normal 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
42
php-cs-fixer.sh
Executable 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
|
||||
40
phpstan.neon
40
phpstan.neon
@@ -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
48
phpstan.sh
Executable 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
2
public/build/app.3463e2a4.js
Normal file
2
public/build/app.3463e2a4.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
20
src/Doctrine/Behavior/CreatedAt.php
Normal file
20
src/Doctrine/Behavior/CreatedAt.php
Normal 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;
|
||||
}
|
||||
28
src/Doctrine/Behavior/CreatedTrait.php
Normal file
28
src/Doctrine/Behavior/CreatedTrait.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
28
src/Doctrine/Behavior/ModifiedTrait.php
Normal file
28
src/Doctrine/Behavior/ModifiedTrait.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -285,7 +285,7 @@ class BaseQuery
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setBookmark(Bookmark $bookmark): void
|
||||
public function setBookmark(?Bookmark $bookmark): void
|
||||
{
|
||||
$this->bookmark = $bookmark;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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' %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">إذا كان الزر لا يعمل ، يرجى نسخ & والصق عنوان URL التالي في متصفحك:%url%</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="IkjnHoP" resname="registration.intro">
|
||||
<source>registration.intro</source>
|
||||
<target state="translated">لقد قمت بالتسجيل في متتبع الوقت Kimai بالبريد الإلكتروني%email%. يرجى تفعيل حسابك في الساعات القليلة القادمة ، قبل انتهاء صلاحية الرابط.</target>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>Εάν το κουμπί δεν λειτουργεί, παρακαλούμε αντιγράψτε & και επικολλήστε την ακόλουθη διεύθυνση URL στο πρόγραμμα περιήγησης: %url%</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
|
||||
<source>automated_email_dont_answer</source>
|
||||
<target>Αυτό είναι ένα αυτοματοποιημένο e-mail. Παρακαλούμε μην απαντήσετε, το περιεχόμενο δεν θα αναγνωσθεί.</target>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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">اگر دکمه کار نکرد، لطفا آدرس زیر را در مروگر خود کپی & جایگذاری نمایید : %url%</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="8jDJmrK" resname="automated_email_dont_answer">
|
||||
<source>automated_email_dont_answer</source>
|
||||
<target>این یک ایمیلی است که به صورت اتوماتیک ساخته شده است. لطفا به آن پاسخ ندهید، این آدرس خوانده نمی شود.</target>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ; l’adresse ne sera pas lue.</target>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 をブラウザにコピー & ペーストしてください:%url%</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="QtcznHC" resname="registration.title">
|
||||
<source>registration.title</source>
|
||||
<target state="translated">ようこそ %username% さん</target>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user