Release 2.53 (#5878)
This commit is contained in:
12
.github/workflows/docker.yaml
vendored
12
.github/workflows/docker.yaml
vendored
@@ -16,13 +16,13 @@ jobs:
|
||||
steps:
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{secrets.DOCKERHUB_USERNAME}}
|
||||
password: ${{secrets.DOCKERHUB_PASSWORD}}
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
echo "kimai_version=$version" >> $GITHUB_ENV
|
||||
|
||||
- name: FPM image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
push: true
|
||||
|
||||
- name: Apache image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
push: true
|
||||
|
||||
- name: Development image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
|
||||
2
.github/workflows/lock-threads.yaml
vendored
2
.github/workflows/lock-threads.yaml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
- uses: dessant/lock-threads@v6
|
||||
with:
|
||||
process-only: 'issues, prs'
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
8
.github/workflows/release-drafter.yaml
vendored
8
.github/workflows/release-drafter.yaml
vendored
@@ -20,11 +20,11 @@ jobs:
|
||||
|
||||
update_release_draft:
|
||||
permissions:
|
||||
contents: write # for release-drafter/release-drafter to create a github release
|
||||
pull-requests: write # for release-drafter/release-drafter to add label to PR
|
||||
contents: write # for release-drafter/release-drafter to create a github release
|
||||
pull-requests: read
|
||||
needs: correct_repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@v6
|
||||
- uses: release-drafter/release-drafter@v7
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
6
.github/workflows/testing.yaml
vendored
6
.github/workflows/testing.yaml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
steps:
|
||||
|
||||
- name: Clone Kimai
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
run: echo "composer_cache_directory=$(composer config cache-dir)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: "${{ env.composer_cache_directory }}"
|
||||
key: ${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
|
||||
- name: Upload code coverage
|
||||
if: matrix.php == '8.5'
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./coverage.xml
|
||||
|
||||
2
.github/workflows/website.yaml
vendored
2
.github/workflows/website.yaml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Emit repository_dispatch
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
uses: peter-evans/repository-dispatch@v4
|
||||
with:
|
||||
token: ${{ secrets.WEBSITE_ACCESS_TOKEN }}
|
||||
repository: kimai/www.kimai.org
|
||||
|
||||
@@ -96,6 +96,7 @@ export default class KimaiPlugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use the plugin directly
|
||||
* @param {string} title
|
||||
* @returns {string}
|
||||
*/
|
||||
|
||||
@@ -83,7 +83,7 @@ export default class KimaiTeamForm extends KimaiFormPlugin {
|
||||
prototype.dataset['widgetCounter'] = (++counter).toString();
|
||||
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = newWidget;
|
||||
temp.innerHTML = ESCAPER.sanitize(newWidget);
|
||||
temp.querySelector('input[type=hidden]').value = option.value;
|
||||
|
||||
const newNode = temp.firstElementChild;
|
||||
|
||||
@@ -74,9 +74,6 @@ export default class KimaiAPILink extends KimaiPlugin {
|
||||
const successHandle = () => {
|
||||
EVENTS.trigger(eventName);
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadedContent'));
|
||||
if (attributes['msgSuccess'] !== undefined) {
|
||||
ALERT.success(attributes['msgSuccess']);
|
||||
}
|
||||
};
|
||||
const errorHandle = (error) => {
|
||||
let message = 'action.update.error';
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export default class KimaiEscape extends KimaiPlugin {
|
||||
|
||||
@@ -26,14 +27,23 @@ export default class KimaiEscape extends KimaiPlugin {
|
||||
return '';
|
||||
}
|
||||
|
||||
const tagsToReplace = {
|
||||
const charToReplace = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
};
|
||||
|
||||
return title.replace(/[&<>]/g, function(tag) {
|
||||
return tagsToReplace[tag] || tag;
|
||||
return title.replace(/[&<>"]/g, function(tag) {
|
||||
return charToReplace[tag] || tag;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} html
|
||||
* @returns {string}
|
||||
*/
|
||||
sanitize(html) {
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,7 +663,7 @@ export default class KimaiCalendar {
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
return escaper.sanitize(`
|
||||
<div class="calendar-entry">
|
||||
<ul>
|
||||
<li>` + this.options['translations']['customer'] + `: ` + escaper.escapeForHtml(eventObj.customer) + `</li>
|
||||
@@ -672,7 +672,7 @@ export default class KimaiCalendar {
|
||||
</ul>` +
|
||||
(eventObj.description !== null || eventObj.tags.length > 0 ? '<hr>' : '') +
|
||||
(eventObj.description ? '<div>' + escaper.escapeForHtml(eventObj.description) + '</div>' : '') + tags + `
|
||||
</div>`;
|
||||
</div>`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
.time-off { color: var(--kimai-time-off); }
|
||||
.other, .parental, .unpaid-vacation { color: var(--kimai-other); }
|
||||
|
||||
.bg-public-holiday{ background-color: var(--kimai-public-holiday-bg); --tblr-table-bg: var(--kimai-public-holiday-bg); i.fas{ color: var(--kimai-public-holiday); } };
|
||||
.bg-public-holiday { background-color: var(--kimai-public-holiday-bg); --tblr-table-bg: var(--kimai-public-holiday-bg); i.fas{ color: var(--kimai-public-holiday); } };
|
||||
.bg-holiday { background-color: var(--kimai-holiday-bg); --tblr-table-bg: var(--kimai-holiday-bg); i.fas{ color: var(--kimai-holiday); } };
|
||||
.bg-sickness, .bg-sickness-child { background-color: var(--kimai-sickness-bg); --tblr-table-bg: var(--kimai-sickness-bg); i.fas{ color: var(--kimai-sickness); } };
|
||||
.bg-time-off { background-color: var(--kimai-time-off-bg); --tblr-table-bg: var(--kimai-time-off-bg); i.fas{ color: var(--kimai-time-off); } };
|
||||
|
||||
397
composer.lock
generated
397
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,7 @@
|
||||
"bootstrap": "^5.3",
|
||||
"chart.js": "^4",
|
||||
"core-js": "^3",
|
||||
"dompurify": "^3",
|
||||
"eslint": "^9",
|
||||
"globals": "^15",
|
||||
"gridstack": "^7",
|
||||
|
||||
@@ -1665,11 +1665,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/Extension/SelectWithApiDataExtension.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$name of method Symfony\\\\Component\\\\Routing\\\\Generator\\\\UrlGeneratorInterface\\:\\:generate\\(\\) expects string, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Extension/SelectWithApiDataExtension.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Form\\\\Helper\\\\ActivityHelper\\:\\:\\$pattern \\(string\\|null\\) does not accept bool\\|float\\|int\\|string\\|null\\.$#"
|
||||
count: 1
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
public/build/app.f0f8091d.js
Normal file
2
public/build/app.f0f8091d.js
Normal file
File diff suppressed because one or more lines are too long
@@ -175,3 +175,5 @@
|
||||
/*!
|
||||
* [KIMAI] Wrapper class for loading Kimai app in browser script scope
|
||||
*/
|
||||
|
||||
/*! @license DOMPurify 3.3.3 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.3.3/LICENSE */
|
||||
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/app.41748d55.js"
|
||||
"/build/app.f0f8091d.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.99ea4166.css"
|
||||
@@ -54,7 +54,7 @@
|
||||
"calendar": {
|
||||
"js": [
|
||||
"/build/runtime.684e9f6d.js",
|
||||
"/build/calendar.7e3186b2.js"
|
||||
"/build/calendar.13247e65.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/calendar.d757753e.css"
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.684e9f6d.js": "sha384-suKiEX2de4fdNqQzdYbUd6osp4AepD9FiMXl+1QdvgMW9dcQqUWQNQasf3KWzwLr",
|
||||
"/build/app.41748d55.js": "sha384-iVPHnOXYjVDuq8567h8I3U2gl5SCV3mNMFNQCJdgMiNuFMZ4KO7PXOLanjgcGzTt",
|
||||
"/build/app.f0f8091d.js": "sha384-F6UUWeiIwbFffkZMRmbczLyw5tuOvtUNLuX1/iY6ZfncN1vYGSgalEumAp4HXFvX",
|
||||
"/build/app.99ea4166.css": "sha384-OF0ozOygdShRhEacN7Tb8YtDSXABczVqjmXRYQjX1YGTmuRQMNCipFTqzHd9IyCH",
|
||||
"/build/app-rtl.15853b82.js": "sha384-UnKKgLMu9FnRT+CFE0no/+UiUks012bYriQdUWa6f02mo6Lswl947mPybjvKL503",
|
||||
"/build/app-rtl.16262d9a.css": "sha384-CjN7UFkBszmM9k6xfN8LWH79IOsgpVTwwHoffvAOc04j9+y904uDw/Y+LnSQmrDj",
|
||||
@@ -92,7 +92,7 @@
|
||||
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
|
||||
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
|
||||
"/build/chart.56f16a68.js": "sha384-SWnYjAbZ8OWEvTP+IZfGuBMWJIcH9OZWLYQ4p38KMPmqfufsc2zhzqWkALO3mCBO",
|
||||
"/build/calendar.7e3186b2.js": "sha384-umm2cw67dX5I6+PJ+PpNuHwWgKDJc+r2MmpN8az3dtAqUGa3N9ZGHD0SHc7xfFVB",
|
||||
"/build/calendar.13247e65.js": "sha384-8b3wBuxn8m2FsxLMtbIpZTg1SGBX9b+0dYRTnF63mGQfcXhRGHKtUs5REwVyhYiP",
|
||||
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
|
||||
"/build/dashboard.9708ae5e.js": "sha384-QN7XIQuxFZu76sHVrgdpZL81+Q2VTwcgF3aI2CnpwYZiMoPbzrcTGfPL3RDy696t",
|
||||
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"build/app.css": "/build/app.99ea4166.css",
|
||||
"build/app.js": "/build/app.41748d55.js",
|
||||
"build/app.js": "/build/app.f0f8091d.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.16262d9a.css",
|
||||
"build/app-rtl.js": "/build/app-rtl.15853b82.js",
|
||||
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
|
||||
@@ -11,7 +11,7 @@
|
||||
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
|
||||
"build/chart.js": "/build/chart.56f16a68.js",
|
||||
"build/calendar.css": "/build/calendar.d757753e.css",
|
||||
"build/calendar.js": "/build/calendar.7e3186b2.js",
|
||||
"build/calendar.js": "/build/calendar.13247e65.js",
|
||||
"build/dashboard.css": "/build/dashboard.b7129fa1.css",
|
||||
"build/dashboard.js": "/build/dashboard.9708ae5e.js",
|
||||
"build/highlight.css": "/build/highlight.98bf3927.css",
|
||||
|
||||
@@ -72,15 +72,14 @@ final class ActivityController extends BaseApiController
|
||||
|
||||
/** @var array<int> $projects */
|
||||
$projects = $paramFetcher->get('projects');
|
||||
$project = $paramFetcher->get('project');
|
||||
if (\is_string($project) && $project !== '') {
|
||||
$projects[] = $project;
|
||||
$pr = $paramFetcher->get('project');
|
||||
if (\is_string($pr) && $pr !== '') {
|
||||
$projects[] = $pr;
|
||||
}
|
||||
|
||||
foreach (array_unique($projects) as $projectId) {
|
||||
$project = $projectRepository->find($projectId);
|
||||
if ($project === null) {
|
||||
throw $this->createNotFoundException('Unknown project: ' . $projectId);
|
||||
foreach ($projectRepository->findByIds(array_unique($projects)) as $project) {
|
||||
if (!$this->isGranted('access', $project)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Project: ' . $project->getId());
|
||||
}
|
||||
$query->addProject($project);
|
||||
}
|
||||
|
||||
@@ -85,15 +85,14 @@ final class ProjectController extends BaseApiController
|
||||
|
||||
/** @var array<int> $customers */
|
||||
$customers = $paramFetcher->get('customers');
|
||||
$customer = $paramFetcher->get('customer');
|
||||
if (\is_string($customer) && $customer !== '') {
|
||||
$customers[] = $customer;
|
||||
$cu = $paramFetcher->get('customer');
|
||||
if (\is_string($cu) && $cu !== '') {
|
||||
$customers[] = $cu;
|
||||
}
|
||||
|
||||
foreach (array_unique($customers) as $customerId) {
|
||||
$customer = $customerRepository->find($customerId);
|
||||
if ($customer === null) {
|
||||
throw $this->createNotFoundException('Unknown customer: ' . $customerId);
|
||||
foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
|
||||
if (!$this->isGranted('access', $customer)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Customer: ' . $customer->getId());
|
||||
}
|
||||
$query->addCustomer($customer);
|
||||
}
|
||||
|
||||
@@ -126,45 +126,42 @@ final class TimesheetController extends BaseApiController
|
||||
|
||||
/** @var array<int> $customers */
|
||||
$customers = $paramFetcher->get('customers');
|
||||
$customer = $paramFetcher->get('customer');
|
||||
if (\is_string($customer) && $customer !== '') {
|
||||
$customers[] = $customer;
|
||||
$cu = $paramFetcher->get('customer');
|
||||
if (\is_string($cu) && $cu !== '') {
|
||||
$customers[] = $cu;
|
||||
}
|
||||
|
||||
foreach (array_unique($customers) as $customerId) {
|
||||
$customer = $customerRepository->find($customerId);
|
||||
if ($customer === null) {
|
||||
throw $this->createNotFoundException('Unknown customer: ' . $customerId);
|
||||
foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
|
||||
if (!$this->isGranted('access', $customer)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Customer: ' . $customer->getId());
|
||||
}
|
||||
$query->addCustomer($customer);
|
||||
}
|
||||
|
||||
/** @var array<int> $projects */
|
||||
$projects = $paramFetcher->get('projects');
|
||||
$project = $paramFetcher->get('project');
|
||||
if (\is_string($project) && $project !== '') {
|
||||
$projects[] = $project;
|
||||
$pr = $paramFetcher->get('project');
|
||||
if (\is_string($pr) && $pr !== '') {
|
||||
$projects[] = $pr;
|
||||
}
|
||||
|
||||
foreach (array_unique($projects) as $projectId) {
|
||||
$project = $projectRepository->find($projectId);
|
||||
if ($project === null) {
|
||||
throw $this->createNotFoundException('Unknown project: ' . $project);
|
||||
foreach ($projectRepository->findByIds(array_unique($projects)) as $project) {
|
||||
if (!$this->isGranted('access', $project)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Project: ' . $project->getId());
|
||||
}
|
||||
$query->addProject($project);
|
||||
}
|
||||
|
||||
/** @var array<int> $activities */
|
||||
$activities = $paramFetcher->get('activities');
|
||||
$activity = $paramFetcher->get('activity');
|
||||
if (\is_string($activity) && $activity !== '') {
|
||||
$activities[] = $activity;
|
||||
$ac = $paramFetcher->get('activity');
|
||||
if (\is_string($ac) && $ac !== '') {
|
||||
$activities[] = $ac;
|
||||
}
|
||||
|
||||
foreach (array_unique($activities) as $activityId) {
|
||||
$activity = $activityRepository->find($activityId);
|
||||
if ($activity === null) {
|
||||
throw $this->createNotFoundException('Unknown activity: ' . $activity);
|
||||
foreach ($activityRepository->findByIds(array_unique($activities)) as $activity) {
|
||||
if (!$this->isGranted('access', $activity)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Activity: ' . $activity->getId());
|
||||
}
|
||||
$query->addActivity($activity);
|
||||
}
|
||||
@@ -234,7 +231,6 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
$data = $this->repository->getPagerfantaForQuery($query);
|
||||
|
||||
$view = new View($data, 200);
|
||||
|
||||
$full = $paramFetcher->get('full');
|
||||
@@ -486,8 +482,7 @@ final class TimesheetController extends BaseApiController
|
||||
|
||||
$copy = $paramFetcher->get('copy');
|
||||
if ($copy === 'all') {
|
||||
$copyTimesheet->setHourlyRate($timesheet->getHourlyRate());
|
||||
$copyTimesheet->setFixedRate($timesheet->getFixedRate());
|
||||
// we do NOT copy rates, as those should always be calculated from the configured settings
|
||||
$copyTimesheet->setDescription($timesheet->getDescription());
|
||||
$copyTimesheet->setBillable($timesheet->isBillable());
|
||||
|
||||
|
||||
@@ -236,16 +236,18 @@ final class UserController extends BaseApiController
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'User ID to set the custom-field value for', required: true)]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(type: 'array', items: new OA\Items(new Model(type: UserPreference::class))))]
|
||||
#[Route(methods: ['PATCH'], path: '/{id}/preferences', requirements: ['id' => '\d+'])]
|
||||
public function updateUserPreference(User $profile, Request $request, EventDispatcherInterface $dispatcher): Response
|
||||
public function updateUserPreference(User $profile, Request $request, EventDispatcherInterface $dispatcher, UserService $userService): Response
|
||||
{
|
||||
$event = new PrepareUserEvent($profile, false);
|
||||
$dispatcher->dispatch($event);
|
||||
$dirty = false;
|
||||
|
||||
foreach ($request->request->all() as $preference) {
|
||||
// why is this not handled by FosRestBundle ?
|
||||
if (!\is_array($preference)) {
|
||||
throw new BadRequestHttpException('Invalid request, array expected');
|
||||
}
|
||||
|
||||
if (!\array_key_exists('name', $preference) || !\array_key_exists('value', $preference)) {
|
||||
throw new BadRequestHttpException('Missing required parameter "name" or "value"');
|
||||
}
|
||||
@@ -253,14 +255,23 @@ final class UserController extends BaseApiController
|
||||
$name = $preference['name'];
|
||||
$value = $preference['value'];
|
||||
|
||||
// TODO allow to update preferences that are used internally but not registered via PrepareUserEvent
|
||||
|
||||
if (null === ($meta = $profile->getPreference($name))) {
|
||||
throw $this->createNotFoundException(\sprintf('Unknown custom-field "%s" requested', $name));
|
||||
}
|
||||
|
||||
if (!$meta->isEnabled()) {
|
||||
throw $this->createAccessDeniedException('User tried to update preference: ' . $name);
|
||||
}
|
||||
|
||||
$meta->setValue($value);
|
||||
$dirty = true;
|
||||
}
|
||||
|
||||
$this->repository->saveUser($profile);
|
||||
if ($dirty) {
|
||||
$userService->saveUser($profile);
|
||||
}
|
||||
|
||||
$view = new View($profile, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace App\Configuration;
|
||||
|
||||
final class SamlConfiguration implements SamlConfigurationInterface
|
||||
{
|
||||
public function __construct(private SystemConfiguration $configuration)
|
||||
public function __construct(private readonly SystemConfiguration $configuration)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -54,4 +54,9 @@ final class SamlConfiguration implements SamlConfigurationInterface
|
||||
{
|
||||
return $this->configuration->getSamlConnection();
|
||||
}
|
||||
|
||||
public function cleanupLongRelayState(): bool
|
||||
{
|
||||
return (bool) $this->configuration->find('saml.connection.cleanupLongRelayState');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,4 +46,7 @@ interface SamlConfigurationInterface
|
||||
public function isRolesResetOnLogin(): bool;
|
||||
|
||||
public function getConnection(): array;
|
||||
|
||||
// TODO 3.0 activate me
|
||||
//public function cleanupLongRelayState(): bool;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,6 @@ final class SystemConfiguration
|
||||
$array = &$replaced;
|
||||
while (\count($keys) > 1) {
|
||||
$search = array_shift($keys);
|
||||
/* @phpstan-ignore-next-line */
|
||||
if (!\array_key_exists($search, $array) || !\is_array($array[$search])) {
|
||||
$array[$search] = [];
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ final class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.52.0';
|
||||
public const VERSION = '2.53.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 25200;
|
||||
public const VERSION_ID = 25300;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -66,13 +66,20 @@ final class SamlController extends AbstractController
|
||||
throw new ServiceUnavailableHttpException(message: 'Unknown firewall.');
|
||||
}
|
||||
|
||||
// this can be an absolute URL including query parameters
|
||||
$redirectTarget = $this->getTargetPath($session, $firewallName);
|
||||
if ($redirectTarget === null || $redirectTarget === '') {
|
||||
$redirectTarget = $this->generateUrl('homepage', [], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
}
|
||||
|
||||
$url = $this->authFactory->create()->login($redirectTarget, [], false, false, true);
|
||||
// the protocol defines max 80 byte for RelayState, even if most IdP support more - see #5752
|
||||
if (method_exists($this->samlConfiguration, 'cleanupLongRelayState') && $this->samlConfiguration->cleanupLongRelayState()) {
|
||||
if (\strlen($redirectTarget) > 80 && ($pos = stripos($redirectTarget, '?')) !== false) {
|
||||
$redirectTarget = substr($redirectTarget, 0, $pos);
|
||||
}
|
||||
}
|
||||
|
||||
$url = $this->authFactory->create()->login($redirectTarget, [], false, false, true);
|
||||
if ($url === null) {
|
||||
throw new \RuntimeException('SAML login failed');
|
||||
}
|
||||
|
||||
@@ -26,19 +26,6 @@ use Symfony\Contracts\Cache\ItemInterface;
|
||||
#[IsGranted('system_information')]
|
||||
final class DoctorController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Required PHP extensions for Kimai.
|
||||
*/
|
||||
public const REQUIRED_EXTENSIONS = [
|
||||
'intl',
|
||||
'json',
|
||||
'mbstring',
|
||||
'pdo',
|
||||
'xml',
|
||||
'xsl',
|
||||
'zip',
|
||||
];
|
||||
|
||||
/**
|
||||
* Directories which need to be writable by the webserver.
|
||||
*/
|
||||
@@ -118,7 +105,8 @@ final class DoctorController extends AbstractController
|
||||
*/
|
||||
private function getOpcacheConfiguration(): array
|
||||
{
|
||||
$status = \function_exists('opcache_get_status') ? opcache_get_status() : false;
|
||||
$known = \function_exists('opcache_get_status');
|
||||
$status = $known ? opcache_get_status() : false;
|
||||
|
||||
$enabled = \is_array($status) && $status['opcache_enabled'];
|
||||
|
||||
@@ -127,6 +115,7 @@ final class DoctorController extends AbstractController
|
||||
}
|
||||
|
||||
return [
|
||||
'unknown' => !$known,
|
||||
'enabled' => $enabled,
|
||||
'status' => $status,
|
||||
];
|
||||
@@ -168,9 +157,27 @@ final class DoctorController extends AbstractController
|
||||
*/
|
||||
private function getLoadedExtensions(): array
|
||||
{
|
||||
$json = file_get_contents(__DIR__ . '/../../composer.json');
|
||||
if ($json === false) {
|
||||
return ['Failed loading composer.json' => false];
|
||||
}
|
||||
|
||||
$composer = json_decode($json, true);
|
||||
if (!\is_array($composer)) {
|
||||
return ['Failed parsing composer.json' => false];
|
||||
}
|
||||
|
||||
if (!\array_key_exists('require', $composer)) {
|
||||
return ['Missing requirements in composer.json' => false];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach (self::REQUIRED_EXTENSIONS as $extName) {
|
||||
foreach ($composer['require'] as $name => $version) {
|
||||
if (!str_starts_with($name, 'ext-')) {
|
||||
continue;
|
||||
}
|
||||
$extName = str_replace('ext-', '', $name);
|
||||
$results[$extName] = false;
|
||||
if (\extension_loaded($extName)) {
|
||||
$results[$extName] = true;
|
||||
|
||||
@@ -99,7 +99,7 @@ final class AppExtension extends Extension
|
||||
foreach (range(0, $iterator->getDepth()) as $depth) {
|
||||
$keys[] = $iterator->getSubIterator($depth)->key();
|
||||
}
|
||||
$newConfig[implode('.', $keys)] = $value;
|
||||
$newConfig[implode('.', $keys)] = $value; // @phpstan-ignore argument.type
|
||||
}
|
||||
|
||||
$container->setParameter('kimai.config', $newConfig);
|
||||
|
||||
@@ -862,6 +862,9 @@ final class Configuration implements ConfigurationInterface
|
||||
->scalarNode('baseurl')->end()
|
||||
->booleanNode('strict')->end()
|
||||
->booleanNode('debug')->end()
|
||||
->booleanNode('cleanupLongRelayState')
|
||||
->defaultFalse()
|
||||
->end()
|
||||
->arrayNode('idp')
|
||||
->children()
|
||||
->scalarNode('entityId')->end()
|
||||
|
||||
@@ -141,6 +141,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
* Contact email
|
||||
*/
|
||||
#[ORM\Column(name: 'email', type: Types::STRING, length: 75, nullable: true)]
|
||||
#[Assert\Email(mode: 'html5')]
|
||||
#[Assert\Length(max: 75)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
@@ -148,6 +149,8 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
private ?string $email = null;
|
||||
#[ORM\Column(name: 'homepage', type: Types::STRING, length: 100, nullable: true)]
|
||||
#[Assert\Length(max: 100)]
|
||||
#[Assert\Url]
|
||||
#[Assert\NoSuspiciousCharacters]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'homepage')]
|
||||
|
||||
@@ -81,6 +81,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
*/
|
||||
#[ORM\Column(name: 'alias', type: Types::STRING, length: 60, nullable: true)]
|
||||
#[Assert\Length(max: 60)]
|
||||
#[Constraints\NoHtmlSpecialCharacters]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'alias')]
|
||||
@@ -96,6 +97,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
*/
|
||||
#[ORM\Column(name: 'title', type: Types::STRING, length: 50, nullable: true)]
|
||||
#[Assert\Length(max: 50)]
|
||||
#[Constraints\NoHtmlSpecialCharacters]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'title')]
|
||||
@@ -162,6 +164,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
#[Assert\NotBlank(groups: ['Registration', 'UserCreate', 'Profile'])]
|
||||
#[Assert\Regex(pattern: '/\//', match: false, groups: ['Registration', 'UserCreate', 'Profile'])]
|
||||
#[Assert\Length(min: 2, max: 64, groups: ['Registration', 'UserCreate', 'Profile'])]
|
||||
#[Constraints\NoHtmlSpecialCharacters]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?string $username = null;
|
||||
|
||||
@@ -11,12 +11,16 @@ namespace App\Export\Package\CellFormatter;
|
||||
|
||||
final class ArrayFormatter implements CellFormatterInterface
|
||||
{
|
||||
/**
|
||||
* @param mixed|array<int, string> $value
|
||||
* @return mixed
|
||||
*/
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if (!\is_array($value)) {
|
||||
throw new \InvalidArgumentException('Only arrays are supported');
|
||||
}
|
||||
|
||||
return implode(', ', $value);
|
||||
return implode(', ', $value); // @phpstan-ignore argument.type
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,6 @@ final class ArrayFormatter implements CellFormatterInterface
|
||||
throw new \InvalidArgumentException('Unsupported value given, only array is supported');
|
||||
}
|
||||
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), implode(';', $value));
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), implode(';', $value)); // @phpstan-ignore argument.type
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ final class SelectWithApiDataExtension extends AbstractTypeExtension
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var array{create: string, select: bool, route: string, route_params: array<string, string>, empty_route_params: array<string, string>, reload: string} $apiData */
|
||||
$apiData = $options['api_data'];
|
||||
|
||||
if (!\is_array($apiData)) {
|
||||
|
||||
@@ -12,7 +12,6 @@ namespace App\Form\Type;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Email;
|
||||
|
||||
final class MailType extends AbstractType
|
||||
{
|
||||
@@ -20,9 +19,7 @@ final class MailType extends AbstractType
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'label' => 'email',
|
||||
'constraints' => [
|
||||
new Email(['mode' => 'html5'])
|
||||
],
|
||||
// no constraint by default, as the form or used entities should add that
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,9 +25,17 @@ final class WeekDaysType extends AbstractType
|
||||
{
|
||||
$builder->addModelTransformer(new CallbackTransformer(
|
||||
function ($weekdays): array {
|
||||
if ($weekdays === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return explode(',', $weekdays);
|
||||
},
|
||||
function ($weekdays): string {
|
||||
if ($weekdays === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return implode(',', $weekdays);
|
||||
}
|
||||
));
|
||||
|
||||
@@ -77,7 +77,7 @@ class UserCreateType extends UserEditType
|
||||
parent::configureOptions($resolver);
|
||||
|
||||
$resolver->setDefaults([
|
||||
'validation_groups' => ['UserCreate', 'Registration'],
|
||||
'validation_groups' => ['UserCreate', 'Registration', 'Default'],
|
||||
'include_roles' => false,
|
||||
'include_teams' => false,
|
||||
]);
|
||||
|
||||
@@ -31,7 +31,7 @@ class UserEditType extends AbstractType
|
||||
{
|
||||
use ColorTrait;
|
||||
|
||||
public function __construct(private SystemConfiguration $configuration)
|
||||
public function __construct(private readonly SystemConfiguration $configuration)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ class UserEditType extends AbstractType
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'validation_groups' => ['Profile'],
|
||||
'validation_groups' => ['Profile', 'Default'],
|
||||
'data_class' => User::class,
|
||||
'csrf_protection' => true,
|
||||
'csrf_field_name' => '_token',
|
||||
|
||||
@@ -37,7 +37,7 @@ use Doctrine\ORM\QueryBuilder;
|
||||
class ActivityRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* @param int[] $activityIds
|
||||
* @param array<int, string|int> $activityIds
|
||||
* @return array<Activity>
|
||||
*/
|
||||
public function findByIds(array $activityIds): array
|
||||
|
||||
@@ -37,7 +37,7 @@ use Doctrine\ORM\QueryBuilder;
|
||||
class CustomerRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* @param int[] $customerIDs
|
||||
* @param array<int, string|int> $customerIDs
|
||||
* @return array<Customer>
|
||||
*/
|
||||
public function findByIds(array $customerIDs): array
|
||||
|
||||
@@ -40,7 +40,7 @@ use Doctrine\ORM\QueryBuilder;
|
||||
class ProjectRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* @param int[] $projectIds
|
||||
* @param array<int, string|int> $projectIds
|
||||
* @return array<Project>
|
||||
*/
|
||||
public function findByIds(array $projectIds): array
|
||||
|
||||
@@ -631,11 +631,13 @@ class TimesheetRepository extends EntityRepository
|
||||
|
||||
if ($query->hasProjects()) {
|
||||
$qb->andWhere($qb->expr()->in('t.project', ':project'))
|
||||
->setParameter('project', $query->getProjectIds());
|
||||
} elseif ($query->hasCustomers()) {
|
||||
->setParameter('project', $query->getProjects());
|
||||
}
|
||||
|
||||
if ($query->hasCustomers()) {
|
||||
$requiresCustomer = true;
|
||||
$qb->andWhere($qb->expr()->in('p.customer', ':customer'))
|
||||
->setParameter('customer', $query->getCustomerIds());
|
||||
->setParameter('customer', $query->getCustomers());
|
||||
}
|
||||
|
||||
$tags = $query->getTags();
|
||||
|
||||
@@ -150,7 +150,7 @@ final class SamlProvider
|
||||
}
|
||||
|
||||
if (!empty($results)) {
|
||||
return implode(' ', $results);
|
||||
return implode(' ', $results); // @phpstan-ignore argument.type
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -24,11 +24,38 @@ final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSucces
|
||||
|
||||
protected function determineTargetUrl(Request $request): string
|
||||
{
|
||||
// see https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf
|
||||
// if using the Deflate encoding, RelayState will be submitted using the query
|
||||
$relayState = $request->request->get('RelayState', $request->query->get('RelayState'));
|
||||
if (\is_scalar($relayState)) {
|
||||
$relayState = (string) $relayState;
|
||||
if ($relayState !== $this->httpUtils->generateUri($request, (string) $this->options['login_path'])) {
|
||||
return $relayState;
|
||||
|
||||
if (\is_string($relayState)) {
|
||||
$values = parse_url($relayState);
|
||||
|
||||
// we use only the path part of the URL to prevent external redirects
|
||||
$path = null;
|
||||
if (\is_array($values) && \array_key_exists('path', $values)) {
|
||||
$path = $values['path'];
|
||||
}
|
||||
|
||||
if (\is_string($path)
|
||||
&& $path !== ''
|
||||
&& str_starts_with($path, '/')
|
||||
&& !str_starts_with($path, '//')
|
||||
&& !str_contains($path, '\\')
|
||||
) {
|
||||
$target = $this->httpUtils->generateUri($request, $path);
|
||||
$loginUrl = $this->httpUtils->generateUri($request, (string) $this->options['login_path']);
|
||||
|
||||
if (\array_key_exists('scheme', $values) && str_starts_with($values['scheme'], 'http')) {
|
||||
if (\array_key_exists('host', $values) && !str_starts_with($target, $values['scheme'] . '://' . $values['host'])) {
|
||||
$target = null;
|
||||
}
|
||||
}
|
||||
|
||||
// make sure that the login URL is not the target, which would be an endless loop for the user
|
||||
if ($target !== null && $target !== $loginUrl) {
|
||||
return $target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
48
src/Twig/ContractExtensions.php
Normal file
48
src/Twig/ContractExtensions.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\WorkingTime\Calculator\WorkingTimeCalculator;
|
||||
use App\WorkingTime\WorkingTimeService;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigTest;
|
||||
|
||||
final class ContractExtensions extends AbstractExtension
|
||||
{
|
||||
/** @var array<string, WorkingTimeCalculator> */
|
||||
private array $calculators = [];
|
||||
|
||||
public function __construct(private readonly WorkingTimeService $workingTimeService)
|
||||
{
|
||||
}
|
||||
|
||||
public function getTests(): array
|
||||
{
|
||||
return [
|
||||
/* @var array{user: User, date: \DateTimeInterface} $values */
|
||||
new TwigTest('work_day', function (array $values): bool {
|
||||
$user = $values['user'];
|
||||
if ($user->getId() === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$id = 'user_' . $user->getId();
|
||||
if (!\array_key_exists($id, $this->calculators)) {
|
||||
$this->calculators[$id] = $this->workingTimeService->getContractMode($user)->getCalculator($user);
|
||||
}
|
||||
|
||||
$date = $values['date'];
|
||||
|
||||
return $this->calculators[$id]->isWorkDay($date);
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,12 @@ final class StrictPolicy implements SecurityPolicyInterface
|
||||
}
|
||||
|
||||
if ($obj instanceof User) {
|
||||
if (\in_array($lcm, ['getpassword', 'gettotpsecret', 'getplainpassword', 'getconfirmationtoken', 'gettotpauthenticationconfiguration'], true)) {
|
||||
if (str_contains($lcm, 'password')
|
||||
|| str_contains($lcm, 'totp')
|
||||
|| str_contains($lcm, 'api')
|
||||
|| str_contains($lcm, 'secret')
|
||||
|| str_contains($lcm, 'token')
|
||||
) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access user secrets', User::class, $method);
|
||||
}
|
||||
}
|
||||
|
||||
29
src/Validator/Constraints/NoHtmlSpecialCharacters.php
Normal file
29
src/Validator/Constraints/NoHtmlSpecialCharacters.php
Normal 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_PROPERTY)]
|
||||
final class NoHtmlSpecialCharacters extends Constraint
|
||||
{
|
||||
public const SPECIAL_CHARACTERS_FOUND = 'kimai-html-character-001';
|
||||
|
||||
protected const ERROR_NAMES = [
|
||||
self::SPECIAL_CHARACTERS_FOUND => 'These characters are not allowed: {{ chars }}',
|
||||
];
|
||||
|
||||
public string $message = 'These characters are not allowed: {{ chars }}';
|
||||
|
||||
public function getTargets(): string
|
||||
{
|
||||
return self::PROPERTY_CONSTRAINT;
|
||||
}
|
||||
}
|
||||
@@ -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 App\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class NoHtmlSpecialCharactersValidator extends ConstraintValidator
|
||||
{
|
||||
public function validate(mixed $value, Constraint $constraint): void
|
||||
{
|
||||
if (!($constraint instanceof NoHtmlSpecialCharacters)) {
|
||||
throw new UnexpectedTypeException($constraint, NoHtmlSpecialCharacters::class);
|
||||
}
|
||||
|
||||
if (!\is_string($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_contains($value, '<')
|
||||
|| str_contains($value, '>')
|
||||
|| str_contains($value, '"')
|
||||
// there are many family names that use the ' (like O'Hara), so we cannot forbid them
|
||||
) {
|
||||
$this->context->buildViolation(NoHtmlSpecialCharacters::getErrorName(NoHtmlSpecialCharacters::SPECIAL_CHARACTERS_FOUND))
|
||||
->setTranslationDomain('validators')
|
||||
->setParameter('{{ chars }}', '< " >')
|
||||
->setCode(NoHtmlSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
namespace App\Voter;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\User;
|
||||
use App\Security\RolePermissionManager;
|
||||
@@ -33,6 +35,7 @@ final class ActivityVoter extends Voter
|
||||
'time',
|
||||
'delete',
|
||||
'permissions',
|
||||
'access',
|
||||
];
|
||||
|
||||
public function __construct(private readonly RolePermissionManager $permissionManager)
|
||||
@@ -54,6 +57,37 @@ final class ActivityVoter extends Voter
|
||||
return $subject instanceof Activity && $this->supportsAttribute($attribute);
|
||||
}
|
||||
|
||||
private function checkTeamPermission(Activity|Project|Customer $subject, User $user): bool
|
||||
{
|
||||
if ($user->canSeeAllData()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($subject instanceof Activity && $subject->getProject() !== null) {
|
||||
if (!$this->checkTeamPermission($subject->getProject(), $user)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($subject instanceof Project && $subject->getCustomer() !== null) {
|
||||
if (!$this->checkTeamPermission($subject->getCustomer(), $user)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($subject->getTeams()->count() === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($subject->getTeams() as $team) {
|
||||
if ($user->isInTeam($team)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
@@ -62,6 +96,12 @@ final class ActivityVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
// this is a virtual permission, only meant to be used by developer
|
||||
// it checks if access to the given activity is potentially possible
|
||||
if ($attribute === 'access') {
|
||||
return $this->checkTeamPermission($subject, $user);
|
||||
}
|
||||
|
||||
if ($this->permissionManager->hasRolePermission($user, $attribute . '_activity')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Voter;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\User;
|
||||
@@ -35,6 +36,7 @@ final class ProjectVoter extends Voter
|
||||
'permissions',
|
||||
'comments',
|
||||
'details',
|
||||
'access',
|
||||
];
|
||||
|
||||
public function __construct(private readonly RolePermissionManager $permissionManager)
|
||||
@@ -56,6 +58,31 @@ final class ProjectVoter extends Voter
|
||||
return $subject instanceof Project && $this->supportsAttribute($attribute);
|
||||
}
|
||||
|
||||
private function checkTeamPermission(Project|Customer $subject, User $user): bool
|
||||
{
|
||||
if ($user->canSeeAllData()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($subject instanceof Project && $subject->getCustomer() !== null) {
|
||||
if (!$this->checkTeamPermission($subject->getCustomer(), $user)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($subject->getTeams()->count() === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($subject->getTeams() as $team) {
|
||||
if ($user->isInTeam($team)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
@@ -64,6 +91,12 @@ final class ProjectVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
// this is a virtual permission, only meant to be used by developer
|
||||
// it checks if access to the given project is potentially possible
|
||||
if ($attribute === 'access') {
|
||||
return $this->checkTeamPermission($subject, $user);
|
||||
}
|
||||
|
||||
if ($this->permissionManager->hasRolePermission($user, $attribute . '_project')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ use App\WorkingTime\Model\Year;
|
||||
use App\WorkingTime\Model\YearPerUserSummary;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
final class WorkingTimeService
|
||||
/**
|
||||
* @final not final for mocking in tests
|
||||
*/
|
||||
class WorkingTimeService
|
||||
{
|
||||
private const LATEST_APPROVAL_PREF = '_latest_approval';
|
||||
private const LATEST_APPROVAL_FORMAT = 'Y-m-d H:i:s';
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
{% elseif type == 'warning' %}
|
||||
ALERT.warning('{{ message|trans({}, domain)|e('js') }}');
|
||||
{% elseif type == 'success' %}
|
||||
// success is expected so we do not need to show messages like "saved successfully"
|
||||
{# success is expected so we do not need to show messages like "saved successfully" #}
|
||||
{# THE NEXT LINE NEEDS TO BE KEPT FOR NOW; OTHERWISE THE FLASH MESSAGE WOULD FILL UP THE SESSION #}
|
||||
// ALERT.success('{{ message|trans({}, domain)|e('js') }}');
|
||||
{% else %}
|
||||
ALERT.info('{{ message|trans({}, domain)|e('js') }}');
|
||||
|
||||
@@ -163,13 +163,18 @@
|
||||
{% endembed %}
|
||||
|
||||
{% set opcache_warning = not opcache['enabled'] %}
|
||||
{% embed '@theme/embeds/collapsible.html.twig' with {open: opcache_warning, boxtype: (opcache_warning ? 'danger' : 'success')} %}
|
||||
{% set opcache_unknown = opcache['unknown'] %}
|
||||
{% embed '@theme/embeds/collapsible.html.twig' with {open: opcache_warning, boxtype: (opcache_warning ? (opcache_unknown ? 'warning' : 'danger') : 'success')} %}
|
||||
{% block title %}OPCache{% endblock %}
|
||||
{% block body_class %}p-0{% endblock %}
|
||||
{% block body %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% if opcache_warning %}
|
||||
{{ widgets.alert('danger', 'Could not detect an active OPCache. This will significantly impact the performance of Kimai.') }}
|
||||
{% if opcache['unknown'] %}
|
||||
{{ widgets.alert('warning', 'Could not detect OPCache status. Using shared hosting? Talk to your hoster to find out if it is activated.') }}
|
||||
{% else %}
|
||||
{{ widgets.alert('danger', 'Could not detect an active OPCache. This will significantly impact the performance of Kimai.') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if opcache['status'] is iterable %}
|
||||
<table class="table table-hover">
|
||||
|
||||
@@ -136,7 +136,6 @@
|
||||
FETCHER.fetch(url, options)
|
||||
.then(response => {
|
||||
response.json().then(json => {
|
||||
kimai.getPlugin('alert').success('action.update.success');
|
||||
linkElement.classList.toggle('permOn');
|
||||
linkElement.classList.toggle('permOff');
|
||||
toggleLabel(linkElement, !isActive);
|
||||
|
||||
@@ -43,6 +43,17 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
|
||||
};
|
||||
}
|
||||
|
||||
protected function getAuthenticatedUserId(string $role = User::ROLE_USER): int
|
||||
{
|
||||
return match ($role) {
|
||||
User::ROLE_SUPER_ADMIN => 6,
|
||||
User::ROLE_ADMIN => 5,
|
||||
User::ROLE_TEAMLEAD => 4,
|
||||
User::ROLE_USER => 2,
|
||||
default => throw new \Exception(\sprintf('Unknown role "%s"', $role)),
|
||||
};
|
||||
}
|
||||
|
||||
protected function createUrl(string $url): string
|
||||
{
|
||||
return '/' . ltrim($url, '/');
|
||||
@@ -215,7 +226,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
|
||||
|
||||
/**
|
||||
* @param Response $response
|
||||
* @param array<int, string>|array<string, mixed> $failedFields
|
||||
* @param array<int, string>|array<string, string> $failedFields
|
||||
* @param bool $extraFields test for the error "This form should not contain extra fields"
|
||||
* @param array<int, string>|array<string, mixed> $globalError
|
||||
*/
|
||||
@@ -793,6 +804,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
|
||||
self::assertIsString($subResult);
|
||||
} else {
|
||||
self::assertIsArray($subResult);
|
||||
self::assertIsString($value['type']);
|
||||
|
||||
if ($value['type'][0] === '@') {
|
||||
if (empty($result[$key])) {
|
||||
@@ -807,6 +819,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
self::assertIsString($value['type']);
|
||||
if ($value['type'][0] === '@') {
|
||||
if (empty($result[$key])) {
|
||||
break;
|
||||
|
||||
@@ -220,11 +220,24 @@ class ActivityControllerTest extends APIControllerBaseTestCase
|
||||
self::assertIsArray($result);
|
||||
self::assertNotEmpty($result);
|
||||
self::assertEquals(5, \count($result));
|
||||
|
||||
self::assertIsArray($result[0]);
|
||||
self::assertApiResponseTypeStructure('ActivityCollection', $result[0]);
|
||||
self::assertEquals($imports[0]->getId(), $result[4]['project']);
|
||||
self::assertEquals($imports[1]->getId(), $result[3]['project']);
|
||||
|
||||
self::assertIsArray($result[2]);
|
||||
self::assertApiResponseTypeStructure('ActivityCollection', $result[2]);
|
||||
self::assertArrayHasKey('project', $result[2]);
|
||||
self::assertEquals($imports[1]->getId(), $result[2]['project']);
|
||||
|
||||
self::assertIsArray($result[3]);
|
||||
self::assertApiResponseTypeStructure('ActivityCollection', $result[3]);
|
||||
self::assertArrayHasKey('project', $result[3]);
|
||||
self::assertEquals($imports[1]->getId(), $result[3]['project']);
|
||||
|
||||
self::assertIsArray($result[4]);
|
||||
self::assertApiResponseTypeStructure('ActivityCollection', $result[4]);
|
||||
self::assertArrayHasKey('project', $result[4]);
|
||||
self::assertEquals($imports[0]->getId(), $result[4]['project']);
|
||||
}
|
||||
|
||||
public function testGetEntityIsSecure(): void
|
||||
|
||||
@@ -32,6 +32,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
|
||||
self::assertStringContainsString('const config = {"basePath":"/api/doc","router":"memory","logo":"/touch-icon-192x192.png","hideInternal":true};', $content);
|
||||
$results = preg_match('/docs\.apiDescriptionDocument\ \=\ (.*)\.spec;/', $content, $matches);
|
||||
self::assertNotFalse($results);
|
||||
self::assertArrayHasKey(1, $matches);
|
||||
$swaggerJson = json_decode($matches[1], true);
|
||||
self::assertIsArray($swaggerJson);
|
||||
self::assertArrayHasKey('spec', $swaggerJson);
|
||||
|
||||
@@ -171,6 +171,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
|
||||
self::assertIsArray($result);
|
||||
self::assertApiResponseTypeStructure('TeamEntity', $result);
|
||||
self::assertNotEmpty($result['id']);
|
||||
self::assertIsArray($result['members']);
|
||||
self::assertCount(3, $result['members']);
|
||||
self::assertIsNumeric($updateId);
|
||||
|
||||
@@ -182,18 +183,23 @@ class TeamControllerTest extends APIControllerBaseTestCase
|
||||
self::assertIsArray($result['members']);
|
||||
self::assertCount(3, $result['members']);
|
||||
|
||||
self::assertIsArray($result['members'][0]);
|
||||
self::assertTrue($result['members'][0]['teamlead']);
|
||||
self::assertIsArray($result['members'][0]['user']);
|
||||
self::assertEquals(2, $result['members'][0]['user']['id']);
|
||||
self::assertEquals('john_user', $result['members'][0]['user']['username']);
|
||||
|
||||
self::assertIsArray($result['members'][1]);
|
||||
self::assertFalse($result['members'][1]['teamlead']);
|
||||
self::assertIsArray($result['members'][1]['user']);
|
||||
self::assertEquals(1, $result['members'][1]['user']['id']);
|
||||
self::assertEquals('clara_customer', $result['members'][1]['user']['username']);
|
||||
|
||||
self::assertIsArray($result['members'][2]);
|
||||
self::assertTrue($result['members'][2]['teamlead']);
|
||||
self::assertIsArray($result['members'][2]['user']);
|
||||
self::assertEquals(4, $result['members'][2]['user']['id']);
|
||||
self::assertEquals('tony_teamlead', $result['members'][2]['user']['username']);
|
||||
|
||||
self::assertTrue($result['members'][0]['teamlead']);
|
||||
self::assertEquals(2, $result['members'][0]['user']['id']);
|
||||
self::assertEquals('john_user', $result['members'][0]['user']['username']);
|
||||
}
|
||||
|
||||
public function testPatchActionWithValidationErrors(): void
|
||||
@@ -380,6 +386,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertIsArray($result);
|
||||
self::assertIsNumeric($result['id']);
|
||||
self::assertIsArray($result['customers']);
|
||||
self::assertCount(0, $result['customers']);
|
||||
|
||||
$this->request($client, '/api/teams/' . $result['id'] . '/customers/1', 'POST');
|
||||
@@ -422,6 +429,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertIsArray($result);
|
||||
self::assertIsNumeric($result['id']);
|
||||
self::assertIsArray($result['customers']);
|
||||
self::assertCount(1, $result['customers']);
|
||||
|
||||
// cannot add existing customer
|
||||
@@ -445,6 +453,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertIsArray($result);
|
||||
self::assertIsNumeric($result['id']);
|
||||
self::assertIsArray($result['customers']);
|
||||
self::assertCount(0, $result['customers']);
|
||||
|
||||
// add customer
|
||||
@@ -453,6 +462,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertIsArray($result);
|
||||
self::assertIsNumeric($result['id']);
|
||||
self::assertIsArray($result['customers']);
|
||||
self::assertCount(1, $result['customers']);
|
||||
|
||||
$this->request($client, '/api/teams/' . $result['id'] . '/customers/1', 'DELETE');
|
||||
@@ -515,6 +525,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertIsArray($result);
|
||||
self::assertIsNumeric($result['id']);
|
||||
self::assertIsArray($result['projects']);
|
||||
self::assertCount(0, $result['projects']);
|
||||
$this->request($client, '/api/teams/' . $result['id'] . '/projects/1', 'POST');
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -558,6 +569,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertIsArray($result);
|
||||
self::assertIsNumeric($result['id']);
|
||||
self::assertIsArray($result['projects']);
|
||||
self::assertCount(1, $result['projects']);
|
||||
|
||||
// cannot add existing project
|
||||
@@ -581,6 +593,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertIsArray($result);
|
||||
self::assertIsNumeric($result['id']);
|
||||
self::assertIsArray($result['projects']);
|
||||
self::assertCount(0, $result['projects']);
|
||||
|
||||
// add project
|
||||
@@ -590,6 +603,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
|
||||
self::assertIsArray($result);
|
||||
self::assertIsArray($result['projects']);
|
||||
self::assertCount(1, $result['projects']);
|
||||
self::assertIsInt($result['id']);
|
||||
|
||||
$this->request($client, '/api/teams/' . $result['id'] . '/projects/1', 'DELETE');
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -649,6 +663,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertIsArray($result);
|
||||
self::assertIsNumeric($result['id']);
|
||||
self::assertIsArray($result['activities']);
|
||||
self::assertCount(0, $result['activities']);
|
||||
$this->request($client, '/api/teams/' . $result['id'] . '/activities/1', 'POST');
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -690,6 +705,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertIsArray($result);
|
||||
self::assertIsNumeric($result['id']);
|
||||
self::assertIsArray($result['activities']);
|
||||
self::assertCount(1, $result['activities']);
|
||||
|
||||
// cannot add existing activity
|
||||
@@ -714,6 +730,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
|
||||
self::assertIsArray($result);
|
||||
self::assertIsArray($result['activities']);
|
||||
self::assertCount(0, $result['activities']);
|
||||
self::assertIsInt($result['id']);
|
||||
|
||||
// add activity
|
||||
$this->request($client, '/api/teams/' . $result['id'] . '/activities/1', 'POST');
|
||||
@@ -721,6 +738,7 @@ class TeamControllerTest extends APIControllerBaseTestCase
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertIsArray($result);
|
||||
self::assertIsNumeric($result['id']);
|
||||
self::assertIsArray($result['activities']);
|
||||
self::assertCount(1, $result['activities']);
|
||||
|
||||
$this->request($client, '/api/teams/' . $result['id'] . '/activities/1', 'DELETE');
|
||||
|
||||
@@ -35,13 +35,21 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
/**
|
||||
* @return Timesheet[]
|
||||
*/
|
||||
protected function importFixtureForUser(string $role, int $amount = 10): array
|
||||
protected function importFixtureForUser(User|string $user, int $amount = 10): array
|
||||
{
|
||||
$fixture = new TimesheetFixtures($this->getUserByRole($role), $amount);
|
||||
if (\is_string($user)) {
|
||||
$role = $user;
|
||||
$user = $this->getUserByRole($role);
|
||||
}
|
||||
|
||||
$start = DateTimeFactory::createByUser($user)->createDateTime('first day of this month');
|
||||
$start = $start->setTime(0, 0, 1);
|
||||
|
||||
$fixture = new TimesheetFixtures($user, $amount);
|
||||
$fixture->setFixedRate(true);
|
||||
$fixture->setHourlyRate(true);
|
||||
$fixture->setAllowEmptyDescriptions(false);
|
||||
$fixture->setStartDate((new \DateTime('first day of this month'))->setTime(0, 0, 1));
|
||||
$fixture->setStartDate($start);
|
||||
|
||||
return $this->importFixture($fixture);
|
||||
}
|
||||
@@ -212,18 +220,25 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
|
||||
public function testGetCollectionWithQuery(): void
|
||||
{
|
||||
$modifiedAfter = new \DateTime('-1 hour');
|
||||
$begin = new \DateTime('first day of this month');
|
||||
$begin->setTime(0, 0, 0);
|
||||
$end = new \DateTime('last day of this month');
|
||||
$end->setTime(23, 59, 59);
|
||||
$role = User::ROLE_USER;
|
||||
$client = $this->getClientForAuthenticatedUser($role);
|
||||
$user = $this->getUserByRole($role);
|
||||
$factory = DateTimeFactory::createByUser($user);
|
||||
|
||||
$begin = $factory->createDateTime('first day of this month');
|
||||
$begin = $begin->setTime(0, 0, 1);
|
||||
|
||||
$end = $factory->createDateTime('last day of this month');
|
||||
$end = $end->setTime(23, 59, 59);
|
||||
|
||||
$modifiedAfter = $factory->createDateTime('-20 hour');
|
||||
|
||||
$query = [
|
||||
'customers' => ['1'],
|
||||
'projects' => ['1'],
|
||||
'activities' => ['1'],
|
||||
'page' => 2,
|
||||
'size' => 4,
|
||||
'page' => '2',
|
||||
'size' => '4',
|
||||
'order' => 'DESC',
|
||||
'orderBy' => 'rate',
|
||||
'active' => 0,
|
||||
@@ -233,8 +248,7 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
'exported' => 0,
|
||||
];
|
||||
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->importFixtureForUser(User::ROLE_USER, 22);
|
||||
$this->importFixtureForUser($user, 22);
|
||||
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
@@ -250,11 +264,6 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
|
||||
public function testGetCollectionWithQueryFailsWith404OnOutOfRangedPage(): void
|
||||
{
|
||||
$begin = new \DateTime('first day of this month');
|
||||
$begin->setTime(0, 0, 0);
|
||||
$end = new \DateTime('last day of this month');
|
||||
$end->setTime(23, 59, 59);
|
||||
|
||||
$query = [
|
||||
'page' => 19,
|
||||
'size' => 50,
|
||||
@@ -268,10 +277,16 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
|
||||
public function testGetCollectionWithSingleParamsQuery(): void
|
||||
{
|
||||
$begin = new \DateTime('first day of this month');
|
||||
$begin->setTime(0, 0, 0);
|
||||
$end = new \DateTime('last day of this month');
|
||||
$end->setTime(23, 59, 59);
|
||||
$role = User::ROLE_USER;
|
||||
$client = $this->getClientForAuthenticatedUser($role);
|
||||
$user = $this->getUserByRole($role);
|
||||
$factory = DateTimeFactory::createByUser($user);
|
||||
|
||||
$begin = $factory->create('first day of this month');
|
||||
$begin = $begin->setTime(0, 0, 1);
|
||||
|
||||
$end = $factory->create('last day of this month');
|
||||
$end = $end->setTime(23, 59, 59);
|
||||
|
||||
$query = [
|
||||
'customer' => '1',
|
||||
@@ -287,8 +302,7 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
'exported' => 0,
|
||||
];
|
||||
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->importFixtureForUser(User::ROLE_USER);
|
||||
$this->importFixtureForUser($user);
|
||||
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
@@ -303,19 +317,23 @@ class TimesheetControllerTest extends APIControllerBaseTestCase
|
||||
|
||||
public function testExportedFilter(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->importFixtureForUser(User::ROLE_USER);
|
||||
$role = User::ROLE_USER;
|
||||
$client = $this->getClientForAuthenticatedUser($role);
|
||||
$user = $this->getUserByRole($role);
|
||||
$factory = DateTimeFactory::createByUser($user);
|
||||
$this->importFixtureForUser($user);
|
||||
|
||||
$fixture = new TimesheetFixtures($this->getUserByRole(User::ROLE_USER), 7);
|
||||
$fixture = new TimesheetFixtures($user, 7);
|
||||
$fixture->setExported(true);
|
||||
$fixture->setStartDate(new \DateTime('first day of this month'));
|
||||
$fixture->setAllowEmptyDescriptions(false);
|
||||
$this->importFixture($fixture);
|
||||
|
||||
$begin = new \DateTime('first day of this month');
|
||||
$begin->setTime(0, 0, 0);
|
||||
$end = new \DateTime('last day of this month');
|
||||
$end->setTime(23, 59, 59);
|
||||
$begin = $factory->create('first day of this month');
|
||||
$begin = $begin->setTime(0, 0, 1);
|
||||
|
||||
$end = $factory->create('last day of this month');
|
||||
$end = $end->setTime(23, 59, 59);
|
||||
|
||||
$query = [
|
||||
'page' => 1,
|
||||
|
||||
@@ -403,4 +403,50 @@ class UserControllerTest extends APIControllerBaseTestCase
|
||||
self::assertEquals('another,testing,bar', $user->getPreferenceValue('metatestmock'));
|
||||
self::assertEquals('another,testing,bar', $user->getPreferenceValue('metatestmock'));
|
||||
}
|
||||
|
||||
public function testUpdateUserPreferenceWithEnabledPreference(): void
|
||||
{
|
||||
$role = User::ROLE_SUPER_ADMIN;
|
||||
$id = $this->getAuthenticatedUserId($role);
|
||||
$client = $this->getClientForAuthenticatedUser($role);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
/** @var User $user */
|
||||
$user = $em->getRepository(User::class)->find($id);
|
||||
self::assertEquals(46, $user->getPreferenceValue('hourly_rate'));
|
||||
|
||||
$data = [
|
||||
[
|
||||
'name' => 'hourly_rate',
|
||||
'value' => 99
|
||||
]
|
||||
];
|
||||
$this->request($client, '/api/users/' . $id . '/preferences', 'PATCH', [], (string) json_encode($data));
|
||||
self::assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
/** @var User $user */
|
||||
$user = $em->getRepository(User::class)->find($id);
|
||||
self::assertEquals(99, $user->getPreferenceValue('hourly_rate'));
|
||||
}
|
||||
|
||||
public function testUpdateUserPreferenceWithDisabledPreference(): void
|
||||
{
|
||||
$role = User::ROLE_USER;
|
||||
$id = $this->getAuthenticatedUserId($role);
|
||||
$client = $this->getClientForAuthenticatedUser($role);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
/** @var User $user */
|
||||
$user = $em->getRepository(User::class)->find($id);
|
||||
self::assertEquals(82, $user->getPreferenceValue('hourly_rate'));
|
||||
|
||||
$data = [
|
||||
[
|
||||
'name' => 'hourly_rate',
|
||||
'value' => 99
|
||||
]
|
||||
];
|
||||
$this->request($client, '/api/users/' . $id . '/preferences', 'PATCH', [], (string) json_encode($data));
|
||||
self::assertApiResponseAccessDenied($client->getResponse());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,6 +303,7 @@ abstract class AbstractControllerBaseTestCase extends WebTestCase
|
||||
$crawler = $client->request('GET', $this->createUrl($url));
|
||||
$form = $crawler->filter($formSelector)->form();
|
||||
if ($disableValidation) {
|
||||
// needed for setting invalid values e.g. in dropdowns
|
||||
$form->disableValidation();
|
||||
}
|
||||
$result = $client->submit($form, $formData);
|
||||
@@ -313,7 +314,13 @@ abstract class AbstractControllerBaseTestCase extends WebTestCase
|
||||
self::assertEquals(
|
||||
\count($fieldNames),
|
||||
\count($validationErrors),
|
||||
\sprintf('Expected %s validation errors, found %s', \count($fieldNames), \count($validationErrors))
|
||||
\sprintf(
|
||||
'Expected %s validation errors, found %s. Expected: %s. Found: %s.',
|
||||
\count($fieldNames),
|
||||
\count($validationErrors),
|
||||
implode(',', $fieldNames),
|
||||
implode(',', $validationErrors->extract(['_text']))
|
||||
)
|
||||
);
|
||||
|
||||
foreach ($fieldNames as $name) {
|
||||
|
||||
@@ -238,11 +238,11 @@ class UserControllerTest extends AbstractControllerBaseTestCase
|
||||
// invalid fields: username, password_second, email, enabled
|
||||
[
|
||||
'user_create' => [
|
||||
'username' => '',
|
||||
'plainPassword' => ['first' => 'sdfsdf123'],
|
||||
'username' => '', // empty
|
||||
'plainPassword' => ['first' => 'sdfsdf123'], // missing second
|
||||
'alias' => 'ycvyxcb',
|
||||
'title' => '34rtwrtewrt',
|
||||
'email' => '',
|
||||
'email' => '', // empty email
|
||||
]
|
||||
],
|
||||
[
|
||||
@@ -251,15 +251,15 @@ class UserControllerTest extends AbstractControllerBaseTestCase
|
||||
'#user_create_email',
|
||||
]
|
||||
],
|
||||
// invalid fields: username, password, email, enabled
|
||||
// invalid fields: username, password, email
|
||||
[
|
||||
[
|
||||
'user_create' => [
|
||||
'username' => 'x',
|
||||
'plainPassword' => ['first' => 'sdfsdf123', 'second' => 'sdfxxxxxxx'],
|
||||
'alias' => 'ycvyxcb',
|
||||
'title' => '34rtwrtewrt',
|
||||
'email' => 'ydfbvsdfgs',
|
||||
'username' => 'x', // too short
|
||||
'plainPassword' => ['first' => 'sdfsdf123', 'second' => 'sdfxxxxxxx'], // do not match
|
||||
'alias' => 'Boo',
|
||||
'title' => 'Foo',
|
||||
'email' => 'ydfbvsdfgs', // invalid email
|
||||
]
|
||||
],
|
||||
[
|
||||
@@ -283,6 +283,22 @@ class UserControllerTest extends AbstractControllerBaseTestCase
|
||||
'#user_create_plainPassword_first',
|
||||
]
|
||||
],
|
||||
// invalid fields: alias (special chars), title (special chars, too long)
|
||||
[
|
||||
[
|
||||
'user_create' => [
|
||||
'username' => 'test1231',
|
||||
'plainPassword' => ['first' => 'A-Real-Password.1', 'second' => 'A-Real-Password.1'],
|
||||
'alias' => '""ycvyx<cb""',
|
||||
'title' => 'sdfgsdfgsd fgsdf "<34rtwrtewrt>" gsdfg sdfg sdfg sdfg sdfg sdfg sdfg sdfgsdfgsd fgsdf gsdfgsgsdfgsdfgsdfgsdfg sdfg sdfg sdfg sdfg sdfg sdfgsdfgsd fgsdf gsdfgsgsdfgsdfgsdfg', // special chars + too long
|
||||
'email' => 'ydfbvsdfgs@example.com',
|
||||
]
|
||||
],
|
||||
[
|
||||
'#user_create_alias',
|
||||
'#user_create_title',
|
||||
]
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ final class TimesheetFixtures implements TestFixture
|
||||
* @param string|\DateTime $date
|
||||
* @return TimesheetFixtures
|
||||
*/
|
||||
public function setStartDate($date): TimesheetFixtures
|
||||
public function setStartDate(string|\DateTime $date): TimesheetFixtures
|
||||
{
|
||||
if (!($date instanceof \DateTime)) {
|
||||
$date = new \DateTime($date);
|
||||
@@ -313,8 +313,8 @@ final class TimesheetFixtures implements TestFixture
|
||||
}
|
||||
|
||||
$start = clone $this->startDate;
|
||||
$start->modify("+ $i days");
|
||||
$start->modify('+ ' . rand(1, 172800) . ' seconds'); // up to 2 days
|
||||
$start = $start->modify("+ $i days");
|
||||
$start = $start->modify('+ ' . rand(1, 172800) . ' seconds'); // up to 2 days
|
||||
|
||||
return $start;
|
||||
}
|
||||
|
||||
@@ -412,7 +412,8 @@ class ConfigurationTest extends TestCase
|
||||
],
|
||||
'mapping' => [],
|
||||
'connection' => [
|
||||
'organization' => []
|
||||
'organization' => [],
|
||||
'cleanupLongRelayState' => false
|
||||
],
|
||||
'provider' => 'default',
|
||||
],
|
||||
|
||||
48
tests/Form/Type/WeekDaysTypeTest.php
Normal file
48
tests/Form/Type/WeekDaysTypeTest.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Form\Type;
|
||||
|
||||
use App\Form\Type\WeekDaysType;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\Test\TypeTestCase;
|
||||
|
||||
#[CoversClass(WeekDaysType::class)]
|
||||
class WeekDaysTypeTest extends TypeTestCase
|
||||
{
|
||||
public function testSubmitNull(): void
|
||||
{
|
||||
$data = ['week_days' => null];
|
||||
$model = new TypeTestModel(['week_days' => null]);
|
||||
|
||||
$form = $this->factory->createBuilder(FormType::class, $model);
|
||||
$form->add('week_days', WeekDaysType::class);
|
||||
$form = $form->getForm();
|
||||
$form->submit($data);
|
||||
|
||||
self::assertTrue($form->isSynchronized());
|
||||
self::assertEquals('', $model->offsetGet('week_days'));
|
||||
}
|
||||
|
||||
public function testSubmitValidData(): void
|
||||
{
|
||||
$data = ['week_days' => ['tuesday', 'friday', 'sunday']];
|
||||
$model = new TypeTestModel(['week_days' => null]);
|
||||
|
||||
$form = $this->factory->createBuilder(FormType::class, $model);
|
||||
$form->add('week_days', WeekDaysType::class);
|
||||
$form = $form->getForm();
|
||||
|
||||
$form->submit($data);
|
||||
|
||||
self::assertTrue($form->isSynchronized());
|
||||
self::assertEquals('tuesday,friday,sunday', $model->offsetGet('week_days'));
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ class DebugRendererTest extends TestCase
|
||||
}
|
||||
|
||||
#[DataProvider('getTestModel')]
|
||||
public function testRender(callable $invoiceModel, $expectedRate, $expectedRows, $expectedDescriptions, $expectedUser1, $expectedUser2, $expectedUser3, $hasProject, $metaFields = []): void
|
||||
public function testRender(callable $invoiceModel, string $expectedRate, int $expectedRows, int $expectedDescriptions, int $expectedUser1, int $expectedUser2, int $expectedUser3, bool $hasProject, array $metaFields = []): void
|
||||
{
|
||||
/** @var InvoiceModel $model */
|
||||
$model = $invoiceModel($this);
|
||||
@@ -67,10 +67,12 @@ class DebugRendererTest extends TestCase
|
||||
|
||||
$this->assertModelStructure($data['model'], \count($model->getQuery()->getProjects()), \count($model->getQuery()->getActivities()));
|
||||
$rows = $data['entries'];
|
||||
self::assertIsArray($rows);
|
||||
self::assertEquals($expectedRows, \count($rows));
|
||||
|
||||
$i = 0;
|
||||
foreach ($rows as $row) {
|
||||
self::assertIsArray($row);
|
||||
$meta = isset($metaFields[$i]) ? $metaFields[$i++] : [];
|
||||
$this->assertEntryStructure($row, $meta);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,11 @@ class SystemConfigurationFactory
|
||||
foreach ($iterator as $value) {
|
||||
$keys = [];
|
||||
foreach (range(0, $iterator->getDepth()) as $depth) {
|
||||
$keys[] = $iterator->getSubIterator($depth)->key();
|
||||
$key = $iterator->getSubIterator($depth)->key();
|
||||
if (!\is_scalar($key)) {
|
||||
continue;
|
||||
}
|
||||
$keys[] = $key;
|
||||
}
|
||||
$newConfig[implode('.', $keys)] = $value;
|
||||
}
|
||||
|
||||
@@ -22,12 +22,37 @@ use Symfony\Component\Security\Http\HttpUtils;
|
||||
#[CoversClass(SamlAuthenticationSuccessHandler::class)]
|
||||
class SamlAuthenticationSuccessHandlerTest extends TestCase
|
||||
{
|
||||
public function testRelayStateWithInvalidHost(): void
|
||||
{
|
||||
$handler = new SamlAuthenticationSuccessHandler(new HttpUtils($this->getUrlGenerator()));
|
||||
$response = $handler->onAuthenticationSuccess($this->getRequest('http://example.com/sso/login', 'https://localhost/relayed'), $this->getSamlToken());
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
$target = $response->getTargetUrl();
|
||||
$expected = 'http://example.com/';
|
||||
self::assertEquals($expected, $target);
|
||||
self::assertTrue($response->isRedirect($target));
|
||||
}
|
||||
|
||||
public function testRelayState(): void
|
||||
{
|
||||
$handler = new SamlAuthenticationSuccessHandler(new HttpUtils($this->getUrlGenerator()));
|
||||
$response = $handler->onAuthenticationSuccess($this->getRequest('/sso/login', 'http://localhost/relayed'), $this->getSamlToken());
|
||||
$response = $handler->onAuthenticationSuccess($this->getRequest('/sso/login', '/relayed'), $this->getSamlToken());
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertTrue($response->isRedirect('http://localhost/relayed'));
|
||||
$target = $response->getTargetUrl();
|
||||
$expected = 'http://localhost/relayed';
|
||||
self::assertEquals($expected, $target);
|
||||
self::assertTrue($response->isRedirect($target));
|
||||
}
|
||||
|
||||
public function testRelayStateWithFullUrls(): void
|
||||
{
|
||||
$handler = new SamlAuthenticationSuccessHandler(new HttpUtils($this->getUrlGenerator()));
|
||||
$response = $handler->onAuthenticationSuccess($this->getRequest('http://example.com/sso/login', 'http://example.com/relayed/123'), $this->getSamlToken());
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
$target = $response->getTargetUrl();
|
||||
$expected = 'http://example.com/relayed/123';
|
||||
self::assertEquals($expected, $target);
|
||||
self::assertTrue($response->isRedirect($target));
|
||||
}
|
||||
|
||||
public function testWithoutRelayState(): void
|
||||
@@ -71,7 +96,7 @@ class SamlAuthenticationSuccessHandlerTest extends TestCase
|
||||
$params['RelayState'] = $relayState;
|
||||
}
|
||||
|
||||
return Request::create($path, 'get', $params);
|
||||
return Request::create($path, 'post', $params);
|
||||
}
|
||||
|
||||
private function getSamlToken(): SamlToken
|
||||
|
||||
52
tests/Twig/ContractExtensionsTest.php
Normal file
52
tests/Twig/ContractExtensionsTest.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Twig;
|
||||
|
||||
use App\Twig\ContractExtensions;
|
||||
use App\WorkingTime\WorkingTimeService;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Twig\TwigTest;
|
||||
|
||||
#[CoversClass(ContractExtensions::class)]
|
||||
class ContractExtensionsTest extends TestCase
|
||||
{
|
||||
private function getSut(): ContractExtensions
|
||||
{
|
||||
$service = $this->createMock(WorkingTimeService::class);
|
||||
|
||||
$sut = new ContractExtensions($service);
|
||||
|
||||
return $sut;
|
||||
}
|
||||
|
||||
public function testDefinedMethods(): void
|
||||
{
|
||||
self::assertCount(1, $this->getSut()->getTests());
|
||||
self::assertCount(0, $this->getSut()->getFilters());
|
||||
self::assertCount(0, $this->getSut()->getFunctions());
|
||||
}
|
||||
|
||||
public function testGetTests(): void
|
||||
{
|
||||
$filters = [
|
||||
'work_day',
|
||||
];
|
||||
$i = 0;
|
||||
|
||||
$sut = $this->getSut();
|
||||
$twigFilters = $sut->getTests();
|
||||
|
||||
foreach ($twigFilters as $filter) {
|
||||
self::assertInstanceOf(TwigTest::class, $filter);
|
||||
self::assertEquals($filters[$i++], $filter->getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,8 @@ class StrictPolicyTestCase extends TestCase
|
||||
[new User(), 'getPlainPassword', 'Tried to access user secrets'],
|
||||
[new User(), 'getConfirmationToken', 'Tried to access user secrets'],
|
||||
[new User(), 'getTotpAuthenticationConfiguration', 'Tried to access user secrets'],
|
||||
[new User(), 'getPlainApiToken', 'Tried to access user secrets'],
|
||||
[new User(), 'getApiToken', 'Tried to access user secrets'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Validator\Constraints;
|
||||
|
||||
use App\Validator\Constraints\NoHtmlSpecialCharacters;
|
||||
use App\Validator\Constraints\NoHtmlSpecialCharactersValidator;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @extends ConstraintValidatorTestCase<NoHtmlSpecialCharactersValidator>
|
||||
*/
|
||||
#[CoversClass(NoHtmlSpecialCharacters::class)]
|
||||
#[CoversClass(NoHtmlSpecialCharactersValidator::class)]
|
||||
class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
protected function createValidator(): NoHtmlSpecialCharactersValidator
|
||||
{
|
||||
return new NoHtmlSpecialCharactersValidator();
|
||||
}
|
||||
|
||||
public function testConstraintIsInvalid(): void
|
||||
{
|
||||
$this->expectException(UnexpectedTypeException::class);
|
||||
|
||||
$this->validator->validate('foo', new NotBlank());
|
||||
}
|
||||
|
||||
public function testGetTargets(): void
|
||||
{
|
||||
$constraint = new NoHtmlSpecialCharacters();
|
||||
self::assertEquals('property', $constraint->getTargets());
|
||||
}
|
||||
|
||||
public static function getValidTestData(): array
|
||||
{
|
||||
return [
|
||||
[''],
|
||||
[null],
|
||||
['asdf-.,123!§$%&/()=?`4567\'890ß'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('getValidTestData')]
|
||||
public function testValidInput(string|null $data): void
|
||||
{
|
||||
$this->validator = $this->createValidator();
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
$this->validator->validate($data, new NoHtmlSpecialCharacters());
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public static function getInvalidTestData(): array
|
||||
{
|
||||
return [
|
||||
['Test" onclick="alert(1)"'],
|
||||
['Test><a href=#>Foo</a>'],
|
||||
['Test" broken string'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('getInvalidTestData')]
|
||||
public function testInvalidInput(string|null $data): void
|
||||
{
|
||||
$this->validator = $this->createValidator();
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
$this->validator->validate($data, new NoHtmlSpecialCharacters());
|
||||
|
||||
$this->buildViolation('These characters are not allowed: {{ chars }}')
|
||||
->setCode(NoHtmlSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
|
||||
->setParameter('{{ chars }}', '< " >')
|
||||
->assertRaised();
|
||||
}
|
||||
}
|
||||
@@ -107,11 +107,6 @@ parameters:
|
||||
count: 1
|
||||
path: API/APIControllerBaseTestCase.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, mixed given\\.$#"
|
||||
count: 1
|
||||
path: API/APIControllerBaseTestCase.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#"
|
||||
count: 3
|
||||
@@ -122,11 +117,6 @@ parameters:
|
||||
count: 1
|
||||
path: API/APIControllerBaseTestCase.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$string of function explode expects string, mixed given\\.$#"
|
||||
count: 1
|
||||
path: API/APIControllerBaseTestCase.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#"
|
||||
count: 1
|
||||
@@ -1461,46 +1451,6 @@ parameters:
|
||||
count: 1
|
||||
path: Invoice/Renderer/DebugRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedDescriptions with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Invoice/Renderer/DebugRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedRate with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Invoice/Renderer/DebugRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedRows with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Invoice/Renderer/DebugRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser1 with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Invoice/Renderer/DebugRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser2 with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Invoice/Renderer/DebugRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser3 with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Invoice/Renderer/DebugRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$hasProject with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Invoice/Renderer/DebugRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Invoice\\\\Renderer\\\\DebugRendererTest\\:\\:testRender\\(\\) has parameter \\$metaFields with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Invoice/Renderer/DebugRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#"
|
||||
count: 1
|
||||
|
||||
@@ -166,6 +166,10 @@
|
||||
<source>Duration cannot be negative.</source>
|
||||
<target state="translated">Die Dauer darf nicht negativ sein.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="EDEDQtZ" resname="These characters are not allowed: {{ chars }}">
|
||||
<source>These characters are not allowed: {{ chars }}</source>
|
||||
<target>Die folgenden Zeichen sind nicht erlaubt: {{ chars }}</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -166,6 +166,10 @@
|
||||
<source>Duration cannot be negative.</source>
|
||||
<target>Duration cannot be negative.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="EDEDQtZ" resname="These characters are not allowed: {{ chars }}">
|
||||
<source>These characters are not allowed: {{ chars }}</source>
|
||||
<target>The following characters are not allowed: {{ chars }}</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
20
yarn.lock
20
yarn.lock
@@ -1880,6 +1880,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/trusted-types@npm:^2.0.7":
|
||||
version: 2.0.7
|
||||
resolution: "@types/trusted-types@npm:2.0.7"
|
||||
checksum: 10c0/4c4855f10de7c6c135e0d32ce462419d8abbbc33713b31d294596c0cc34ae1fa6112a2f9da729c8f7a20707782b0d69da3b1f8df6645b0366d08825ca1522e0c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/yargs-parser@npm:*":
|
||||
version: 21.0.3
|
||||
resolution: "@types/yargs-parser@npm:21.0.3"
|
||||
@@ -2875,6 +2882,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dompurify@npm:^3":
|
||||
version: 3.3.3
|
||||
resolution: "dompurify@npm:3.3.3"
|
||||
dependencies:
|
||||
"@types/trusted-types": "npm:^2.0.7"
|
||||
dependenciesMeta:
|
||||
"@types/trusted-types":
|
||||
optional: true
|
||||
checksum: 10c0/097c14a21a3f6cb95beded9ecd255f7c3512c42767b048390c747b0fe35736f6a71e02320fc50a9ac2be645834b463e4760915d595d502a56452daf339d0ea9c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"domutils@npm:^2.5.2, domutils@npm:^2.8.0":
|
||||
version: 2.8.0
|
||||
resolution: "domutils@npm:2.8.0"
|
||||
@@ -3706,6 +3725,7 @@ __metadata:
|
||||
bootstrap: "npm:^5.3"
|
||||
chart.js: "npm:^4"
|
||||
core-js: "npm:^3"
|
||||
dompurify: "npm:^3"
|
||||
eslint: "npm:^9"
|
||||
globals: "npm:^15"
|
||||
gridstack: "npm:^7"
|
||||
|
||||
Reference in New Issue
Block a user