* merge master - allow to upload twig invoice templates via UI
* support adding existing teams with same name
* permissions cannot be set right after role was created - fixes #3777
* allow to deactivate unique customer number validation - fixes #3762 
* invalid message when trying to edit locked or exported timesheets in calendar - fixes #3766
* updated icons and manifest - fixes #3761
This commit is contained in:
Kevin Papst
2023-01-21 14:49:55 +01:00
committed by GitHub
parent b62253e1f3
commit a230be77dd
79 changed files with 428 additions and 358 deletions

View File

@@ -71,7 +71,11 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
return Modal.getOrCreateInstance(this._getModalElement())
}
openUrlInModal(url)
/**
* @param {string} url
* @param {function(Response)} error the callback to execute if the fetch failed
*/
openUrlInModal(url, error)
{
const headers = new Headers();
headers.append('X-Requested-With', 'Kimai-Modal');
@@ -91,8 +95,12 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
this._openFormInModal(html);
});
})
.catch(() => {
window.location = url;
.catch((reason) => {
if (error === undefined || error === null) {
window.location = url;
} else {
error(reason);
}
});
}

View File

@@ -376,7 +376,18 @@ export default class KimaiCalendar {
return;
}
this.hidePopover(eventClickInfo.el);
MODAL.openUrlInModal(this.options.url.edit(event.id));
if (!event.extendedProps.exported || this.hasPermission('edit_exported')) {
MODAL.openUrlInModal(
this.options.url.edit(event.id), (reason) => {
// 403 = user is not allowed to edit the entry (e.g. lockdown mode)
if (reason.status !== 403) {
// keep the log, it might help with debugging
console.log(reason);
}
}
);
}
},
}};
@@ -506,6 +517,8 @@ export default class KimaiCalendar {
}
/**
* Only used on manipulated timesheets!
*
* @param {object} apiItem
* @return {{activity, color: *, start, description, project, end, id, title: *, textColor: *, customer, tags: ([number,number,[],string,string]|*)}}
* @private
@@ -538,6 +551,7 @@ export default class KimaiCalendar {
timesheet: apiItem.id,
title: title,
description: apiItem.description,
exported: apiItem.exported,
start: apiItem.begin,
end: apiItem.end,
activity: apiItem.activity.name,
@@ -591,6 +605,12 @@ export default class KimaiCalendar {
changeHandler(eventArg) {
/** @type {EventApi} event */
const event = eventArg.event;
if (event.extendedProps.exported && !this.hasPermission('edit_exported')) {
eventArg.revert();
return;
}
/** @type {KimaiAPI} API */
const API = this.kimai.getPlugin('api');
/** @type {KimaiAlert} ALERT */

View File

@@ -990,21 +990,6 @@ parameters:
count: 1
path: src/Controller/ActivityController.php
-
message: "#^Method App\\\\Controller\\\\ActivityController\\:\\:detailsAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ActivityController.php
-
message: "#^Method App\\\\Controller\\\\ActivityController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ActivityController.php
-
message: "#^Method App\\\\Controller\\\\ActivityController\\:\\:indexAction\\(\\) has parameter \\$page with no type specified\\.$#"
count: 1
path: src/Controller/ActivityController.php
-
message: "#^Parameter \\#1 \\$name of class App\\\\Entity\\\\Team constructor expects string, string\\|null given\\.$#"
count: 1
@@ -1075,71 +1060,6 @@ parameters:
count: 2
path: src/Controller/CalendarController.php
-
message: "#^Method App\\\\Controller\\\\CustomerController\\:\\:addCommentAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Method App\\\\Controller\\\\CustomerController\\:\\:createAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Method App\\\\Controller\\\\CustomerController\\:\\:createDefaultTeamAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Method App\\\\Controller\\\\CustomerController\\:\\:deleteAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Method App\\\\Controller\\\\CustomerController\\:\\:deleteCommentAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Method App\\\\Controller\\\\CustomerController\\:\\:detailsAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Method App\\\\Controller\\\\CustomerController\\:\\:editAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Method App\\\\Controller\\\\CustomerController\\:\\:exportAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Method App\\\\Controller\\\\CustomerController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Method App\\\\Controller\\\\CustomerController\\:\\:indexAction\\(\\) has parameter \\$page with no type specified\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Method App\\\\Controller\\\\CustomerController\\:\\:pinCommentAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Method App\\\\Controller\\\\CustomerController\\:\\:projectsAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Method App\\\\Controller\\\\CustomerController\\:\\:teamPermissionsAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Parameter \\#1 \\$address of method JeroenDesloovere\\\\VCard\\\\VCard\\:\\:addEmail\\(\\) expects string, string\\|null given\\.$#"
count: 1
@@ -1290,11 +1210,6 @@ parameters:
count: 1
path: src/Controller/InvoiceController.php
-
message: "#^Method App\\\\Controller\\\\InvoiceController\\:\\:uploadDocumentAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/InvoiceController.php
-
message: "#^Parameter \\#1 \\$string of function substr expects string, string\\|false given\\.$#"
count: 1
@@ -1340,86 +1255,6 @@ parameters:
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:activitiesAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:addCommentAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:createAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:createDefaultTeamAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:createProject\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:createWithCustomerAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:deleteAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:deleteCommentAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:detailsAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:duplicateAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:editAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:exportAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:indexAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:indexAction\\(\\) has parameter \\$page with no type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:pinCommentAction\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Method App\\\\Controller\\\\ProjectController\\:\\:teamPermissions\\(\\) has no return type specified\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Parameter \\#1 \\$name of class App\\\\Entity\\\\Team constructor expects string, string\\|null given\\.$#"
count: 1
@@ -1980,11 +1815,6 @@ parameters:
count: 1
path: src/Controller/UserController.php
-
message: "#^Method App\\\\Controller\\\\UserController\\:\\:indexAction\\(\\) has parameter \\$page with no type specified\\.$#"
count: 1
path: src/Controller/UserController.php
-
message: "#^Parameter \\#1 \\$entries of method App\\\\Export\\\\Spreadsheet\\\\UserExporter\\:\\:export\\(\\) expects array\\<App\\\\Entity\\\\User\\>, iterable\\<App\\\\Entity\\\\User\\> given\\.$#"
count: 1
@@ -3840,11 +3670,6 @@ parameters:
count: 1
path: src/Form/Helper/ProjectHelper.php
-
message: "#^Method App\\\\Form\\\\InvoiceDocumentUploadForm\\:\\:validateDocument\\(\\) has no return type specified\\.$#"
count: 1
path: src/Form/InvoiceDocumentUploadForm.php
-
message: "#^Method App\\\\Form\\\\InvoiceDocumentUploadForm\\:\\:validateDocument\\(\\) has parameter \\$value with no type specified\\.$#"
count: 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -2,9 +2,7 @@
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="favicon/mstile-small.jpg"/>
<square150x150logo src="favicon/mstile-medium.jpg"/>
<wide310x150logo src="favicon/mstile-wide.jpg"/>
<square150x150logo src="favicon/mstile-150x150.png"/>
<square310x310logo src="favicon/mstile-large.jpg"/>
<TileColor>#00a300</TileColor>
</tile>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
"app": {
"js": [
"/build/runtime.f0079159.js",
"/build/app.694c6cb5.js"
"/build/app.8b0c21dc.js"
],
"css": [
"/build/app.73c27235.css"
@@ -45,7 +45,7 @@
"calendar": {
"js": [
"/build/runtime.f0079159.js",
"/build/calendar.414914c7.js"
"/build/calendar.7dd54a5f.js"
],
"css": [
"/build/calendar.bb473428.css"
@@ -63,7 +63,7 @@
},
"integrity": {
"/build/runtime.f0079159.js": "sha384-H22sAW1aTvyIPqvHOvGXWSWTxf0y6mptp+MsVmyXCfjx/WJjBbhX9gbUZ+qIuihV",
"/build/app.694c6cb5.js": "sha384-RJ7fX6EM951Vhf6E63ZqGb9G+9fFVvC3V8xZt4CZDSk6TUFs7ITkLwwiKoj1xlAW",
"/build/app.8b0c21dc.js": "sha384-k0LUmYHZUK6bFVymGMzcHSq+utW09iR+YG6VMzqCiXfBGtVvb2Sdr0IB7w6hR+I6",
"/build/app.73c27235.css": "sha384-0vdzXrL3JuXTfiYJUdY17wIGFIbUTzQWfBQXbhcXcFRVmpmJYY1TZYSauxVUNwev",
"/build/export-pdf.587575e7.js": "sha384-J50GStmmfVwUTN4dIRQ02eg9hyzGFPSzpTtpPody92j0V6zCqw+s5l8+ZhVTugeW",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
@@ -72,7 +72,7 @@
"/build/invoice-pdf.d86b82ee.js": "sha384-A0HJqP+MvEqQr1uG8wViCeEWxBRKyS6l8D+Ao4pFYHUA12gCC1gRYhk9I+SJPvZq",
"/build/invoice-pdf.c88953bb.css": "sha384-ZvSi1e+ZKGzvZJUtAPLjzOSTh13N9zRevq44GKdYdBja/DAplGE55saY2Ur+83yv",
"/build/chart.f5becfac.js": "sha384-GSqETm8wULiVXyizvwRompfwu63r/C0Qd/AvrHDE4cqAKiIGCssb3QyBtGu1WN+W",
"/build/calendar.414914c7.js": "sha384-v0La1MlAfoR6x87S4ucwp70e6ma0Y8uIf77i9ADTq/xB+D0Sn3/L67XQDXW1bwY6",
"/build/calendar.7dd54a5f.js": "sha384-oUWTIvkHr+k1UrlriT4+iA+irValKNLFoxva7ljQ/bsbczzaql3uWD0dlC4mF0ss",
"/build/calendar.bb473428.css": "sha384-900W9o8666Qpw21rLP7hn5Ql8tWd40k0IcE3QpFm7E7V6bKva7xQgGlyzVuuh4GK",
"/build/dashboard.6774a712.js": "sha384-lBwkNqUPv+IBbznagiFakY2BOIueq/Bg89wHO2XeM9YAZ51rPkrvdMd+XmFupHsM",
"/build/dashboard.18f5a8b7.css": "sha384-PBD8ftb2yBoSjRe2sN5Xb4dEz045kOEUfcIufdSis+tWoWdAx5j4Yic+F/6CYbrP"

View File

@@ -1,6 +1,6 @@
{
"build/app.css": "/build/app.73c27235.css",
"build/app.js": "/build/app.694c6cb5.js",
"build/app.js": "/build/app.8b0c21dc.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.587575e7.js",
"build/invoice.css": "/build/invoice.3c80ee80.css",
@@ -9,7 +9,7 @@
"build/invoice-pdf.js": "/build/invoice-pdf.d86b82ee.js",
"build/chart.js": "/build/chart.f5becfac.js",
"build/calendar.css": "/build/calendar.bb473428.css",
"build/calendar.js": "/build/calendar.414914c7.js",
"build/calendar.js": "/build/calendar.7dd54a5f.js",
"build/dashboard.css": "/build/dashboard.18f5a8b7.css",
"build/dashboard.js": "/build/dashboard.6774a712.js",
"build/runtime.js": "/build/runtime.f0079159.js"

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,21 +1,31 @@
{
"name": "Kimai Time-Tracker",
"short_name": "Kimai 2",
"short_name": "Kimai",
"icons": [
{
"src": "favicon-32x32.png",
"sizes": "32x32",
"type": "image/png",
"density": "0.75"
"src": "/apple-touch-icon.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "touch-icon-192x192.png",
"src": "/touch-icon-180x180.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/touch-icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"density": "4.0"
"type": "image/png"
},
{
"src": "/touch-icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"scope": "./",
"start_url": "./",
"theme-color": "#1d273b",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@@ -1,5 +1,2 @@
# www.robotstxt.org/
# www.google.com/support/webmasters/bin/answer.py?hl=en&answer=156449
User-agent: *
Disallow: /

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -56,7 +56,7 @@ final class ActivityController extends AbstractController
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_activity', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_activity_paginated', methods: ['GET'])]
public function indexAction($page, Request $request)
public function indexAction(int $page, Request $request): Response
{
$query = new ActivityQuery();
$query->setCurrentUser($this->getUser());
@@ -124,7 +124,7 @@ final class ActivityController extends AbstractController
#[Route(path: '/{id}/details', name: 'activity_details', methods: ['GET', 'POST'])]
#[IsGranted('view', 'activity')]
public function detailsAction(Activity $activity, TeamRepository $teamRepository, ActivityRateRepository $rateRepository, ActivityStatisticService $statisticService)
public function detailsAction(Activity $activity, TeamRepository $teamRepository, ActivityRateRepository $rateRepository, ActivityStatisticService $statisticService): Response
{
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event);
@@ -297,13 +297,11 @@ final class ActivityController extends AbstractController
public function createDefaultTeamAction(Activity $activity, TeamRepository $teamRepository): Response
{
$defaultTeam = $teamRepository->findOneBy(['name' => $activity->getName()]);
if (null !== $defaultTeam) {
$this->flashError('action.update.error', 'Team already existing');
return $this->redirectToRoute('activity_details', ['id' => $activity->getId()]);
if (null === $defaultTeam) {
$defaultTeam = new Team($activity->getName());
}
$defaultTeam = new Team($activity->getName());
$defaultTeam->addTeamlead($this->getUser());
$defaultTeam->addActivity($activity);

View File

@@ -63,7 +63,7 @@ final class CustomerController extends AbstractController
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_customer', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_customer_paginated', methods: ['GET'])]
public function indexAction($page, Request $request)
public function indexAction(int $page, Request $request): Response
{
$query = new CustomerQuery();
$query->setCurrentUser($this->getUser());
@@ -141,7 +141,7 @@ final class CustomerController extends AbstractController
#[Route(path: '/create', name: 'admin_customer_create', methods: ['GET', 'POST'])]
#[IsGranted('create_customer')]
public function createAction(Request $request, CustomerService $customerService)
public function createAction(Request $request, CustomerService $customerService): Response
{
$customer = $customerService->createNewCustomer('');
@@ -150,7 +150,7 @@ final class CustomerController extends AbstractController
#[Route(path: '/{id}/permissions', name: 'admin_customer_permissions', methods: ['GET', 'POST'])]
#[IsGranted('permissions', 'customer')]
public function teamPermissionsAction(Customer $customer, Request $request)
public function teamPermissionsAction(Customer $customer, Request $request): Response
{
$form = $this->createForm(CustomerTeamPermissionForm::class, $customer, [
'action' => $this->generateUrl('admin_customer_permissions', ['id' => $customer->getId()]),
@@ -183,7 +183,7 @@ final class CustomerController extends AbstractController
#[Route(path: '/{id}/comment_delete/{token}', name: 'customer_comment_delete', methods: ['GET'])]
#[IsGranted(new Expression("is_granted('edit', subject.getCustomer()) and is_granted('comments', subject.getCustomer())"), 'comment')]
public function deleteCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
public function deleteCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
{
$customerId = $comment->getCustomer()->getId();
@@ -206,7 +206,7 @@ final class CustomerController extends AbstractController
#[Route(path: '/{id}/comment_add', name: 'customer_comment_add', methods: ['POST'])]
#[IsGranted('comments', 'customer')]
public function addCommentAction(Customer $customer, Request $request)
public function addCommentAction(Customer $customer, Request $request): Response
{
$comment = new CustomerComment($customer);
$form = $this->getCommentForm($comment);
@@ -226,7 +226,7 @@ final class CustomerController extends AbstractController
#[Route(path: '/{id}/comment_pin/{token}', name: 'customer_comment_pin', methods: ['GET'])]
#[IsGranted(new Expression("is_granted('edit', subject.getCustomer()) and is_granted('comments', subject.getCustomer())"), 'comment')]
public function pinCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
public function pinCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
{
$customerId = $comment->getCustomer()->getId();
@@ -251,16 +251,14 @@ final class CustomerController extends AbstractController
#[Route(path: '/{id}/create_team', name: 'customer_team_create', methods: ['GET'])]
#[IsGranted('create_team')]
#[IsGranted('permissions', 'customer')]
public function createDefaultTeamAction(Customer $customer, TeamRepository $teamRepository)
public function createDefaultTeamAction(Customer $customer, TeamRepository $teamRepository): Response
{
$defaultTeam = $teamRepository->findOneBy(['name' => $customer->getName()]);
if (null !== $defaultTeam) {
$this->flashError('action.update.error', 'Team already existing');
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
if (null === $defaultTeam) {
$defaultTeam = new Team($customer->getName());
}
$defaultTeam = new Team($customer->getName());
$defaultTeam->addTeamlead($this->getUser());
$defaultTeam->addCustomer($customer);
@@ -275,7 +273,7 @@ final class CustomerController extends AbstractController
#[Route(path: '/{id}/projects/{page}', defaults: ['page' => 1], name: 'customer_projects', methods: ['GET', 'POST'])]
#[IsGranted('view', 'customer')]
public function projectsAction(Customer $customer, int $page, ProjectRepository $projectRepository)
public function projectsAction(Customer $customer, int $page, ProjectRepository $projectRepository): Response
{
$query = new ProjectQuery();
$query->setCurrentUser($this->getUser());
@@ -298,7 +296,7 @@ final class CustomerController extends AbstractController
#[Route(path: '/{id}/details', name: 'customer_details', methods: ['GET', 'POST'])]
#[IsGranted('view', 'customer')]
public function detailsAction(Customer $customer, TeamRepository $teamRepository, CustomerRateRepository $rateRepository, CustomerStatisticService $statisticService)
public function detailsAction(Customer $customer, TeamRepository $teamRepository, CustomerRateRepository $rateRepository, CustomerStatisticService $statisticService): Response
{
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event);
@@ -462,14 +460,14 @@ final class CustomerController extends AbstractController
#[Route(path: '/{id}/edit', name: 'admin_customer_edit', methods: ['GET', 'POST'])]
#[IsGranted('edit', 'customer')]
public function editAction(Customer $customer, Request $request)
public function editAction(Customer $customer, Request $request): Response
{
return $this->renderCustomerForm($customer, $request);
}
#[Route(path: '/{id}/delete', name: 'admin_customer_delete', methods: ['GET', 'POST'])]
#[IsGranted('delete', 'customer')]
public function deleteAction(Customer $customer, Request $request, CustomerStatisticService $statisticService)
public function deleteAction(Customer $customer, Request $request, CustomerStatisticService $statisticService): Response
{
$stats = $statisticService->getCustomerStatistics($customer);
@@ -511,7 +509,7 @@ final class CustomerController extends AbstractController
}
#[Route(path: '/export', name: 'customer_export', methods: ['GET'])]
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter): Response
{
$query = new CustomerQuery();
$query->setCurrentUser($this->getUser());

View File

@@ -9,6 +9,7 @@
namespace App\Controller;
use App\Configuration\SystemConfiguration;
use App\Entity\Customer;
use App\Entity\Invoice;
use App\Entity\InvoiceTemplate;
@@ -48,6 +49,7 @@ use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Twig\Environment;
/**
* Controller used to create invoices and manage invoice templates.
@@ -447,7 +449,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])]
#[IsGranted('upload_invoice_template')]
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository)
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration): Response
{
$dir = $documentRepository->getUploadDirectory();
$invoiceDir = $dir;
@@ -456,6 +458,7 @@ final class InvoiceController extends AbstractController
if ($invoiceDir[0] !== '/') {
$invoiceDir = $projectDirectory . DIRECTORY_SEPARATOR . $dir;
}
$invoiceDir = rtrim($invoiceDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$used = [];
foreach ($this->templateRepository->findAll() as $template) {
@@ -511,23 +514,56 @@ final class InvoiceController extends AbstractController
/** @var UploadedFile $uploadedFile */
$uploadedFile = $form->get('document')->getData();
$originalFilename = pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_FILENAME);
$safeFilename = transliterator_transliterate(
'Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()',
$originalFilename
);
$originalName = $uploadedFile->getClientOriginalName();
$safeFilename = null;
$extension = null;
$success = true;
$extension = $uploadedFile->guessExtension();
$allowed = InvoiceDocumentUploadForm::EXTENSIONS_NO_TWIG;
if ((bool) $systemConfiguration->find('invoice.upload_twig') === true) {
$allowed = InvoiceDocumentUploadForm::EXTENSIONS;
}
$newFilename = substr($safeFilename, 0, 20) . '.' . $extension;
foreach ($allowed as $ext) {
$len = \strlen($ext);
if (substr_compare($originalName, $ext, -$len) === 0) {
$extension = $ext;
$withoutExtension = str_replace($ext, '', $originalName);
$safeFilename = transliterator_transliterate(InvoiceDocumentUploadForm::FILENAME_RULE, $withoutExtension);
break;
}
}
try {
$uploadedFile->move($invoiceDir, $newFilename);
if ($safeFilename === null || $extension === null) {
$success = false;
$this->flashError('Invalid file given');
} else {
$newFilename = substr($safeFilename, 0, 20) . $extension;
try {
$uploadedFile->move($invoiceDir, $newFilename);
// if this is a twig file, we directly try to compile the template
if (stripos($newFilename, '.twig') !== false) {
try {
$twig->enableAutoReload();
$twig->load('@invoice/' . $newFilename);
$twig->disableAutoReload();
} catch (Exception $ex) {
unlink($invoiceDir . $newFilename);
$success = false;
$this->flashException($ex, 'File was deleted, as Twig template is broken: ' . $ex->getMessage());
}
}
} catch (Exception $ex) {
$this->flashException($ex, 'action.upload.error');
}
}
if ($success) {
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_invoice_document_upload');
} catch (Exception $ex) {
$this->flashException($ex, 'action.upload.error');
}
}
}

View File

@@ -63,7 +63,7 @@ final class ProjectController extends AbstractController
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_project', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_project_paginated', methods: ['GET'])]
public function indexAction($page, Request $request)
public function indexAction(int $page, Request $request): Response
{
$query = new ProjectQuery();
$query->setCurrentUser($this->getUser());
@@ -134,7 +134,7 @@ final class ProjectController extends AbstractController
#[Route(path: '/{id}/permissions', name: 'admin_project_permissions', methods: ['GET', 'POST'])]
#[IsGranted('permissions', 'project')]
public function teamPermissions(Project $project, Request $request)
public function teamPermissions(Project $project, Request $request): Response
{
$form = $this->createForm(ProjectTeamPermissionForm::class, $project, [
'action' => $this->generateUrl('admin_project_permissions', ['id' => $project->getId()]),
@@ -167,19 +167,19 @@ final class ProjectController extends AbstractController
#[Route(path: '/create/{customer}', name: 'admin_project_create_with_customer', methods: ['GET', 'POST'])]
#[IsGranted('create_project')]
public function createWithCustomerAction(Request $request, Customer $customer)
public function createWithCustomerAction(Request $request, Customer $customer): Response
{
return $this->createProject($request, $customer);
}
#[Route(path: '/create', name: 'admin_project_create', methods: ['GET', 'POST'])]
#[IsGranted('create_project')]
public function createAction(Request $request)
public function createAction(Request $request): Response
{
return $this->createProject($request, null);
}
private function createProject(Request $request, ?Customer $customer = null)
private function createProject(Request $request, ?Customer $customer = null): Response
{
$project = $this->projectService->createNewProject($customer);
@@ -206,7 +206,7 @@ final class ProjectController extends AbstractController
#[Route(path: '/{id}/comment_delete/{token}', name: 'project_comment_delete', methods: ['GET'])]
#[IsGranted(new Expression("is_granted('edit', subject.getProject()) and is_granted('comments', subject.getProject())"), 'comment')]
public function deleteCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
public function deleteCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
{
$projectId = $comment->getProject()->getId();
@@ -229,7 +229,7 @@ final class ProjectController extends AbstractController
#[Route(path: '/{id}/comment_add', name: 'project_comment_add', methods: ['POST'])]
#[IsGranted('comments', 'project')]
public function addCommentAction(Project $project, Request $request)
public function addCommentAction(Project $project, Request $request): Response
{
$comment = new ProjectComment($project);
$form = $this->getCommentForm($comment);
@@ -249,7 +249,7 @@ final class ProjectController extends AbstractController
#[Route(path: '/{id}/comment_pin/{token}', name: 'project_comment_pin', methods: ['GET'])]
#[IsGranted(new Expression("is_granted('edit', subject.getProject()) and is_granted('comments', subject.getProject())"), 'comment')]
public function pinCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
public function pinCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
{
$projectId = $comment->getProject()->getId();
@@ -274,16 +274,14 @@ final class ProjectController extends AbstractController
#[Route(path: '/{id}/create_team', name: 'project_team_create', methods: ['GET'])]
#[IsGranted('create_team')]
#[IsGranted('permissions', 'project')]
public function createDefaultTeamAction(Project $project, TeamRepository $teamRepository)
public function createDefaultTeamAction(Project $project, TeamRepository $teamRepository): Response
{
$defaultTeam = $teamRepository->findOneBy(['name' => $project->getName()]);
if (null !== $defaultTeam) {
$this->flashError('action.update.error', 'Team already existing');
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
if (null === $defaultTeam) {
$defaultTeam = new Team($project->getName());
}
$defaultTeam = new Team($project->getName());
$defaultTeam->addTeamlead($this->getUser());
$defaultTeam->addProject($project);
@@ -298,7 +296,7 @@ final class ProjectController extends AbstractController
#[Route(path: '/{id}/activities/{page}', defaults: ['page' => 1], name: 'project_activities', methods: ['GET', 'POST'])]
#[IsGranted('view', 'project')]
public function activitiesAction(Project $project, int $page, ActivityRepository $activityRepository)
public function activitiesAction(Project $project, int $page, ActivityRepository $activityRepository): Response
{
$query = new ActivityQuery();
$query->setCurrentUser($this->getUser());
@@ -322,7 +320,7 @@ final class ProjectController extends AbstractController
#[Route(path: '/{id}/details', name: 'project_details', methods: ['GET', 'POST'])]
#[IsGranted('view', 'project')]
public function detailsAction(Project $project, TeamRepository $teamRepository, ProjectRateRepository $rateRepository, ProjectStatisticService $statisticService, CsrfTokenManagerInterface $csrfTokenManager)
public function detailsAction(Project $project, TeamRepository $teamRepository, ProjectRateRepository $rateRepository, ProjectStatisticService $statisticService, CsrfTokenManagerInterface $csrfTokenManager): Response
{
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event);
@@ -427,7 +425,7 @@ final class ProjectController extends AbstractController
#[Route(path: '/{id}/edit', name: 'admin_project_edit', methods: ['GET', 'POST'])]
#[IsGranted('edit', 'project')]
public function editAction(Project $project, Request $request)
public function editAction(Project $project, Request $request): Response
{
$editForm = $this->createEditForm($project);
$editForm->handleRequest($request);
@@ -452,7 +450,7 @@ final class ProjectController extends AbstractController
#[Route(path: '/{id}/duplicate/{token}', name: 'admin_project_duplicate', methods: ['GET', 'POST'])]
#[IsGranted('edit', 'project')]
public function duplicateAction(Project $project, string $token, ProjectDuplicationService $projectDuplicationService, CsrfTokenManagerInterface $csrfTokenManager)
public function duplicateAction(Project $project, string $token, ProjectDuplicationService $projectDuplicationService, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('project.duplicate', $token))) {
$this->flashError('action.csrf.error');
@@ -471,7 +469,7 @@ final class ProjectController extends AbstractController
#[Route(path: '/{id}/delete', name: 'admin_project_delete', methods: ['GET', 'POST'])]
#[IsGranted('delete', 'project')]
public function deleteAction(Project $project, Request $request, ProjectStatisticService $statisticService)
public function deleteAction(Project $project, Request $request, ProjectStatisticService $statisticService): Response
{
$stats = $statisticService->getProjectStatistics($project);
@@ -514,7 +512,7 @@ final class ProjectController extends AbstractController
}
#[Route(path: '/export', name: 'project_export', methods: ['GET'])]
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter): Response
{
$query = new ProjectQuery();
$query->setCurrentUser($this->getUser());

View File

@@ -432,6 +432,7 @@ final class SystemConfigurationController extends AbstractController
])
->setRequired(true)
->setType(TextType::class)
->setConstraints([new NotBlank()])
->setTranslationDomain('system-configuration'),
]),
$authentication,
@@ -459,6 +460,10 @@ final class SystemConfigurationController extends AbstractController
->setRequired(true)
->setType(TextType::class)
->setTranslationDomain('system-configuration'),
(new Configuration('customer.rules.allow_duplicate_number'))
->setLabel('customer.allow_duplicate_number')
->setType(YesNoType::class)
->setTranslationDomain('system-configuration'),
]),
(new SystemConfigurationModel('project'))
->setConfiguration([

View File

@@ -47,7 +47,7 @@ final class UserController extends AbstractController
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_user', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_user_paginated', methods: ['GET'])]
public function indexAction($page, Request $request): Response
public function indexAction(int $page, Request $request): Response
{
$query = new UserQuery();
$query->setCurrentUser($this->getUser());

View File

@@ -322,6 +322,9 @@ final class Configuration implements ConfigurationInterface
->scalarNode('number_format')
->defaultValue('{Y}/{cy,3}')
->end()
->booleanNode('upload_twig')
->defaultTrue()
->end()
->end()
;
@@ -531,6 +534,14 @@ final class Configuration implements ConfigurationInterface
->scalarNode('number_format')
->defaultValue('{cc,4}')
->end()
->arrayNode('rules')
->addDefaultsIfNotSet()
->children()
->booleanNode('allow_duplicate_number')
->defaultFalse()
->end()
->end()
->end()
->end()
;

View File

@@ -10,21 +10,21 @@
namespace App\Entity;
use App\Export\Annotation as Exporter;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use OpenApi\Attributes as OA;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_customers')]
#[ORM\Index(columns: ['visible'])]
#[ORM\Entity(repositoryClass: 'App\Repository\CustomerRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('number')]
#[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', 'teams', 'comment', 'billable'])]
#[Constraints\Customer]
class Customer implements EntityWithMetaFields, EntityWithBudget
{
public const DEFAULT_CURRENCY = 'EUR';

View File

@@ -9,6 +9,8 @@
namespace App\EventSubscriber;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
@@ -19,6 +21,10 @@ use Symfony\Component\Security\Core\Exception\AuthenticationExpiredException;
final class AjaxAuthenticationSubscriber implements EventSubscriberInterface
{
public function __construct(private Security $security)
{
}
public static function getSubscribedEvents(): array
{
return [
@@ -29,6 +35,12 @@ final class AjaxAuthenticationSubscriber implements EventSubscriberInterface
public function onCoreException(ExceptionEvent $event): void
{
$request = $event->getRequest();
// do not act upon requests which were triggered by fully logged-in users
if ($this->security->getUser() instanceof User && $this->security->isGranted('IS_AUTHENTICATED_FULLY')) {
return;
}
$header = $request->headers->get('X-Requested-With');
if ($request->isXmlHttpRequest() || ($header !== null && str_contains(strtolower($header), 'kimai'))) {

View File

@@ -9,6 +9,7 @@
namespace App\Form;
use App\Configuration\SystemConfiguration;
use App\Repository\InvoiceDocumentRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
@@ -21,28 +22,45 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
final class InvoiceDocumentUploadForm extends AbstractType
{
public function __construct(private InvoiceDocumentRepository $repository)
public const EXTENSIONS = ['.html.twig', '.pdf.twig', '.docx', '.xlsx', '.ods'];
public const EXTENSIONS_NO_TWIG = ['.docx', '.xlsx', '.ods'];
public const FILENAME_RULE = 'Any-Latin; Latin-ASCII; [^A-Za-z0-9_\-] remove; Lower()';
/** @var array<string> */
private array $extensions = [];
public function __construct(private InvoiceDocumentRepository $repository, private SystemConfiguration $systemConfiguration)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$this->extensions = self::EXTENSIONS_NO_TWIG;
$extensions = 'DOCX, ODS, XLSX';
$mimetypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.oasis.opendocument.spreadsheet',
];
if ((bool) $this->systemConfiguration->find('invoice.upload_twig') === true) {
$this->extensions = self::EXTENSIONS;
$extensions = 'DOCX, ODS, XLSX, TWIG (PDF & HTML)';
$mimetypes = array_merge($mimetypes, [
'application/octet-stream', // needed for twig templates
'text/html', // needed for twig templates
'text/plain', // needed for twig templates
]);
}
$builder
->add('document', FileType::class, [
'label' => 'invoice_renderer',
'translation_domain' => 'invoice-renderer',
'help' => 'help.upload',
'help_translation_parameters' => ['%extensions%' => $extensions],
'mapped' => false,
'required' => true,
'attr' => [
'accept' => implode(',', $mimetypes)
],
'constraints' => [
new File([
'mimeTypes' => $mimetypes,
@@ -54,7 +72,7 @@ final class InvoiceDocumentUploadForm extends AbstractType
;
}
public function validateDocument($value, ExecutionContextInterface $context)
public function validateDocument($value, ExecutionContextInterface $context): void
{
if (!($value instanceof UploadedFile)) {
return;
@@ -71,6 +89,48 @@ final class InvoiceDocumentUploadForm extends AbstractType
->setTranslationDomain('validators')
->setCode('kimai-invoice-document-upload-01')
->addViolation();
return;
}
$extension = null;
$nameWithoutExtension = null;
foreach ($this->extensions as $ext) {
$len = \strlen($ext);
if (substr_compare($name, $ext, -$len) === 0) {
$extension = $ext;
$nameWithoutExtension = str_replace($ext, '', $name);
break;
}
}
if ($extension === null || $nameWithoutExtension === null) {
$context->buildViolation('This invoice document cannot be used, allowed file extensions are: %extensions%')
->setParameters(['%extensions%' => implode(', ', $this->extensions)])
->setTranslationDomain('validators')
->setCode('kimai-invoice-document-upload-02')
->addViolation();
return;
}
$safeFilename = transliterator_transliterate(self::FILENAME_RULE, $nameWithoutExtension);
if ($safeFilename !== $nameWithoutExtension) {
$context->buildViolation('This invoice document cannot be used, filename may only contain the following ascii character: %character%')
->setParameters(['%character%' => 'A-Z a-z 0-9 _ -'])
->setTranslationDomain('validators')
->setCode('kimai-invoice-document-upload-03')
->addViolation();
}
if (mb_strlen($nameWithoutExtension) > 20) {
$context->buildViolation('This invoice document cannot be used, allowed filename length without extension is %character% character.')
->setParameters(['%character%' => 20])
->setTranslationDomain('validators')
->setCode('kimai-invoice-document-upload-04')
->addViolation();
}
}

View File

@@ -0,0 +1,29 @@
<?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\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_CLASS)]
final class Customer extends Constraint
{
public const CUSTOMER_NUMBER_EXISTING = 'kimai-customer-00';
protected const ERROR_NAMES = [
self::CUSTOMER_NUMBER_EXISTING => 'This account number is already used.',
];
public string $message = 'This customer has invalid settings.';
public function getTargets(): string|array
{
return self::CLASS_CONSTRAINT;
}
}

View File

@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Validator\Constraints;
use App\Configuration\SystemConfiguration;
use App\Entity\Customer as CustomerEntity;
use App\Repository\CustomerRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class CustomerValidator extends ConstraintValidator
{
public function __construct(private SystemConfiguration $systemConfiguration, private CustomerRepository $customerRepository)
{
}
/**
* @param CustomerEntity|mixed $value
* @param Constraint $constraint
*/
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof Customer)) {
throw new UnexpectedTypeException($constraint, Customer::class);
}
if (!($value instanceof CustomerEntity)) {
throw new UnexpectedTypeException($value, CustomerEntity::class);
}
if ((bool) $this->systemConfiguration->find('customer.rules.allow_duplicate_number') === false && (($number = $value->getNumber()) !== null)) {
$tmp = $this->customerRepository->findOneBy(['number' => $number]);
if ($tmp !== null && $tmp->getId() !== $value->getId()) {
$this->context->buildViolation(Customer::getErrorName(Customer::CUSTOMER_NUMBER_EXISTING))
->atPath('number')
->setTranslationDomain('validators')
->setCode(Customer::CUSTOMER_NUMBER_EXISTING)
->addViolation();
}
}
}
}

View File

@@ -69,6 +69,7 @@
{% set editRoute = 'timesheet_edit' %}
{% set canCreate = is_granted('create_own_timesheet') %}
{% set canEdit = is_granted('edit_own_timesheet') %}
{% set canEditExported = is_granted('edit_exported_timesheet') %}
{% set isForeignEdit = false %}
{% if user != app.user %}
{% set isForeignEdit = true %}
@@ -104,6 +105,7 @@
},
permissions: {
edit: {% if canEdit %}true{% else %}false{% endif %},
edit_exported: {% if canEditExported %}true{% else %}false{% endif %},
create: {% if canCreate %}true{% else %}false{% endif %},
edit_begin: {% if can_edit_begin %}true{% else %}false{% endif %},
edit_end: {% if can_edit_end %}true{% else %}false{% endif %},

View File

@@ -8,13 +8,17 @@
{% block box_title %}
{{ 'upload'|trans }}
{% endblock %}
{% block box_body %}
{% block box_before %}
{{ form_start(form) }}
{% endblock %}
{% block box_after %}
{{ form_end(form) }}
{% endblock %}
{% block box_body %}
{{ form_row(form.document) }}
<p>
<a href="https://github.com/kimai/invoice-templates" target="_blank">{{ 'download_invoice_renderer'|trans({}, 'invoice-renderer') }}</a>
</p>
{{ form_end(form) }}
{% endblock %}
{% block box_footer %}
<button type="submit" class="btn btn-primary">{{ 'action.save'|trans }}</button>

View File

@@ -1,10 +1,14 @@
<link rel="shortcut icon" type="image/x-icon" href="{{ asset('favicon.ico') }}">
<link rel="icon" type="image/x-icon" href="{{ asset('favicon.ico') }}">
<link rel="icon" type="image/png" href="{{ asset('favicon-16x16.png') }}" sizes="16x16">
<link rel="icon" type="image/png" href="{{ asset('favicon-32x32.png') }}" sizes="32x32">
<link rel="apple-touch-icon" href="{{ asset('apple-touch-icon.png') }}">
<link rel="apple-touch-icon-precomposed" href="{{ asset('apple-touch-icon-precomposed.png') }}">
<link rel="apple-touch-icon" sizes="152x152" href="{{ asset('apple-touch-icon.png') }}">
<link rel="apple-touch-icon" sizes="180x180" href="{{ asset('touch-icon-180x180.png') }}">
<link rel="manifest" href="{{ asset('manifest.json') }}">
<meta name="apple-mobile-web-app-title" content="{{ constant('App\\Constants::SOFTWARE') }}">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="application-name" content="{{ constant('App\\Constants::SOFTWARE') }}">
<meta name="msapplication-config" content="{{ asset('browserconfig.xml') }}">
<meta name="theme-color" content="#1d273b">

View File

@@ -120,7 +120,7 @@
});
}
KimaiReloadPageWidget.create('kimai.userRoleUpdate');
KimaiReloadPageWidget.create('kimai.userRoleUpdate', true);
});
function toggleLabel(element, showTrue) {

View File

@@ -308,12 +308,6 @@ class ActivityControllerTest extends ControllerBaseTest
self::assertStringContainsString('Only visible to the following teams and all admins.', $node->text());
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body table tbody tr');
self::assertEquals(1, $node->count());
// creating the default team a second time fails, as the name already exists
$this->request($client, '/admin/activity/1/create_team');
$this->assertIsRedirect($client, $this->createUrl('/admin/activity/1/details'));
$client->followRedirect();
$this->assertHasFlashError($client, 'Changes could not be saved: Team already existing');
}
public function testDeleteAction()

View File

@@ -283,12 +283,6 @@ class CustomerControllerTest extends ControllerBaseTest
self::assertStringContainsString('Only visible to the following teams and all admins.', $node->text(null, true));
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body table tbody tr');
self::assertEquals(1, $node->count());
// creating the default team a second time fails, as the name already exists
$this->request($client, '/admin/customer/1/create_team');
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$this->assertHasFlashError($client, 'Changes could not be saved: Team already existing');
}
public function testProjectsAction()

View File

@@ -356,12 +356,6 @@ class ProjectControllerTest extends ControllerBaseTest
self::assertStringContainsString('Only visible to the following teams and all admins.', $node->text(null, true));
$node = $client->getCrawler()->filter('div.card#team_listing_box .card-body table tbody tr');
self::assertEquals(1, $node->count());
// creating the default team a second time fails, as the name already exists
$this->request($client, '/admin/project/1/create_team');
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$this->assertHasFlashError($client, 'Changes could not be saved: Team already existing');
}
public function testActivitiesAction()

View File

@@ -309,6 +309,7 @@ class ConfigurationTest extends TestCase
1 => 'templates/invoice/renderer/',
],
'number_format' => '{Y}/{cy,3}',
'upload_twig' => true,
],
'export' => [
'documents' => [
@@ -430,6 +431,9 @@ class ConfigurationTest extends TestCase
],
'customer' => [
'number_format' => '{cc,4}',
'rules' => [
'allow_duplicate_number' => false,
],
],
];

View File

@@ -9,8 +9,10 @@
namespace App\Tests\EventSubscriber;
use App\Entity\User;
use App\EventSubscriber\AjaxAuthenticationSubscriber;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
@@ -24,26 +26,46 @@ use Symfony\Component\Security\Core\Exception\AuthenticationExpiredException;
*/
class AjaxAuthenticationSubscriberTest extends TestCase
{
public function testGetSubscribedEvents()
public function testGetSubscribedEvents(): void
{
$events = AjaxAuthenticationSubscriber::getSubscribedEvents();
$this->assertArrayHasKey(KernelEvents::EXCEPTION, $events);
/** @var string $methodName */
$methodName = $events[KernelEvents::EXCEPTION][0];
$this->assertTrue(method_exists(AjaxAuthenticationSubscriber::class, $methodName));
}
public function getTestHeader()
/**
* @return array<array<string>>
*/
public function getTestHeader(): array
{
yield ['XMLHttpRequest'];
yield ['Kimai'];
return [
['XMLHttpRequest'],
['Kimai']
];
}
private function getSut(bool $loggedIn = false): AjaxAuthenticationSubscriber
{
$security = $this->createMock(Security::class);
if ($loggedIn) {
$user = new User();
$security->method('getUser')->willReturn($user);
$security->method('isGranted')->willReturn(true);
}
$sut = new AjaxAuthenticationSubscriber($security);
return $sut;
}
/**
* @dataProvider getTestHeader
*/
public function testAuthenticationExpiredException(string $requestedWith)
public function testAuthenticationExpiredException(string $requestedWith): void
{
$sut = new AjaxAuthenticationSubscriber();
$sut = $this->getSut();
$exception = new AuthenticationExpiredException();
$kernel = $this->createMock(HttpKernelInterface::class);
@@ -65,9 +87,9 @@ class AjaxAuthenticationSubscriberTest extends TestCase
/**
* @dataProvider getTestHeader
*/
public function testAuthenticationException(string $requestedWith)
public function testAuthenticationException(string $requestedWith): void
{
$sut = new AjaxAuthenticationSubscriber();
$sut = $this->getSut();
$exception = new AuthenticationException();
$kernel = $this->createMock(HttpKernelInterface::class);
@@ -89,9 +111,9 @@ class AjaxAuthenticationSubscriberTest extends TestCase
/**
* @dataProvider getTestHeader
*/
public function testAccessDeniedException(string $requestedWith)
public function testAccessDeniedException(string $requestedWith): void
{
$sut = new AjaxAuthenticationSubscriber();
$sut = $this->getSut();
$exception = new AccessDeniedException();
$kernel = $this->createMock(HttpKernelInterface::class);
@@ -109,4 +131,23 @@ class AjaxAuthenticationSubscriberTest extends TestCase
self::assertTrue($response->headers->has('Login-Required'));
self::assertEquals('1', $response->headers->get('Login-Required'));
}
/**
* @dataProvider getTestHeader
*/
public function testAccessDeniedExceptionWithLoggedInUser(string $requestedWith): void
{
$sut = $this->getSut(true);
$exception = new AccessDeniedException();
$kernel = $this->createMock(HttpKernelInterface::class);
$request = new Request();
$request->initialize([], [], [], [], [], ['HTTP_X-Requested-With' => $requestedWith]);
$event = new ExceptionEvent($kernel, $request, 1, $exception);
$sut->onCoreException($event);
self::assertNull($event->getResponse());
}
}

View File

@@ -6037,36 +6037,6 @@ parameters:
count: 1
path: EventSubscriber/Actions/UsersSubscriberTest.php
-
message: "#^Method App\\\\Tests\\\\EventSubscriber\\\\AjaxAuthenticationSubscriberTest\\:\\:getTestHeader\\(\\) has no return type specified\\.$#"
count: 1
path: EventSubscriber/AjaxAuthenticationSubscriberTest.php
-
message: "#^Method App\\\\Tests\\\\EventSubscriber\\\\AjaxAuthenticationSubscriberTest\\:\\:testAccessDeniedException\\(\\) has no return type specified\\.$#"
count: 1
path: EventSubscriber/AjaxAuthenticationSubscriberTest.php
-
message: "#^Method App\\\\Tests\\\\EventSubscriber\\\\AjaxAuthenticationSubscriberTest\\:\\:testAuthenticationException\\(\\) has no return type specified\\.$#"
count: 1
path: EventSubscriber/AjaxAuthenticationSubscriberTest.php
-
message: "#^Method App\\\\Tests\\\\EventSubscriber\\\\AjaxAuthenticationSubscriberTest\\:\\:testAuthenticationExpiredException\\(\\) has no return type specified\\.$#"
count: 1
path: EventSubscriber/AjaxAuthenticationSubscriberTest.php
-
message: "#^Method App\\\\Tests\\\\EventSubscriber\\\\AjaxAuthenticationSubscriberTest\\:\\:testGetSubscribedEvents\\(\\) has no return type specified\\.$#"
count: 1
path: EventSubscriber/AjaxAuthenticationSubscriberTest.php
-
message: "#^Parameter \\#2 \\$method of function method_exists expects string, array\\<int\\|string, int\\|string\\>\\|int\\|string given\\.$#"
count: 1
path: EventSubscriber/AjaxAuthenticationSubscriberTest.php
-
message: "#^Method App\\\\Tests\\\\EventSubscriber\\\\EmailSubscriberTest\\:\\:testGetSubscribedEvents\\(\\) has no return type specified\\.$#"
count: 1

View File

@@ -36,7 +36,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">انتباه: سيتم الكتابة فوق الملفات الموجودة. أنواع الملفات المسموح بها هي: DOCX و ODS و XLSX.</target>
<target state="translated">انتباه: سيتم الكتابة فوق الملفات الموجودة. أنواع الملفات المسموح بها هي: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -24,7 +24,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Pozor: existující soubory budou přepsány. Povolené typy jsou: DOCX, ODS, XLSX.</target>
<target state="translated">Pozor: existující soubory budou přepsány. Povolené typy jsou: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Achtung: existierende Dateien werden überschrieben. Erlaubte Dateitypen sind: DOCX, ODS, XLSX.</target>
<target state="translated">Achtung: existierende Dateien werden überschrieben. Erlaubte Dateitypen sind: %extensions%.</target>
</trans-unit>
<trans-unit id="22pt8Gs" resname="download_invoice_renderer">
<source>download_invoice_renderer</source>

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="needs-translation">Achtung: existierende Dateien werden überschrieben. Erlaubte Dateitypen sind: DOCX, ODS, XLSX</target>
<target state="needs-translation">Achtung: existierende Dateien werden überschrieben. Erlaubte Dateitypen sind: %extensions%</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Προσοχή: τα υπάρχοντα αρχεία θα αντικατασταθούν. Επιτρεπόμενοι τύποι αρχείων είναι: DOCX, ODS, XLSX.</target>
<target state="translated">Προσοχή: τα υπάρχοντα αρχεία θα αντικατασταθούν. Επιτρεπόμενοι τύποι αρχείων είναι: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Attention: existing files will be overwritten. Allowed file types are: DOCX, ODS, XLSX.</target>
<target>Attention: existing files will be overwritten. Allowed file types are: %extensions%.</target>
</trans-unit>
<trans-unit id="22pt8Gs" resname="download_invoice_renderer">
<source>download_invoice_renderer</source>

View File

@@ -24,7 +24,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Atentu: ekzistantaj dosieroj estos anstataŭigitaj. Permesitaj tipoj de dosieroj estas: DOCX, ODS, XLSX.</target>
<target>Atentu: ekzistantaj dosieroj estos anstataŭigitaj. Permesitaj tipoj de dosieroj estas: %extensions%.</target>
</trans-unit>
</body>
</file>

View File

@@ -24,7 +24,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Atención: Los archivos existentes serán sobrescritos. Los tipos de archivo permitidos son: DOCX, ODS, XLSX.</target>
<target state="translated">Atención: Los archivos existentes serán sobrescritos. Los tipos de archivo permitidos son: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">توجه: فایل های موجود رونویسی خواهند شد. فایل های مجاز عبارتند از: DOCX، ODS، XLSX.</target>
<target state="translated">توجه: فایل های موجود رونویسی خواهند شد. فایل های مجاز عبارتند از: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Huomioitavaa: olemassa olevat tiedostot korvataan. Sallitut tiedosto tyypit on: DOCX, ODS, XLSX.</target>
<target state="translated">Huomioitavaa: olemassa olevat tiedostot korvataan. Sallitut tiedosto tyypit on: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="needs-translation">Attention: existing files will be overwritten. Allowed file types are: DOCX, ODS, XLSX.</target>
<target state="needs-translation">Attention: existing files will be overwritten. Allowed file types are: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -28,7 +28,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Attention : les fichiers existants seront écrasés. Les types de fichiers autorisés sont : DOCX, ODS, XLSX.</target>
<target>Attention : les fichiers existants seront écrasés. Les types de fichiers autorisés sont : %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -24,7 +24,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>שים לב: קבצים קיימים ידרסו וימחקו. סוגי קבצים מותרים: DOCX, ODS, XLSX.</target>
<target>שים לב: קבצים קיימים ידרסו וימחקו. סוגי קבצים מותרים: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Oprez: postojeće datoteke će se prepisati. Dopuštene vrste datoteka su: DOCX, ODS, XLSX.</target>
<target>Oprez: postojeće datoteke će se prepisati. Dopuštene vrste datoteka su: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -24,7 +24,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Figyelem: a meglévő fájlok felülíródnak. A megengedett fájltípusok: DOCX, ODS, XLSX.</target>
<target>Figyelem: a meglévő fájlok felülíródnak. A megengedett fájltípusok: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -24,7 +24,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>주의: 기존 파일을 덮어씁니다. 허용되는 파일 형식은 DOCX, ODS, XLSX입니다.</target>
<target>주의: 기존 파일을 덮어씁니다. 허용되는 파일 형식은 %extensions%입니다.</target>
</trans-unit>
<trans-unit id="nA.KOyM" resname="Html">
<source>Html</source>

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Obs: Eksisterende filer vil bli overskrevet. Filtypene DOCX, ODS og XLSX støttes.</target>
<target>Obs: Eksisterende filer vil bli overskrevet. Filtypene %extensions% støttes.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Attentie: Bestaande bestanden zullen overschreven worden. Toegestaande bestandstypes zijn: DOCX, ODS, XLSX.</target>
<target>Attentie: Bestaande bestanden zullen overschreven worden. Toegestaande bestandstypes zijn: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -24,7 +24,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Uwaga: istniejące pliki zostaną nadpisane. Dozwolone typu plików to: DOCX, ODS, XLSX.</target>
<target state="translated">Uwaga: istniejące pliki zostaną nadpisane. Dozwolone typu plików to: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Atenção: os ficheiros já existentes serão substituídos. Os tipos de ficheiros permitidos são: DOCX, ODS, XLSX.</target>
<target state="translated">Atenção: os ficheiros já existentes serão substituídos. Os tipos de ficheiros permitidos são: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -24,7 +24,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Atenção: os arquivos já existentes serão substituídos. Os tipos dos arquivos permitidos são: DOCX, ODS, XLSX.</target>
<target>Atenção: os arquivos já existentes serão substituídos. Os tipos dos arquivos permitidos são: %extensions%.</target>
</trans-unit>
<trans-unit id="nA.KOyM" resname="Html">
<source>Html</source>

View File

@@ -24,7 +24,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Atenție: fișierele existente vor fi suprascrise. Tipuri de fișiere permise: DOCX, ODS, XLSX.</target>
<target>Atenție: fișierele existente vor fi suprascrise. Tipuri de fișiere permise: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -16,7 +16,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Внимание: существующие файлы будут перезаписаны. Допустимые типы файлов: DOCX, ODS, XLSX.</target>
<target state="translated">Внимание: существующие файлы будут перезаписаны. Допустимые типы файлов: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Obs: redan exsiterande filer kommer att skrivas över. Tillåtna fil typer är: DOCX, ODS, XLSX.</target>
<target state="translated">Obs: redan exsiterande filer kommer att skrivas över. Tillåtna fil typer är: %extensions%.</target>
</trans-unit>
<trans-unit id="Utbj3k_" resname="invoice">
<source>invoice</source>

View File

@@ -32,7 +32,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Dikkat: mevcut dosyaların üzerine yazılacaktır. İzin verilen dosya türleri: DOCX, ODS, XLSX.</target>
<target>Dikkat: mevcut dosyaların üzerine yazılacaktır. İzin verilen dosya türleri: %extensions%.</target>
</trans-unit>
<trans-unit id="nA.KOyM" resname="Html">
<source>Html</source>

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Увага: існуючі файли будуть перезаписані. Дозволені типи файлів: DOCX, ODS, XLSX.</target>
<target state="translated">Увага: існуючі файли будуть перезаписані. Дозволені типи файлів: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -24,7 +24,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Chú ý: các tệp hiện có sẽ bị ghi đè. Các loại tệp được phép là: DOCX, ODS, XLSX.</target>
<target>Chú ý: các tệp hiện có sẽ bị ghi đè. Các loại tệp được phép là: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -32,7 +32,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">注意:现有文件将被覆盖。允许的文件类型是:DOCX, ODS, XLSX.</target>
<target state="translated">注意:现有文件将被覆盖。允许的文件类型是:%extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -210,6 +210,10 @@
<source>customer.number_format</source>
<target>Kundennummer Format</target>
</trans-unit>
<trans-unit id="1cAQPNS" resname="customer.allow_duplicate_number">
<source>customer.allow_duplicate_number</source>
<target>Erlaube Kundennummer mehrfach zu verwenden</target>
</trans-unit>
<trans-unit id="IZY5kqL" resname="allowed_replacer">
<source>allowed_replacer</source>
<target>Erlaubte Ersetzer: %replacer%</target>

View File

@@ -210,6 +210,10 @@
<source>customer.number_format</source>
<target>Customer number Format</target>
</trans-unit>
<trans-unit id="1cAQPNS" resname="customer.allow_duplicate_number">
<source>customer.allow_duplicate_number</source>
<target>Allow duplicate account number</target>
</trans-unit>
<trans-unit id="IZY5kqL" resname="allowed_replacer">
<source>allowed_replacer</source>
<target>Allowed replacer: %replacer%</target>

View File

@@ -3007,9 +3007,9 @@ __metadata:
linkType: hard
"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001359, caniuse-lite@npm:^1.0.30001400":
version: 1.0.30001441
resolution: "caniuse-lite@npm:1.0.30001441"
checksum: 0f5aa8f7ea4d165e88e0d1eaa44564c5bfee66641f265a1fd959e74f0a7e6bc0207db6c28e2fb63dc8b2cd23e0e3cee06c4f372de11c93c57ff5ff4207962c3f
version: 1.0.30001446
resolution: "caniuse-lite@npm:1.0.30001446"
checksum: b31a7e1837783afd7f3d4cb742689996c0a09d67394ddaa0609fd2bce00ceea65c448e25f91c03ba0f2d0e345b7e28fd5bc636c6760c949621a654c0effe74b5
languageName: node
linkType: hard