Compare commits

...

56 Commits

Author SHA1 Message Date
Kevin Papst
233c0e792e merge main 2024-09-22 20:26:03 +02:00
Kevin Papst
896e8b42c1 merge main 2024-09-22 20:11:37 +02:00
Kevin Papst
bf288bf115 Merge branch 'refs/heads/main' into api-pagination
# Conflicts:
#	src/API/BaseApiController.php
2024-09-22 17:34:24 +02:00
Kevin Papst
13649e146c Improve pagination support in API (#5073) 2024-09-22 17:26:02 +02:00
Kevin Papst
0f9b964b11 Merge remote-tracking branch 'refs/remotes/origin/api-enhancements' into api-pagination
# Conflicts:
#	src/API/ActivityController.php
#	src/API/CustomerController.php
#	src/API/ProjectController.php
#	src/API/TimesheetController.php
#	src/API/UserController.php
#	src/Repository/ProjectRepository.php
2024-09-22 16:34:24 +02:00
Kevin Papst
4076e1c3d3 Support for changeable work contract types (#5069) 2024-09-22 16:18:21 +02:00
Kevin Papst
8de54e1fa7 Added API endpoints to fetch invoices (#5070)
* added serializer attributes for API usage
* new setters to fill invoice data from fixture
* re-usable fixture helper methods
* added API endpoints to fetch invoices
* adjust tests
2024-09-22 16:17:45 +02:00
Weblate (bot)
1965c35b43 Translated using Weblate (#5045)
Co-authored-by: Doctorredits_here <alkaf.alkaf2018@tutamail.com>
Co-authored-by: Hien <hienly@yandex.com>
Co-authored-by: Jose Delvani <jsdelvani@users.noreply.hosted.weblate.org>
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Reza Almanda <rezaalmanda27@gmail.com>
Co-authored-by: Wellington Terumi Uemura <wellingtonuemura@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: albanobattistella <albano_battistella@hotmail.com>
Co-authored-by: enly sure <enlysure@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
2024-09-22 15:38:51 +02:00
Kevin Papst
3fd770047e allow Authorization header in CORS request 2024-09-20 23:26:29 +02:00
Kevin Papst
036b297a76 fix hydration issues in favorites (#5068) 2024-09-20 22:43:16 +02:00
Kevin Papst
537c120ad9 Release 2.22.0 (#5043) 2024-09-20 14:30:11 +02:00
Weblate (bot)
3e0dadc0c8 Translated using Weblate (#5037)
Co-authored-by: Jose Delvani <jsdelvani@users.noreply.hosted.weblate.org>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: hugoalh <hugoalh@users.noreply.hosted.weblate.org>
2024-09-01 14:58:47 +02:00
Kevin Papst
004ac5a6b9 re-phrase 2024-09-01 14:57:07 +02:00
Kevin Papst
3204dcb03e bump PHPSpreadsheet major version (#5040) 2024-08-31 23:42:41 +02:00
Kevin Papst
b1903ba183 Release 2.21.0 (#5014) 2024-08-28 17:16:37 +02:00
Weblate (bot)
75fa7a20b8 Translated using Weblate (#5012)
Co-authored-by: BeckeBauer <berko@gmx.de>
Co-authored-by: Biscuittttt <biscuitwithtea.tall310@passinbox.com>
Co-authored-by: Héctor Borrás Aleixandre <hborrasaleixandre@gmail.com>
Co-authored-by: John Titor <utkin2007@gmail.com>
Co-authored-by: Léane GRASSER <leane.grasser@proton.me>
Co-authored-by: Prefill add-on <noreply-addon-prefill@weblate.org>
Co-authored-by: Reza Almanda <rezaalmanda27@gmail.com>
Co-authored-by: Uporabnik Alen <obutimacek556@gmail.com>
Co-authored-by: XblateX <blate@users.noreply.hosted.weblate.org>
Co-authored-by: tygyh <jonis9898@hotmail.com>
2024-08-28 17:15:57 +02:00
Kevin Papst
a19426147a added event to send email to user (#5034) 2024-08-28 14:04:36 +02:00
Kevin Papst
9d933f62c0 refactored repositories and DB queries (#5026)
* removed unused teams from export order
* added new paginator for query instead of querybuilder
* added field hydrate enums
* hide PARTIAL deprecation
* never log deprecations in production
* replaced InvoiceLoader with native Doctrine feature
* prevent excessive permission queries
* support loading customers of team
* improved findByIds
* internalized API
* fix null string deprecations
2024-08-27 10:11:19 +02:00
Kevin Papst
9e3d243b4b use enabled_locales logic to handle locales (#5017)
* allow to skip locales (here: catalan)
* use enabled_locales and replace app_locales with kimai_locales
* added test to call all reports once for super_admin
2024-08-11 17:43:20 +02:00
Kevin Papst
62047c7c01 link security guidelines on the website 2024-08-05 18:44:38 +02:00
Kevin Papst
0bcd45babe clarify test exclusions 2024-08-05 13:23:53 +02:00
Kevin Papst
2a72815481 remove invalid label 2024-08-04 19:38:47 +02:00
Kevin Papst
ff6a3e8262 Release 2.20.0 (#4987) 2024-08-04 18:22:27 +02:00
Kevin Papst
0947b88d36 added OCI docker labels (#4996) 2024-08-04 16:43:46 +02:00
Kevin Papst
5de5b4fef5 BC: Cleanup Docker tags (#4995) 2024-08-04 16:37:55 +02:00
robjuz
cde856a9c1 BC: Docker support for encoded characters in DATABASE_URL (#5000) 2024-08-04 16:02:08 +02:00
Kevin Papst
6f8877a740 new export of project overview list (#5009) 2024-08-03 00:43:05 +02:00
Kevin Papst
e7d71bff64 remove comments from XLIFF files 2024-08-02 22:43:36 +02:00
Weblate (bot)
cca0657291 Update translation files (#5010) 2024-08-02 21:49:13 +02:00
Weblate (bot)
a4904d3335 Update translation files (#5007)
Co-authored-by: Héctor Borrás <hborras@cyberclick.net>
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Naoko Takano <naokomc@gmail.com>
Co-authored-by: Reza Almanda <rezaalmanda27@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: stuntworks <stuntworks@saber.games>
2024-08-02 21:43:29 +02:00
Kevin Papst
b9c1036b21 Revert Docker credentials urldecode (#4998) 2024-07-28 18:04:18 +02:00
Weblate (bot)
818c080d28 Translated using Weblate (#4990)
Co-authored-by: Ldm Public <ldmpub@gmail.com>
Co-authored-by: Wouter Meeusen <wouter.meeusen@outlook.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
2024-07-27 17:37:10 +02:00
Weblate (bot)
a5a6ae065b Translated using Weblate (#4984)
Co-authored-by: Adrien N <adriennathaniel1999@gmail.com>
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
2024-07-23 10:03:56 +02:00
Kevin Papst
8b06ca9d10 unify CSRF token usage in comment sections (#4986) 2024-07-23 08:33:02 +02:00
Kevin Papst
7e1025d61d fix financial year issue, fix test, improve DateTimeFactory (#4985) 2024-07-23 07:20:11 +02:00
Kevin Papst
8788311faf Release 2.19 (#4922) 2024-07-22 17:51:03 +02:00
Weblate (bot)
ce22520d7f Translated using Weblate (#4946)
Co-authored-by: Ahmad Dakhlallah <ahmad@linuxarabia.co>
Co-authored-by: Bent Haar <BentHaar@users.noreply.hosted.weblate.org>
Co-authored-by: Carlos Carreras <mytriponlinux@gmail.com>
Co-authored-by: David Bauer <dbs23.dbs23@gmail.com>
Co-authored-by: Jose Delvani <delvani.eletricista@gmail.com>
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Ldm Public <ldmpub@gmail.com>
Co-authored-by: Mads R. Andersen <madsdk_789@hotmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Mikkel Ricky <rimi@aarhus.dk>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Quiwy <github@quiwy.ninja>
Co-authored-by: Wellington Terumi Uemura <wellingtonuemura@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: geekom13 <geekom13@proton.me>
Co-authored-by: lilosti <lilosti@aarhus.dk>
Co-authored-by: ssantos <ssantos@web.de>
2024-07-20 17:36:02 +02:00
robjuz
f5d76cfd9a fix database URLs with special characters for docker (#4976) 2024-07-20 17:20:23 +02:00
Chinmay Purav
27f7f14539 fixed case of AS keyword in Dockerfile (#4943) 2024-06-29 21:45:14 +02:00
Kevin Papst
f7c32e633a bump docker action versions to fix GH deprecations 2024-06-17 11:05:49 +02:00
Kevin Papst
6d9055826f bump docker action versions to fix GH deprecations 2024-06-17 10:07:27 +02:00
Toby Batch
4627a9047e feat: stop multiarch images being lost on re tagging docker images (#4856) 2024-06-17 10:03:11 +02:00
Kevin Papst
987b46bf8f Release 2.18 (#4878) 2024-06-16 13:15:49 +02:00
Weblate (bot)
8792a1df09 Translated using Weblate (#4876)
Co-authored-by: Chan Young Park <designbycy@gmail.com>
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Oh Wow! <kasra.hashemi1999@gmail.com>
Co-authored-by: René Bischoff <rene.bischoff@gmail.com>
Co-authored-by: Wei-Cheng Yeh (IID) <iid@ccns.ncku.edu.tw>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: wkwkjddndjdusiq <wkwkjddndjdusiq@onionmail.com>
2024-06-16 13:02:27 +02:00
Kevin Papst
a8678892d6 yarn upgrade 2024-06-15 11:40:35 +02:00
Kevin Papst
72350b76c0 improve equality detection of same user 2024-06-15 11:40:28 +02:00
Kevin Papst
8578bbe033 improve project end handling 2024-06-13 22:31:33 +02:00
Kevin Papst
93ca983e0b invoice: do not use activity name as fallback for description (#4884)
* added replacement field description_safe
2024-06-03 19:01:40 +02:00
Kevin Papst
e29ef25581 link domains for donations 2024-05-22 14:19:11 +02:00
Kevin Papst
a617eba535 link about pages for donations 2024-05-22 14:15:42 +02:00
Kevin Papst
0c445d1bc4 Release 2.17 (#4836)
see https://github.com/kimai/kimai/pull/4836
2024-05-19 17:42:03 +02:00
Weblate (bot)
5f7114e008 Translated using Weblate (#4862)
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: SC <lalocas@protonmail.com>
Co-authored-by: Wei-Cheng Yeh (IID) <iid@ccns.ncku.edu.tw>
Co-authored-by: Wellington Terumi Uemura <wellingtonuemura@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
2024-05-19 17:03:48 +02:00
Habibhaidari1
e2c511cf46 Update RateCalculatorInterface (#4851) 2024-05-17 22:55:08 +02:00
Weblate (bot)
e042ef6bfa Translated using Weblate (#4850)
Co-authored-by: Barna László <barna.laszlo@lscomputer.hu>
Co-authored-by: Wei-Cheng Yeh (IID) <iid@ccns.ncku.edu.tw>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
2024-05-12 12:41:06 +02:00
Daniel Langemann
c059e51b3c Fix typo in markdown link (#4845) 2024-05-07 09:30:33 +02:00
Kevin Papst
3e3cdc5e69 global pagination support for API collection calls 2024-03-10 13:27:04 +01:00
561 changed files with 11725 additions and 6609 deletions

View File

@@ -2,7 +2,6 @@
ServerAdmin webmaster@localhost
DocumentRoot /opt/kimai/public
PassEnv DATABASE_PREFIX
PassEnv MAILER_FROM
PassEnv APP_ENV
PassEnv APP_SECRET

View File

@@ -167,8 +167,6 @@ HEALTHCHECK --interval=20s --timeout=10s --retries=3 \
FROM ${BASE}-base AS base
ARG TIMEZONE
LABEL maintainer="tobias@neontribe.co.uk"
ENV TIMEZONE=${TIMEZONE}
RUN ln -snf /usr/share/zoneinfo/${TIMEZONE} /etc/localtime && echo ${TIMEZONE} > /etc/timezone && \
# make composer home dir

View File

@@ -1,9 +1,9 @@
<?php
$DB_HOST = $argv[1];
$DB_BASE = $argv[2];
$DB_HOST = urldecode($argv[1]);
$DB_BASE = urldecode($argv[2]);
$DB_PORT = $argv[3];
$DB_USER = $argv[4];
$DB_PASS = $argv[5];
$DB_USER = urldecode($argv[4]);
$DB_PASS = urldecode($argv[5]);
echo "Testing DB:";
echo "*";

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +1,2 @@
github: [kevinpapst]
custom: ["https://www.kimai.org/donate/", "https://www.paypal.me/kevinpapst"]
custom: ["https://www.kimai.org/", "https://www.kimai.cloud/", "https://www.kevinpapst.de/"]

View File

@@ -21,8 +21,6 @@ version-resolver:
- 'translation'
default: patch
template: |
[Upgrade Kimai](https://www.kimai.org/documentation/updates.html) - [Install Kimai](https://www.kimai.org/documentation/installation.html) - [Docker](https://www.kimai.org/documentation/docker.html)
**Compatible with PHP 8.1 to 8.3**
$CHANGES

View File

@@ -19,16 +19,16 @@ jobs:
uses: actions/checkout@v4
- name: Install buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{secrets.DOCKERHUB_USERNAME}}
password: ${{secrets.DOCKERHUB_PASSWORD}}
- name: Build
uses: docker/build-push-action@v4
uses: docker/build-push-action@v5
with:
context: .docker
file: .docker/Dockerfile.kimai-base

View File

@@ -10,7 +10,6 @@ jobs:
build:
strategy:
matrix:
type: [ dev, prod ]
server: [ fpm, apache ]
runs-on: ubuntu-latest
@@ -20,13 +19,13 @@ jobs:
uses: actions/checkout@v4
- name: Install buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Install lastversion
run: sudo apt-get update -y; sudo apt-get install --no-install-recommends -y python3 python3-pip; pip install lastversion
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{secrets.DOCKERHUB_USERNAME}}
password: ${{secrets.DOCKERHUB_PASSWORD}}
@@ -36,18 +35,33 @@ jobs:
run: echo "kimai_version=$(lastversion https://github.com/kimai/kimai)" >> $GITHUB_ENV
- name: Build
uses: docker/build-push-action@v4
uses: docker/build-push-action@v5
with:
context: .
build-args: |
KIMAI=${{ env.kimai_version }}
TIMEZONE=Europe/London
BASE=${{ matrix.server }}
target: ${{ matrix.type }}
platforms: linux/amd64,linux/arm64 #,linux/arm/v8,linux/arm/v7,linux/arm/v6
target: prod
platforms: linux/amd64,linux/arm64
tags: |
kimai/kimai2:${{ matrix.server }}-${{ matrix.type }}
kimai/kimai2:${{ matrix.server }}-${{ env.kimai_version }}-${{ matrix.type }}
kimai/kimai2:${{ matrix.server }}
kimai/kimai2:${{ matrix.server }}-${{ env.kimai_version }}
push: true
- name: Build dev image
if: matrix.server == 'apache'
uses: docker/build-push-action@v5
with:
context: .
build-args: |
KIMAI=${{ env.kimai_version }}
TIMEZONE=Europe/London
BASE=apache
target: dev
platforms: linux/amd64,linux/arm64
tags: |
kimai/kimai2:dev
push: true
tag:
@@ -56,49 +70,18 @@ jobs:
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{secrets.DOCKERHUB_USERNAME}}
password: ${{secrets.DOCKERHUB_PASSWORD}}
- name: Install buildx
uses: docker/setup-buildx-action@v3
- name: Pull images
run: |
docker pull kimai/kimai2:fpm-prod
docker pull kimai/kimai2:fpm-dev
docker pull kimai/kimai2:apache-prod
docker pull kimai/kimai2:apache-dev
- name: Tag fpm
run: |
docker tag kimai/kimai2:fpm-prod kimai/kimai2:fpm
docker push kimai/kimai2:fpm
- name: Tag fpm latest
run: |
docker tag kimai/kimai2:fpm-prod kimai/kimai2:fpm-latest
docker push kimai/kimai2:fpm-latest
docker pull kimai/kimai2:fpm
- name: Tag latest
run: |
docker tag kimai/kimai2:fpm-prod kimai/kimai2:latest
docker push kimai/kimai2:latest
- name: Tag prod
run: |
docker tag kimai/kimai2:fpm-prod kimai/kimai2:prod
docker push kimai/kimai2:prod
- name: Tag apache
run: |
docker tag kimai/kimai2:apache-prod kimai/kimai2:apache
docker push kimai/kimai2:apache
- name: Tag apache latest
run: |
docker tag kimai/kimai2:apache-prod kimai/kimai2:apache-latest
docker push kimai/kimai2:apache-latest
- name: Tag dev
run: |
docker tag kimai/kimai2:apache-dev kimai/kimai2:dev
docker push kimai/kimai2:dev
docker buildx imagetools create -t kimai/kimai2:latest kimai/kimai2:fpm

View File

@@ -56,7 +56,7 @@ jobs:
run: composer install
- name: Validate Composer
run: composer validate --strict
run: composer validate --strict --no-check-all
- name: Warmup cache
run: APP_ENV=dev bin/console kimai:reload -n
@@ -93,7 +93,6 @@ jobs:
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
APP_ENV: test
MAILER_URL: null://localhost
TEST_WITH_BUNDLES: 1
- name: Full test-suite with coverage
if: matrix.php == '8.2'
@@ -102,7 +101,6 @@ jobs:
DATABASE_URL: mysql://root:kimai@127.0.0.1:${{ job.services.mysql.ports['3306'] }}/kimai?charset=utf8mb4&serverVersion=8.0.35
APP_ENV: dev
MAILER_URL: null://localhost
TEST_WITH_BUNDLES: 1
- name: Upload code coverage
if: matrix.php == '8.2'

View File

@@ -9,6 +9,7 @@ COMMENT;
$fixer = new PhpCsFixer\Config();
$fixer
->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect())
->setRiskyAllowed(true)
->setRules([
'encoding' => true,

View File

@@ -33,10 +33,10 @@ RUN rm -r tests
# FPM base
FROM kimai/kimai-base:fpm as fpm-base
FROM kimai/kimai-base:fpm AS fpm-base
# Apache base
FROM kimai/kimai-base:apache as apache-base
FROM kimai/kimai-base:apache AS apache-base
COPY .docker/000-default.conf /etc/apache2/sites-available/000-default.conf
###########################
@@ -47,7 +47,16 @@ FROM ${BASE}-base AS base
ARG KIMAI
ARG TIMEZONE
LABEL maintainer="tobias@neontribe.co.uk"
LABEL org.opencontainers.image.title="Kimai" \
org.opencontainers.image.description="Kimai is a time-tracking application." \
org.opencontainers.image.authors="Kimai Community" \
org.opencontainers.image.url="https://www.kimai.org/" \
org.opencontainers.image.documentation="https://www.kimai.org/documentation/" \
org.opencontainers.image.source="https://github.com/kimai/kimai" \
org.opencontainers.image.version="${KIMAI}" \
org.opencontainers.image.vendor="Kevin Papst" \
org.opencontainers.image.licenses="AGPL-3.0" \
org.opencontainers.image.base.name="docker.io/library/alpine"
ENV KIMAI=${KIMAI}
ENV TIMEZONE=${TIMEZONE}

View File

@@ -26,9 +26,7 @@ and so much more.
There are two [versions](https://www.kimai.org/documentation/versions.html) of Kimai existing:
- [Version 2](https://github.com/kimai/kimai) — the current stable release (PHP 8.1+)
- [Version 1](https://github.com/kimai/kimai/tree/1.x) — EOL since mid of 2023 (PHP 7.4)
Do **NOT** use Version 1, it won't get any more updates!
- [Version 1](https://github.com/kimai/kimai/tree/1.x) — do **NOT** use, EOL since mid of 2023 (PHP 7.4)
### Links
@@ -38,23 +36,20 @@ Do **NOT** use Version 1, it won't get any more updates!
### Requirements
- PHP 8.1.3 minimum
- PHP 8.1.3 minimum (support for PHP 8.2 and 8.3)
- MariaDB or MySQL
- A webserver and subdomain (subdirectory is not supported)
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `tokenizer`, `xml`, `xsl`, `zip`
Support for PHP 8.2 and 8.3.
## Installation
- [Native setup](https://www.kimai.org/documentation/installation.html) — with Git and Composer
- [Docker](https://hub.docker.com/r/kimai/kimai2) — containerized by @tobybatch
- Caddy with Docker-Compose at [Hetzner](https://www.kimai.org/documentation/hosting-hetzner-cloud.html) and [DigitalOcean](https://www.kimai.org/documentation/hosting-digital-ocean.html)
- [SSH setup](https://www.kimai.org/documentation/installation.html) with Git and Composer
- [Docker images](https://hub.docker.com/r/kimai/kimai2) with FPM only or incl. Apache
- [Synology](https://www.kimai.org/documentation/synology.html) user can host the Docker version
- [Developer setups](https://www.kimai.org/documentation/developers.html) if you want to create Kimai integrations
There are also documentations for:
- [developer setups](https://www.kimai.org/documentation/developers.html) — on your local machine
- [shared hostings](https://www.kimai.org/documentation/installation.html#shared-hosting) — the least favorable option
- [Synology](https://www.kimai.org/documentation/synology.html) — you could try to host the Docker version instead
- [1-click installer](https://www.kimai.org/documentation/installation.html#hosting-and-1-click-installations) — hosted environments
There are more documented ways for [on-premise hosting](https://www.kimai.org/documentation/chapter-on-premise.html).
And if you don't want to host Kimai, you can use [the Cloud version](https://www.kimai.cloud/) of it.
@@ -65,13 +60,12 @@ And if you don't want to host Kimai, you can use [the Cloud version](https://www
### Plugins
- [Plugin marketplace](https://www.kimai.org/store/) — find existing plugins here
- [Plugins](https://www.kimai.org/store/) — paid and free plugin marketplace
- [Developer documentation](https://www.kimai.org/documentation/developers.html) — how to create a plugin
## Roadmap and releases
You can see a rough development roadmap in the [Milestones](https://github.com/kimai/kimai/milestones) sections.
It is open for changes and input from the community, your [ideas and questions](https://github.com/kimai/kimai/issues) are welcome.
You can see a rough development [roadmap](https://github.com/orgs/kimai/projects/2), which is open for changes and input from the community, your [ideas](https://github.com/kimai/kimai/issues) are welcome.
Release versions will be created on a regular basis, every couple of weeks latest.
Every code change, whether it's a new feature or a bugfix, will be done on the `main` branch.
@@ -83,7 +77,7 @@ The best way to start is to [open a new issue](https://github.com/kimai/kimai/is
In case you want to contribute, but you wouldn't know how, here are some suggestions:
- Spread the word: Please [write a testimonial for our Wall of love]{https://love.kimai.org}, vote for Kimai on any software platform, you can toot or tweet about it, share it on LinkedIn, Reddit and any other social media platform!
- Spread the word: Please [write a testimonial for our Wall of love](https://love.kimai.org), vote for Kimai on any software platform, you can toot or tweet about it, share it on LinkedIn, Reddit and any other social media platform!
- Answer questions: You know the answer to another user's problem? Share your knowledge.
- Something can be done better? An essential feature is missing? Create a feature request.
- Report bugs makes Kimai better for everyone.

View File

@@ -1,27 +1,11 @@
# Security Policy
## Supported Versions
As announced in the [README](README.md) I only support the latest available release and `main` branch.
As announced in the [README](README.md) security fixes will only be added to the `main` branch.
| Version | Supported |
|----------------------|--------------------|
| main branch | :white_check_mark: |
| latest minor release | :white_check_mark: |
| older releases | :x: |
## Reporting a Vulnerability
Please read the [Bughunter](https://www.kimai.org/documentation/bughunter.html) documentation before posting.
You can report any security related vulnerability in the [advisory section at GitHub](https://github.com/kimai/kimai/security/advisories)
or via email to [support@kimai.org](mailto:support@kimai.org).
I will work as fast as I can to fix the problem and publish a bugfix release / security update.
Depending on the size of the required fixes, this might take a couple of hours or a couple of days.
You can expect that your message will be answered ASAP.
You will be mentioned in the release notes if your issue is valid.
I am grateful for any (discrete) disclosure of vulnerabilities!
Please note: if you are asking for money, I will not reply. I receive these scam messages every day...
You find all information in our [Bughunter documentation](https://www.kimai.org/documentation/bughunter.html).

View File

@@ -8,6 +8,18 @@ you can upgrade your Kimai installation to the latest stable release.
Check below if there are more version specific steps required, which need to be executed after the normal update process.
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
## [2.14.0](https://github.com/kimai/kimai/releases/tag/2.13.0)
**DEVELOPERS ONLY**
The API now uses pagination globally for all collection endpoints. This might break current implementations
All collections are limited to 50 entries by default (changes in Customer, Project, Activity, User).
The parameters to influence pagination, page size and page number are `size` (and `pageSize`) and `page`.
Read the [API pagination docs](https://www.kimai.org/documentation/api-pagination.html) for more infos.
## [2.0.30](https://github.com/kimai/kimai/releases/tag/2.0.30)
The `DATABASE_URL` in your environment settings (e.g. [.env](https://github.com/kimai/kimai/issues/4246), [docker-compose.yaml](https://github.com/tobybatch/kimai2/issues/531) or webserver config)

View File

@@ -42,6 +42,7 @@ import KimaiNotification from "./plugins/KimaiNotification";
import KimaiHotkeys from "./plugins/KimaiHotkeys";
import KimaiRemoteModal from "./plugins/KimaiRemoteModal";
import KimaiUser from "./plugins/KimaiUser";
import KimaiAutocompleteTags from "./forms/KimaiAutocompleteTags";
export default class KimaiLoader {
@@ -70,6 +71,7 @@ export default class KimaiLoader {
kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepicker="on"]'));
kimai.registerPlugin(new KimaiDatePicker('input[data-datepicker="on"]'));
kimai.registerPlugin(new KimaiAutocomplete());
kimai.registerPlugin(new KimaiAutocompleteTags());
kimai.registerPlugin(new KimaiTimesheetForm());
kimai.registerPlugin(new KimaiTeamForm());
kimai.registerPlugin(new KimaiCopyDataForm());
@@ -83,7 +85,7 @@ export default class KimaiLoader {
kimai.registerPlugin(new KimaiDatatable('section.content', 'table.dataTable'));
kimai.registerPlugin(new KimaiToolbar('form.searchform', 'toolbar-action'));
kimai.registerPlugin(new KimaiAlternativeLinks('.alternative-link'));
kimai.registerPlugin(new KimaiAjaxModalForm('.modal-ajax-form'));
kimai.registerPlugin(new KimaiAjaxModalForm('.modal-ajax-form', ['td.multiCheckbox', 'td.actions']));
kimai.registerPlugin(new KimaiRemoteModal());
kimai.registerPlugin(new KimaiActiveRecords());
kimai.registerPlugin(new KimaiAPILink('api-link'));

View File

@@ -6,13 +6,12 @@
*/
import TomSelect from 'tom-select';
import KimaiFormPlugin from "./KimaiFormPlugin";
import KimaiFormTomselectPlugin from "./KimaiFormTomselectPlugin";
/**
* Supporting auto-complete fields via API.
* Used for timesheet tagging in toolbar and edit dialogs.
*/
export default class KimaiAutocomplete extends KimaiFormPlugin {
export default class KimaiAutocomplete extends KimaiFormTomselectPlugin {
init()
{
@@ -28,11 +27,23 @@ export default class KimaiAutocomplete extends KimaiFormPlugin {
return true;
}
activateForm(form)
{
loadData(apiUrl, query, callback) {
/** @type {KimaiAPI} API */
const API = this.getContainer().getPlugin('api');
API.get(apiUrl, {'name': query}, (data) => {
let results = [];
for (let item of data) {
results.push({text: item.name, value: item.name});
}
callback(results);
}, () => {
callback();
});
}
activateForm(form)
{
[].slice.call(form.querySelectorAll(this.selector)).map((node) => {
const apiUrl = node.dataset['autocompleteUrl'];
let minChars = 3;
@@ -40,7 +51,7 @@ export default class KimaiAutocomplete extends KimaiFormPlugin {
minChars = parseInt(node.dataset['minimumCharacter']);
}
new TomSelect(node, {
let options = {
// see https://github.com/orchidjs/tom-select/issues/543#issuecomment-1664342257
onItemAdd: function(){
// remove remaining characters from input after selecting an item
@@ -58,36 +69,21 @@ export default class KimaiAutocomplete extends KimaiFormPlugin {
return query.length >= minChars;
},
load: (query, callback) => {
API.get(apiUrl, {'name': query}, (data) => {
const results = [].slice.call(data).map((result) => {
return {text: result, value: result};
});
callback(results);
}, () => {
callback();
});
this.loadData(apiUrl, query, callback);
},
render: {
// eslint-disable-next-line
not_loading: (data, escape) => {
// no default content
},
option_create: (data, escape) => {
const name = escape(data.input);
if (name.length < 3) {
return null;
}
const tpl = this.translate('select.search.create');
const tplReplaced = tpl.replace('%input%', '<strong>' + name + '</strong>')
return '<div class="create">' + tplReplaced + '</div>';
},
no_results: (data, escape) => {
const tpl = this.translate('select.search.notfound');
const tplReplaced = tpl.replace('%input%', '<strong>' + escape(data.input) + '</strong>')
return '<div class="no-results">' + tplReplaced + '</div>';
},
};
let render = {
// eslint-disable-next-line
not_loading: (data, escape) => {
// no default content
},
});
};
const rendererType = (node.dataset['renderer'] !== undefined) ? node.dataset['renderer'] : 'default';
options.render = {...render, ...this.getRenderer(rendererType)};
new TomSelect(node, options);
});
}

View File

@@ -0,0 +1,34 @@
/*
* 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.
*/
import KimaiAutocomplete from "./KimaiAutocomplete";
/**
* Used for timesheet tagging in toolbar and edit dialogs.
*/
export default class KimaiAutocompleteTags extends KimaiAutocomplete {
init()
{
this.selector = '[data-form-widget="tags"]';
}
loadData(apiUrl, query, callback) {
/** @type {KimaiAPI} API */
const API = this.getContainer().getPlugin('api');
API.get(apiUrl, {'name': query}, (data) => {
let results = [];
for (let item of data) {
results.push({text: item.name, value: item.name, color: item['color-safe']});
}
callback(results);
}, () => {
callback();
});
}
}

View File

@@ -10,9 +10,9 @@
*/
import TomSelect from 'tom-select';
import KimaiFormPlugin from "./KimaiFormPlugin";
import KimaiFormTomselectPlugin from "./KimaiFormTomselectPlugin";
export default class KimaiFormSelect extends KimaiFormPlugin {
export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
constructor(selector, apiSelects)
{
@@ -82,30 +82,20 @@ export default class KimaiFormSelect extends KimaiFormPlugin {
plugins: plugins,
// if there are more than X entries, the other ones are hidden and can only be found
// by typing some characters to trigger the internal option search
// see App\Form\Type\TagsType::MAX_AMOUNT_SELECT
maxOptions: 500,
sortField:[{field: '$order'}, {field: '$score'}],
};
let render = {
option_create: (data, escape) => {
const name = escape(data.input);
if (name.length < 3) {
return null;
}
const tpl = this.translate('select.search.create');
const tplReplaced = tpl.replace('%input%', '<strong>' + name + '</strong>');
return '<div class="create">' + tplReplaced + '</div>';
},
no_results: (data, escape) => {
const tpl = this.translate('select.search.notfound');
const tplReplaced = tpl.replace('%input%', '<strong>' + escape(data.input) + '</strong>');
return '<div class="no-results">' + tplReplaced + '</div>';
},
onOptionAdd: (value) => {
node.dispatchEvent(new CustomEvent('create', {detail: {'value': value}}));
},
};
const rendererType = (node.dataset['renderer'] !== undefined) ? node.dataset['renderer'] : 'default';
options.render = {...render, ...this.getRenderer(rendererType)};
if (node.dataset['create'] !== undefined) {
options = {...options, ...{
persist: true,
@@ -124,44 +114,6 @@ export default class KimaiFormSelect extends KimaiFormPlugin {
}};
}
if (node.dataset['renderer'] !== undefined && node.dataset['renderer'] === 'color') {
options.render = {...render, ...{
option: function(data, escape) {
let item = '<div class="list-group-item border-0 p-1 ps-2 text-nowrap">';
if (data.color !== undefined) {
item += '<span style="background-color:' + data.color + '" class="color-choice-item">&nbsp;</span>';
} else {
item += '<span class="color-choice-item">&nbsp;</span>';
}
item += escape(data.text) + '</div>';
return item;
},
item: function(data, escape) {
let item = '<div class="text-nowrap">';
if (data.color !== undefined) {
item += '<span style="background-color:' + data.color + '" class="color-choice-item">&nbsp;</span>';
} else {
item += '<span class="color-choice-item">&nbsp;</span>';
}
item += escape(data.text) + '</div>';
return item;
}
}};
} else {
options.render = {...render, ...{
// the empty entry would collapse and only show as a tiny 5px line if there is no content inside
option: function(data, escape) {
let text = data.text;
if (text === null || text.trim() === '') {
text = '&nbsp;';
} else {
text = escape(text);
}
return '<div>' + text + '</div>';
}
}};
}
const select = new TomSelect(node, options);
node.addEventListener('data-reloaded', (event) => {
select.clear(true);

View File

@@ -0,0 +1,80 @@
/*
* 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.
*/
/*!
* [KIMAI] KimaiFormPlugin: base class for all none ID plugin that handle forms
*/
import KimaiFormPlugin from './KimaiFormPlugin';
export default class KimaiFormTomselectPlugin extends KimaiFormPlugin {
/**
* @param {string} rendererType
* @return array
*/
getRenderer(rendererType)
{
// default renderer
let render = {
option_create: (data, escape) => {
const name = escape(data.input);
if (name.length < 3) {
return null;
}
const tpl = this.translate('select.search.create');
const tplReplaced = tpl.replace('%input%', '<strong>' + name + '</strong>')
return '<div class="create">' + tplReplaced + '</div>';
},
no_results: (data, escape) => {
const tpl = this.translate('select.search.notfound');
const tplReplaced = tpl.replace('%input%', '<strong>' + escape(data.input) + '</strong>')
return '<div class="no-results">' + tplReplaced + '</div>';
},
};
if (rendererType === 'color') {
render = {...render, ...{
option: function(data, escape) {
let item = '<div class="list-group-item border-0 p-1 ps-2 text-nowrap">';
// if no color is set, do NOT add an empty placeholder
if (data.color !== undefined) {
item += '<span style="background-color:' + data.color + '" class="color-choice-item">&nbsp;</span>';
}
item += escape(data.text) + '</div>';
return item;
},
item: function(data, escape) {
let item = '<div class="text-nowrap">';
// if no color is set, do NOT add an empty placeholder
if (data.color !== undefined) {
item += '<span style="background-color:' + data.color + '" class="color-choice-item">&nbsp;</span>';
}
item += escape(data.text) + '</div>';
return item;
}
}};
} else {
render = {...render, ...{
// the empty entry would collapse and only show as a tiny 5px line if there is no content inside
option: function(data, escape) {
let text = data.text;
if (text === null || text.trim() === '') {
text = '&nbsp;';
} else {
text = escape(text);
}
return '<div>' + text + '</div>';
}
}};
}
return render;
}
}

View File

@@ -17,9 +17,10 @@ import { Modal } from 'bootstrap';
export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
constructor(selector) {
constructor(selector, stopSelector) {
super();
this._selector = selector;
this._stopSelector = stopSelector;
}
getId()
@@ -63,7 +64,7 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
this.addClickHandler(this._selector, (href) => {
this.openUrlInModal(href);
});
}, this._stopSelector);
}
_getModal()

View File

@@ -24,7 +24,7 @@ export default class KimaiAlternativeLinks extends KimaiReducedClickHandler {
init() {
this.addClickHandler(this._selector, function(href) {
window.location = href;
});
}, []);
}
}

View File

@@ -15,10 +15,11 @@ export default class KimaiReducedClickHandler extends KimaiPlugin {
/**
* No _underscore naming for now, as it would be mangled otherwise
* @param selector
* @param callback
* @param {string} selector
* @param {callback} callback
* @param {array<string>} stopSelector
*/
addClickHandler(selector, callback) {
addClickHandler(selector, callback, stopSelector) {
document.body.addEventListener('click', (event) => {
// event.currentTarget is ALWAYS the body
@@ -38,6 +39,12 @@ export default class KimaiReducedClickHandler extends KimaiPlugin {
return;
}
for (let x of stopSelector) {
if (target.matches(x)) {
return;
}
}
target = target.parentNode;
}
@@ -54,6 +61,12 @@ export default class KimaiReducedClickHandler extends KimaiPlugin {
return;
}
for (let x of stopSelector) {
if (target.matches(x)) {
return;
}
}
event.preventDefault();
event.stopPropagation();

View File

@@ -4,7 +4,7 @@ fieldset legend.col-form-label {
}
/* See project assignment to teams */
fieldset.form-fieldset legend {
fieldset.form-fieldset > legend {
font-size: 1rem;
font-weight: bold;
}

View File

@@ -123,7 +123,7 @@ table.dataTable {
}
th.weekend,
td.weekend {
background-color: var(--tblr-bg-surface-secondary);
background-color: var(--tblr-gray-50);
}
/* order is important, "today” should overwrite "weekend" therefor later in the file */
th.today {

View File

@@ -1,8 +1,26 @@
@include color-mode(dark, true) {
$dark-font-opacity: 0.6;
/*
--tblr-body-bg: #1e222b;
--tblr-bg-surface-tertiary: #1e222b;
--tblr-bg-surface: #1e222b;
--tblr-bg-surface-secondary: #17202b;
//--tblr-muted: #d9dcdf;
*/
.table thead th {
color: inherit;
opacity: $dark-font-opacity;
}
table.dataTable {
th.today {
color: var(--tblr-muted);
}
thead .sorting:after {
opacity: $dark-font-opacity;
}
}
.overlay {
@@ -12,4 +30,16 @@
color: var(--tblr-white);
}
}
/* Fix tabs pills (e.g. user profile) */
.nav {
--tblr-nav-link-color: inherit;
opacity: $dark-font-opacity;
}
.datagrid-title {
color: inherit;
opacity: $dark-font-opacity;
}
.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) {
color: inherit;
}
}

View File

@@ -35,14 +35,14 @@
"friendsofsymfony/rest-bundle": "^3.0",
"gedmo/doctrine-extensions": "^3.6",
"jms/serializer-bundle": "^5.0",
"kevinpapst/tabler-bundle": "^1.2",
"kevinpapst/tabler-bundle": "1.4.2",
"league/csv": "^9.4",
"mpdf/mpdf": "^8.0",
"nelmio/api-doc-bundle": "^4.0",
"nelmio/cors-bundle": "^2.0",
"onelogin/php-saml": "^4.0",
"pagerfanta/pagerfanta": "^3.0",
"phpoffice/phpspreadsheet": "^1.16",
"phpoffice/phpspreadsheet": "^2.0",
"phpoffice/phpword": "^1.0",
"psr/container": "^2.0",
"psr/log": "^3.0",
@@ -171,7 +171,6 @@
"@tests-integration"
],
"linting": [
"composer validate --strict",
"bin/console lint:container",
"bin/console lint:yaml config --parse-tags",
"bin/console lint:twig templates --show-deprecations",

2543
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@ framework:
csrf_protection: true
annotations: false
handle_all_throwables: true
disallow_search_engine_index: true
set_content_language_from_locale: true
serializer:
enable_attributes: true

View File

@@ -117,10 +117,10 @@ kimai:
ROLE_SUPER_ADMIN: ['ACTIVITIES','PROJECTS','CUSTOMERS','INVOICE','INVOICE_ADMIN','TIMESHEET','TIMESHEET_OTHER','PROFILE','PROFILE_OTHER','USER','TEAMS','RATE','RATE_OTHER','EXPORT','BILLABLE','TAGS','LOCKDOWN','REPORTING', 'EVERYONE']
# mapping a "role name" to an array of "permission names"
roles:
ROLE_USER: ['view_team_member','time_team_project','create_tag','view_reporting']
ROLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member']
ROLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data','contract_other_profile']
ROLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','supervisor_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data','contract_other_profile']
ROLE_USER: ['view_team_member','time_team_project','create_tag','view_reporting','hours_own_profile']
ROLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member','hours_other_profile','hours_own_profile']
ROLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data','contract_other_profile','hours_other_profile','hours_own_profile']
ROLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','supervisor_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data','contract_other_profile','hours_other_profile','hours_own_profile']
# --------------------------------------------------------------------------------

View File

@@ -19,12 +19,6 @@ when@prod:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!deprecation"]
# deactivated, because currently there are too many deprecations cause by gedmo and doctrine
# deprecation:
# type: stream
# channels: ["deprecation"]
# path: "%kernel.logs_dir%/deprecations.log"
# formatter: monolog.formatter.deprecation
when@dev:
monolog:

View File

@@ -40,14 +40,16 @@ nelmio_api_doc:
- { alias: TeamCollection, type: App\Entity\Team, groups: [Default, Collection, Team] }
- { alias: TeamMember, type: App\Entity\TeamMember, groups: [Team_Entity] }
- { alias: TeamMembership, type: App\Entity\TeamMember, groups: [User_Entity] }
- { alias: Invoice, type: App\Entity\Invoice, groups: [Default, Entity, Invoice, Invoice_Entity] }
- { alias: InvoiceCollection, type: App\Entity\Invoice, groups: [Default, Collection, Invoice] }
areas:
path_patterns:
- ^/api(?!/doc)
documentation:
info:
title: Kimai - API Docs
title: Kimai - API
description: |
JSON API for the Kimai time-tracking software: [API documentation](https://www.kimai.org/documentation/rest-api.html), [Swagger definition file](doc.json)
JSON API for the Kimai time-tracking software. Read our [API documentation](https://www.kimai.org/documentation/rest-api.html) and download the [Open API definition](doc.json) to import into your API client.
version: '1.0'
components:
securitySchemes:

View File

@@ -17,9 +17,10 @@ nelmio_cors:
hosts: []
origin_regex: true
forced_allow_origin_value: ~
# allow_private_network: true
paths:
'^/api/':
allow_origin: ['*']
allow_headers: ['X-AUTH-USER', 'X-AUTH-TOKEN', 'Content-Type']
allow_headers: ['X-AUTH-USER', 'X-AUTH-TOKEN', 'Content-Type', 'Authorization']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
max_age: 3600

View File

@@ -72,8 +72,8 @@ security:
login_link:
check_route: link_login_check
signature_properties: ['id']
lifetime: 300
max_uses: 1
lifetime: 900
max_uses: 3
access_decision_manager:
# only grants access if there is no voter denying access
@@ -89,12 +89,12 @@ security:
access_control:
- { path: '^/auth/2fa', role: IS_AUTHENTICATED_2FA_IN_PROGRESS }
- { path: '^/auth', roles: PUBLIC_ACCESS }
- { path: '^/(%app_locales%)$', role: PUBLIC_ACCESS }
- { path: '^/(%app_locales%)/auth', role: PUBLIC_ACCESS }
- { path: '^/(%app_locales%)/login', role: PUBLIC_ACCESS }
- { path: '^/(%app_locales%)/register', role: PUBLIC_ACCESS }
- { path: '^/(%app_locales%)/resetting', role: PUBLIC_ACCESS }
- { path: '^/(%app_locales%)/', roles: ROLE_USER }
- { path: '^/{_locale}$', role: PUBLIC_ACCESS }
- { path: '^/{_locale}/auth', role: PUBLIC_ACCESS }
- { path: '^/{_locale}/login', role: PUBLIC_ACCESS }
- { path: '^/{_locale}/register', role: PUBLIC_ACCESS }
- { path: '^/{_locale}/resetting', role: PUBLIC_ACCESS }
- { path: '^/{_locale}/', roles: ROLE_USER }
- { path: '^/api', roles: IS_AUTHENTICATED }
when@test:
@@ -108,7 +108,3 @@ when@test:
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon
firewalls:
secured_area:
http_basic: ~

View File

@@ -29,7 +29,6 @@ tabler:
routes:
tabler_welcome: dashboard
tabler_login: login
tabler_logout: logout
tabler_login_check: security_check
tabler_registration: registration_register
tabler_registration_register: registration_register

View File

@@ -1,4 +1,6 @@
framework:
# https://symfony.com/doc/current/reference/configuration/framework.html#enabled-locales
enabled_locales: '%kimai_locales%'
default_locale: '%locale%'
translator:
default_path: '%kernel.project_dir%/translations'

View File

@@ -2,8 +2,6 @@ controllers:
resource: ../src/Controller/
type: attribute
prefix: /{_locale}
requirements:
_locale: '%app_locales%'
defaults:
_locale: '%locale%'
@@ -31,8 +29,6 @@ security:
resource: ../src/Controller/Security/
type: attribute
prefix: /{_locale}
requirements:
_locale: '%app_locales%'
defaults:
_locale: '%locale%'
@@ -49,8 +45,6 @@ home:
homeLocale:
path: /{_locale}
requirements:
_locale: '%app_locales%'
defaults:
_controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction
_locale: '%locale%'

View File

@@ -1,7 +1,5 @@
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
prefix: /{_locale}/_error
requirements:
_locale: '%app_locales%'
defaults:
_locale: '%locale%'

View File

@@ -1,7 +1,5 @@
app.test.active_entries:
path: /{_locale}/layou/active_entries
controller: App\Controller\LayoutController::activeEntries
requirements:
_locale: '%app_locales%'
defaults:
_locale: '%locale%'

View File

@@ -1,7 +1,7 @@
parameters:
locale: en
# can be regenerated with "bin/console kimai:reset:locales"
app_locales: ar|cs|da|de|de_CH|el|en|eo|es|eu|fa|fi|fo|fr|he|hr|hu|id|it|ja|ko|nb_NO|nl|pa|pl|pt|pt_BR|ro|ru|sk|sl|sv|tr|uk|vi|zh_CN|zh_Hant|zh_Hant_TW|cs_CZ|da_DK|da_GL|de_AT|de_BE|de_CH|de_DE|de_IT|de_LI|de_LU|el_CY|el_GR|en_AE|en_AG|en_AI|en_AS|en_AT|en_AU|en_BB|en_BE|en_BI|en_BM|en_BS|en_BW|en_BZ|en_CA|en_CC|en_CH|en_CK|en_CM|en_CX|en_CY|en_DE|en_DG|en_DK|en_DM|en_ER|en_FI|en_FJ|en_FK|en_FM|en_GB|en_GD|en_GG|en_GH|en_GI|en_GM|en_GU|en_GY|en_HK|en_ID|en_IE|en_IL|en_IM|en_IN|en_IO|en_JE|en_JM|en_KE|en_KI|en_KN|en_KY|en_LC|en_LR|en_LS|en_MG|en_MH|en_MO|en_MP|en_MS|en_MT|en_MU|en_MV|en_MW|en_MY|en_NA|en_NF|en_NG|en_NH|en_NL|en_NR|en_NU|en_NZ|en_PG|en_PH|en_PK|en_PN|en_PR|en_PW|en_RH|en_RW|en_SB|en_SC|en_SD|en_SE|en_SG|en_SH|en_SI|en_SL|en_SS|en_SX|en_SZ|en_TC|en_TK|en_TO|en_TT|en_TV|en_TZ|en_UG|en_UM|en_US|en_VC|en_VG|en_VI|en_VU|en_WS|en_ZA|en_ZM|en_ZW|es_AR|es_BO|es_BR|es_BZ|es_CL|es_CO|es_CR|es_CU|es_DO|es_EA|es_EC|es_ES|es_GQ|es_GT|es_HN|es_IC|es_MX|es_NI|es_PA|es_PE|es_PH|es_PR|es_PY|es_SV|es_US|es_UY|es_VE|eu_ES|fa_AF|fa_IR|fi_FI|fo_DK|fo_FO|fr_BE|fr_BF|fr_BI|fr_BJ|fr_BL|fr_CA|fr_CD|fr_CF|fr_CG|fr_CH|fr_CI|fr_CM|fr_DJ|fr_DZ|fr_FR|fr_GA|fr_GF|fr_GN|fr_GP|fr_GQ|fr_HT|fr_KM|fr_LU|fr_MA|fr_MC|fr_MF|fr_MG|fr_ML|fr_MQ|fr_MR|fr_MU|fr_NC|fr_NE|fr_PF|fr_PM|fr_RE|fr_RW|fr_SC|fr_SN|fr_SY|fr_TD|fr_TG|fr_TN|fr_VU|fr_WF|fr_YT|he_IL|hr_BA|hr_HR|hu_HU|it_CH|it_IT|it_SM|it_VA|ja_JP|ko_CN|ko_KP|ko_KR|nl_AW|nl_BE|nl_BQ|nl_CW|nl_NL|nl_SR|nl_SX|pl_PL|pt_AO|pt_BR|pt_CH|pt_CV|pt_GQ|pt_GW|pt_LU|pt_MO|pt_MZ|pt_PT|pt_ST|pt_TL|ro_MD|ro_RO|ru_BY|ru_KG|ru_KZ|ru_MD|ru_RU|ru_UA|sk_SK|sv_AX|sv_FI|sv_SE|tr_CY|tr_TR|uk_UA|vi_VN
# can be regenerated with: bin/console kimai:reset:locales
kimai_locales: ['ar', 'ca', 'cs', 'da', 'de', 'de_CH', 'el', 'en', 'eo', 'es', 'eu', 'fa', 'fi', 'fo', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'nb_NO', 'nl', 'pa', 'pl', 'pt', 'pt_BR', 'ro', 'ru', 'sk', 'sl', 'sv', 'tr', 'uk', 'vi', 'zh_CN', 'zh_Hant', 'zh_Hant_TW', 'ca_AD', 'ca_ES', 'ca_FR', 'ca_IT', 'cs_CZ', 'da_DK', 'da_GL', 'de_AT', 'de_BE', 'de_CH', 'de_DE', 'de_IT', 'de_LI', 'de_LU', 'el_CY', 'el_GR', 'en_AE', 'en_AG', 'en_AI', 'en_AS', 'en_AT', 'en_AU', 'en_BB', 'en_BE', 'en_BI', 'en_BM', 'en_BS', 'en_BW', 'en_BZ', 'en_CA', 'en_CC', 'en_CH', 'en_CK', 'en_CM', 'en_CX', 'en_CY', 'en_DE', 'en_DG', 'en_DK', 'en_DM', 'en_ER', 'en_FI', 'en_FJ', 'en_FK', 'en_FM', 'en_GB', 'en_GD', 'en_GG', 'en_GH', 'en_GI', 'en_GM', 'en_GU', 'en_GY', 'en_HK', 'en_ID', 'en_IE', 'en_IL', 'en_IM', 'en_IN', 'en_IO', 'en_JE', 'en_JM', 'en_KE', 'en_KI', 'en_KN', 'en_KY', 'en_LC', 'en_LR', 'en_LS', 'en_MG', 'en_MH', 'en_MO', 'en_MP', 'en_MS', 'en_MT', 'en_MU', 'en_MV', 'en_MW', 'en_MY', 'en_NA', 'en_NF', 'en_NG', 'en_NH', 'en_NL', 'en_NR', 'en_NU', 'en_NZ', 'en_PG', 'en_PH', 'en_PK', 'en_PN', 'en_PR', 'en_PW', 'en_RH', 'en_RW', 'en_SB', 'en_SC', 'en_SD', 'en_SE', 'en_SG', 'en_SH', 'en_SI', 'en_SL', 'en_SS', 'en_SX', 'en_SZ', 'en_TC', 'en_TK', 'en_TO', 'en_TT', 'en_TV', 'en_TZ', 'en_UG', 'en_UM', 'en_US', 'en_VC', 'en_VG', 'en_VI', 'en_VU', 'en_WS', 'en_ZA', 'en_ZM', 'en_ZW', 'es_AR', 'es_BO', 'es_BR', 'es_BZ', 'es_CL', 'es_CO', 'es_CR', 'es_CU', 'es_DO', 'es_EA', 'es_EC', 'es_ES', 'es_GQ', 'es_GT', 'es_HN', 'es_IC', 'es_MX', 'es_NI', 'es_PA', 'es_PE', 'es_PH', 'es_PR', 'es_PY', 'es_SV', 'es_US', 'es_UY', 'es_VE', 'eu_ES', 'fa_AF', 'fa_IR', 'fi_FI', 'fo_DK', 'fo_FO', 'fr_BE', 'fr_BF', 'fr_BI', 'fr_BJ', 'fr_BL', 'fr_CA', 'fr_CD', 'fr_CF', 'fr_CG', 'fr_CH', 'fr_CI', 'fr_CM', 'fr_DJ', 'fr_DZ', 'fr_FR', 'fr_GA', 'fr_GF', 'fr_GN', 'fr_GP', 'fr_GQ', 'fr_HT', 'fr_KM', 'fr_LU', 'fr_MA', 'fr_MC', 'fr_MF', 'fr_MG', 'fr_ML', 'fr_MQ', 'fr_MR', 'fr_MU', 'fr_NC', 'fr_NE', 'fr_PF', 'fr_PM', 'fr_RE', 'fr_RW', 'fr_SC', 'fr_SN', 'fr_SY', 'fr_TD', 'fr_TG', 'fr_TN', 'fr_VU', 'fr_WF', 'fr_YT', 'he_IL', 'hr_BA', 'hr_HR', 'hu_HU', 'it_CH', 'it_IT', 'it_SM', 'it_VA', 'ja_JP', 'ko_CN', 'ko_KP', 'ko_KR', 'nl_AW', 'nl_BE', 'nl_BQ', 'nl_CW', 'nl_NL', 'nl_SR', 'nl_SX', 'pl_PL', 'pt_AO', 'pt_BR', 'pt_CH', 'pt_CV', 'pt_GQ', 'pt_GW', 'pt_LU', 'pt_MO', 'pt_MZ', 'pt_PT', 'pt_ST', 'pt_TL', 'ro_MD', 'ro_RO', 'ru_BY', 'ru_KG', 'ru_KZ', 'ru_MD', 'ru_RU', 'ru_UA', 'sk_SK', 'sv_AX', 'sv_FI', 'sv_SE', 'tr_CY', 'tr_TR', 'uk_UA', 'vi_VN']
services:
# default configuration for services in *this* file
@@ -23,12 +23,14 @@ services:
- '../src/API/Model/'
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Event/'
- '../src/Form/Model/'
- '../src/Model/'
- '../src/Repository/Loader/'
- '../src/Repository/Paginator/'
- '../src/Repository/Query/'
- '../src/Repository/Result/'
- '../src/Event/'
- '../src/Model/'
- '../src/WorkingTime/Calculator/'
- '../src/Kernel.php'
- '../src/Constants.php'

View File

@@ -1,6 +1,6 @@
parameters:
locale: en
app_locales: ar|cs|de|de_CH|da|en|eo|es|eu|fr|he|hu|it|ja|ko|nl|pl|pt_BR|ro|ru|sk|sv|tr|vi|zh_CN
kimai_locales: ['ar', 'cs', 'de', 'de_CH', 'da', 'en', 'eo', 'es', 'eu', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'nl', 'pl', 'pt_BR', 'ro', 'ru', 'sk', 'sv', 'tr', 'vi', 'zh_CN']
services:
_defaults:
@@ -23,7 +23,3 @@ services:
App\User\UserService:
public: true
arguments: ['@App\Repository\UserRepository', '@event_dispatcher', '@validator', '@App\Configuration\SystemConfiguration', '@security.user_password_hasher']
App\Plugin\PluginManager:
public: true
arguments: [!tagged kimai.plugin]

View File

@@ -24,11 +24,15 @@ final class <className> extends AbstractMigration
public function up(Schema $schema): void
{
$this->abortIf(true, 'Migration was auto-generated, adapt to your needs before running it.');
<up>
}
public function down(Schema $schema): void
{
$this->abortIf(true, 'Migration was auto-generated, adapt to your needs before running it.');
<down>
}<override>
}

View File

@@ -0,0 +1,61 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 2.22
*/
final class Version20240920105524 extends AbstractMigration
{
public function getDescription(): string
{
return 'Updates the user preferences for configurable work contract type';
}
public function up(Schema $schema): void
{
$ids = $this->connection->fetchFirstColumn("
SELECT DISTINCT user_id
FROM kimai2_user_preferences AS kp
WHERE kp.value > 0
AND kp.name IN ('work_monday', 'work_tuesday', 'work_wednesday', 'work_thursday', 'work_friday', 'work_saturday', 'work_sunday')
AND NOT EXISTS (
SELECT 1
FROM kimai2_user_preferences AS kp2
WHERE kp2.user_id = kp.user_id
AND kp2.name = 'work_contract_type'
);");
foreach ($ids as $id) {
$this->addSql('INSERT INTO kimai2_user_preferences (`user_id`, `name`, `value`) VALUES (:id, :name, :value)', [
'id' => $id,
'name' => 'work_contract_type',
'value' => 'day',
]);
}
if (\count($ids) === 0) {
$this->preventEmptyMigrationWarning();
}
}
public function down(Schema $schema): void
{
$this->addSql("DELETE FROM kimai2_user_preferences WHERE `name` = 'work_contract_type'");
}
public function isTransactional(): bool
{
return true;
}
}

View File

@@ -34,6 +34,7 @@ parameters:
tmpDir: %rootDir%/../../../var/cache/phpstan
excludePaths:
- %rootDir%/../../../src/Ldap/LdapDriver.php
- %rootDir%/../../../src/Event/PageActionsEvent.php
treatPhpDocTypesAsCertain: false
inferPrivatePropertyTypeFromConstructor: true
doctrine:
@@ -153,16 +154,6 @@ parameters:
count: 1
path: src/API/UserController.php
-
message: "#^Method App\\\\Activity\\\\ActivityStatisticService\\:\\:createStatisticQueryBuilder\\(\\) has parameter \\$activities with no value type specified in iterable type array\\.$#"
count: 1
path: src/Activity/ActivityStatisticService.php
-
message: "#^Method App\\\\Activity\\\\ActivityStatisticService\\:\\:getBudgetStatistic\\(\\) should return array\\<int, App\\\\Model\\\\ActivityStatistic\\> but returns array\\<int\\|string, App\\\\Model\\\\ActivityStatistic\\>\\.$#"
count: 1
path: src/Activity/ActivityStatisticService.php
-
message: "#^Parameter \\#2 \\$statistic of class App\\\\Event\\\\ActivityStatisticEvent constructor expects App\\\\Model\\\\ActivityStatistic, App\\\\Model\\\\ActivityStatistic\\|null given\\.$#"
count: 1
@@ -278,21 +269,11 @@ parameters:
count: 1
path: src/Command/DemoteUserCommand.php
-
message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
count: 1
path: src/Command/ExportCreateCommand.php
-
message: "#^Binary operation \"\\.\" between non\\-falsy\\-string and non\\-empty\\-list\\<string\\>\\|string results in an error\\.$#"
count: 2
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$customerIDs of method App\\\\Repository\\\\CustomerRepository\\:\\:findByIds\\(\\) expects array\\<int\\>, mixed given\\.$#"
count: 1
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$datetime of method App\\\\Timesheet\\\\DateTimeFactory\\:\\:createDateTime\\(\\) expects string, mixed given\\.$#"
count: 2
@@ -313,11 +294,6 @@ parameters:
count: 1
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$projectIds of method App\\\\Repository\\\\ProjectRepository\\:\\:findByIds\\(\\) expects array\\<int\\>, mixed given\\.$#"
count: 1
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$string of function rtrim expects string, mixed given\\.$#"
count: 1
@@ -328,31 +304,11 @@ parameters:
count: 2
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$teamIds of method App\\\\Repository\\\\TeamRepository\\:\\:findByIds\\(\\) expects array\\<int\\>, mixed given\\.$#"
count: 1
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$timezone of class DateTimeZone constructor expects string, mixed given\\.$#"
count: 1
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$userIds of method App\\\\Repository\\\\UserRepository\\:\\:findByIds\\(\\) expects array\\<int\\>, mixed given\\.$#"
count: 1
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#"
count: 5
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\.\\.\\.\\$addresses of method Symfony\\\\Component\\\\Mime\\\\Email\\:\\:addTo\\(\\) expects string\\|Symfony\\\\Component\\\\Mime\\\\Address, mixed given\\.$#"
count: 1
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#2 \\$content of method Symfony\\\\Component\\\\Filesystem\\\\Filesystem\\:\\:dumpFile\\(\\) expects resource\\|string, string\\|false given\\.$#"
count: 1
@@ -793,11 +749,6 @@ parameters:
count: 1
path: src/Controller/ActivityController.php
-
message: "#^Parameter \\#2 \\$entries of method App\\\\Export\\\\Spreadsheet\\\\EntityWithMetaFieldsExporter\\:\\:export\\(\\) expects array, iterable\\<App\\\\Entity\\\\Activity\\> given\\.$#"
count: 1
path: src/Controller/ActivityController.php
-
message: "#^Parameter \\#2 \\$replace of method App\\\\Repository\\\\ActivityRepository\\:\\:deleteActivity\\(\\) expects App\\\\Entity\\\\Activity\\|null, mixed given\\.$#"
count: 1
@@ -843,11 +794,6 @@ parameters:
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Parameter \\#2 \\$entries of method App\\\\Export\\\\Spreadsheet\\\\EntityWithMetaFieldsExporter\\:\\:export\\(\\) expects array, iterable\\<App\\\\Entity\\\\Customer\\> given\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Parameter \\#2 \\$replace of method App\\\\Repository\\\\CustomerRepository\\:\\:deleteCustomer\\(\\) expects App\\\\Entity\\\\Customer\\|null, mixed given\\.$#"
count: 1
@@ -983,11 +929,6 @@ parameters:
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Parameter \\#2 \\$entries of method App\\\\Export\\\\Spreadsheet\\\\EntityWithMetaFieldsExporter\\:\\:export\\(\\) expects array, iterable\\<App\\\\Entity\\\\Project\\> given\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Parameter \\#2 \\$replace of method App\\\\Repository\\\\ProjectRepository\\:\\:deleteProject\\(\\) expects App\\\\Entity\\\\Project\\|null, mixed given\\.$#"
count: 1
@@ -1143,11 +1084,6 @@ parameters:
count: 1
path: src/Controller/TimesheetAbstractController.php
-
message: "#^Parameter \\#2 \\$page of method App\\\\Controller\\\\TimesheetAbstractController\\:\\:getEditForm\\(\\) expects int, mixed given\\.$#"
count: 1
path: src/Controller/TimesheetAbstractController.php
-
message: "#^PHPDoc tag @var for variable \\$teams contains generic class Doctrine\\\\Common\\\\Collections\\\\ArrayCollection but does not specify its types\\: TKey, T$#"
count: 1
@@ -1158,11 +1094,6 @@ parameters:
count: 1
path: src/Controller/TimesheetTeamController.php
-
message: "#^Parameter \\#2 \\$plainPassword of method Symfony\\\\Component\\\\PasswordHasher\\\\Hasher\\\\UserPasswordHasherInterface\\:\\:hashPassword\\(\\) expects string, string\\|null given\\.$#"
count: 1
path: src/Controller/UserController.php
-
message: "#^Parameter \\#2 \\$replace of method App\\\\User\\\\UserService\\:\\:deleteUser\\(\\) expects App\\\\Entity\\\\User\\|null, mixed given\\.$#"
count: 1
@@ -1428,11 +1359,6 @@ parameters:
count: 1
path: src/Entity/Tag.php
-
message: "#^Property App\\\\Entity\\\\Tag\\:\\:\\$timesheets with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
count: 1
path: src/Entity/Tag.php
-
message: "#^Cannot call method removeMembership\\(\\) on App\\\\Entity\\\\User\\|null\\.$#"
count: 1
@@ -1563,11 +1489,6 @@ parameters:
count: 1
path: src/Entity/User.php
-
message: "#^Method App\\\\Entity\\\\User\\:\\:getTeams\\(\\) should return iterable\\<App\\\\Entity\\\\Team\\> but returns array\\<int, App\\\\Entity\\\\Team\\|null\\>\\.$#"
count: 1
path: src/Entity/User.php
-
message: "#^Method App\\\\Entity\\\\User\\:\\:getTimezone\\(\\) should return string but returns bool\\|float\\|int\\|string\\|null\\.$#"
count: 1
@@ -1678,26 +1599,6 @@ parameters:
count: 1
path: src/Event/CalendarConfigurationEvent.php
-
message: "#^Method App\\\\Event\\\\PageActionsEvent\\:\\:addAction\\(\\) has parameter \\$action with no value type specified in iterable type array\\.$#"
count: 1
path: src/Event/PageActionsEvent.php
-
message: "#^Method App\\\\Event\\\\PageActionsEvent\\:\\:addActionToSubmenu\\(\\) has parameter \\$action with no value type specified in iterable type array\\.$#"
count: 1
path: src/Event/PageActionsEvent.php
-
message: "#^Method App\\\\Event\\\\PageActionsEvent\\:\\:getActions\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Event/PageActionsEvent.php
-
message: "#^Method App\\\\Event\\\\PageActionsEvent\\:\\:replaceAction\\(\\) has parameter \\$action with no value type specified in iterable type array\\.$#"
count: 1
path: src/Event/PageActionsEvent.php
-
message: "#^Method App\\\\Event\\\\PermissionsEvent\\:\\:getPermissions\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -2163,21 +2064,6 @@ parameters:
count: 1
path: src/Form/CustomerEditForm.php
-
message: "#^Class App\\\\Form\\\\DataTransformer\\\\DurationStringToSecondsTransformer implements generic interface Symfony\\\\Component\\\\Form\\\\DataTransformerInterface but does not specify its types\\: T, R$#"
count: 1
path: src/Form/DataTransformer/DurationStringToSecondsTransformer.php
-
message: "#^Parameter \\#1 \\$formatToInt \\(string\\|null\\) of method App\\\\Form\\\\DataTransformer\\\\DurationStringToSecondsTransformer\\:\\:reverseTransform\\(\\) should be contravariant with parameter \\$value \\(mixed\\) of method Symfony\\\\Component\\\\Form\\\\DataTransformerInterface\\<mixed,mixed\\>\\:\\:reverseTransform\\(\\)$#"
count: 1
path: src/Form/DataTransformer/DurationStringToSecondsTransformer.php
-
message: "#^Parameter \\#1 \\$intToFormat \\(int\\) of method App\\\\Form\\\\DataTransformer\\\\DurationStringToSecondsTransformer\\:\\:transform\\(\\) should be contravariant with parameter \\$value \\(mixed\\) of method Symfony\\\\Component\\\\Form\\\\DataTransformerInterface\\<mixed,mixed\\>\\:\\:transform\\(\\)$#"
count: 1
path: src/Form/DataTransformer/DurationStringToSecondsTransformer.php
-
message: "#^Class App\\\\Form\\\\DataTransformer\\\\SearchTermTransformer implements generic interface Symfony\\\\Component\\\\Form\\\\DataTransformerInterface but does not specify its types\\: T, R$#"
count: 1
@@ -2198,36 +2084,11 @@ parameters:
count: 1
path: src/Form/DataTransformer/StringToArrayTransformer.php
-
message: "#^Method App\\\\Form\\\\Extension\\\\DocumentationLinkExtension\\:\\:buildView\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1
path: src/Form/Extension/DocumentationLinkExtension.php
-
message: "#^Method App\\\\Form\\\\Extension\\\\EnhancedChoiceTypeExtension\\:\\:buildView\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1
path: src/Form/Extension/EnhancedChoiceTypeExtension.php
-
message: "#^Method App\\\\Form\\\\Extension\\\\IconExtension\\:\\:buildView\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1
path: src/Form/Extension/IconExtension.php
-
message: "#^Cannot call method getName\\(\\) on Symfony\\\\Component\\\\Form\\\\FormInterface\\|null\\.$#"
count: 1
path: src/Form/Extension/SelectWithApiDataExtension.php
-
message: "#^Cannot call method getParent\\(\\) on Symfony\\\\Component\\\\Form\\\\FormInterface\\|null\\.$#"
count: 1
path: src/Form/Extension/SelectWithApiDataExtension.php
-
message: "#^Method App\\\\Form\\\\Extension\\\\SelectWithApiDataExtension\\:\\:buildView\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
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
@@ -2238,16 +2099,6 @@ parameters:
count: 2
path: src/Form/Helper/ActivityHelper.php
-
message: "#^Parameter \\#1 \\$string of function substr expects string, string\\|null given\\.$#"
count: 1
path: src/Form/Helper/ActivityHelper.php
-
message: "#^Parameter \\#2 \\$replace of function str_replace expects array\\|string, string\\|null given\\.$#"
count: 1
path: src/Form/Helper/ActivityHelper.php
-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, bool\\|float\\|int\\|string given\\.$#"
count: 1
@@ -2263,16 +2114,6 @@ parameters:
count: 2
path: src/Form/Helper/CustomerHelper.php
-
message: "#^Parameter \\#1 \\$string of function substr expects string, string\\|null given\\.$#"
count: 1
path: src/Form/Helper/CustomerHelper.php
-
message: "#^Parameter \\#2 \\$replace of function str_replace expects array\\|string, string\\|null given\\.$#"
count: 1
path: src/Form/Helper/CustomerHelper.php
-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, bool\\|float\\|int\\|string given\\.$#"
count: 1
@@ -2288,16 +2129,6 @@ parameters:
count: 2
path: src/Form/Helper/ProjectHelper.php
-
message: "#^Parameter \\#1 \\$string of function substr expects string, string\\|null given\\.$#"
count: 1
path: src/Form/Helper/ProjectHelper.php
-
message: "#^Parameter \\#2 \\$replace of function str_replace expects array\\|string, string\\|null given\\.$#"
count: 1
path: src/Form/Helper/ProjectHelper.php
-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, bool\\|float\\|int\\|string given\\.$#"
count: 1
@@ -3273,36 +3104,6 @@ parameters:
count: 1
path: src/Form/Type/CustomerType.php
-
message: "#^Parameter \\#1 \\$inputTimezone of class Symfony\\\\Component\\\\Form\\\\Extension\\\\Core\\\\DataTransformer\\\\DateTimeToLocalizedStringTransformer constructor expects string\\|null, mixed given\\.$#"
count: 2
path: src/Form/Type/DateRangeType.php
-
message: "#^Parameter \\#1 \\$separator of function explode expects non\\-empty\\-string, mixed given\\.$#"
count: 1
path: src/Form/Type/DateRangeType.php
-
message: "#^Parameter \\#2 \\$outputTimezone of class Symfony\\\\Component\\\\Form\\\\Extension\\\\Core\\\\DataTransformer\\\\DateTimeToLocalizedStringTransformer constructor expects string\\|null, mixed given\\.$#"
count: 2
path: src/Form/Type/DateRangeType.php
-
message: "#^Parameter \\#4 \\$timezone of class IntlDateFormatter constructor expects DateTimeZone\\|IntlTimeZone\\|string\\|null, mixed given\\.$#"
count: 1
path: src/Form/Type/DateRangeType.php
-
message: "#^Parameter \\#6 \\$pattern of class IntlDateFormatter constructor expects string\\|null, mixed given\\.$#"
count: 1
path: src/Form/Type/DateRangeType.php
-
message: "#^Parameter \\#6 \\$pattern of class Symfony\\\\Component\\\\Form\\\\Extension\\\\Core\\\\DataTransformer\\\\DateTimeToLocalizedStringTransformer constructor expects string\\|null, mixed given\\.$#"
count: 1
path: src/Form/Type/DateRangeType.php
-
message: "#^Parameter \\#1 \\$inputTimezone of class Symfony\\\\Component\\\\Form\\\\Extension\\\\Core\\\\DataTransformer\\\\DateTimeToArrayTransformer constructor expects string\\|null, mixed given\\.$#"
count: 1
@@ -3383,11 +3184,6 @@ parameters:
count: 1
path: src/Form/Type/SystemConfigurationType.php
-
message: "#^Parameter \\#1 \\$teams of method App\\\\Repository\\\\Query\\\\BaseQuery\\:\\:setTeams\\(\\) expects array\\<App\\\\Entity\\\\Team\\>\\|null, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Form/Type/TeamType.php
-
message: "#^Parameter \\#1 \\$format of static method DateTime\\:\\:createFromFormat\\(\\) expects string, mixed given\\.$#"
count: 1
@@ -3868,16 +3664,6 @@ parameters:
count: 1
path: src/Model/ActivityStatistic.php
-
message: "#^Method App\\\\Model\\\\BudgetStatisticModel\\:\\:setStatistic\\(\\) has no return type specified\\.$#"
count: 1
path: src/Model/BudgetStatisticModel.php
-
message: "#^Method App\\\\Model\\\\BudgetStatisticModel\\:\\:setStatisticTotal\\(\\) has no return type specified\\.$#"
count: 1
path: src/Model/BudgetStatisticModel.php
-
message: "#^Parameter \\#3 \\$length of function substr expects int\\|null, int\\<0, max\\>\\|false given\\.$#"
count: 1
@@ -4013,21 +3799,6 @@ parameters:
count: 2
path: src/Project/ProjectStatisticService.php
-
message: "#^Parameter \\#1 \\$counter of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setCounter\\(\\) expects int, float\\|int given\\.$#"
count: 1
path: src/Project/ProjectStatisticService.php
-
message: "#^Parameter \\#1 \\$counter of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setCounterBillable\\(\\) expects int, float\\|int given\\.$#"
count: 1
path: src/Project/ProjectStatisticService.php
-
message: "#^Parameter \\#1 \\$counter of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setCounterExported\\(\\) expects int, float\\|int given\\.$#"
count: 1
path: src/Project/ProjectStatisticService.php
-
message: "#^Parameter \\#1 \\$datetime of method App\\\\Timesheet\\\\DateTimeFactory\\:\\:createDateTime\\(\\) expects string, mixed given\\.$#"
count: 1
@@ -4118,311 +3889,11 @@ parameters:
count: 1
path: src/Reporting/ProjectDetails/ProjectDetailsModel.php
-
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Cannot call method getSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Cannot call method hasSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 2
path: src/Repository/ActivityRepository.php
-
message: "#^Method App\\\\Repository\\\\ActivityRepository\\:\\:addPermissionCriteria\\(\\) has parameter \\$teams with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Method App\\\\Repository\\\\ActivityRepository\\:\\:deleteActivity\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Method App\\\\Repository\\\\ActivityRepository\\:\\:getPermissionCriteria\\(\\) has parameter \\$teams with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Method App\\\\Repository\\\\ActivityRepository\\:\\:saveActivity\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Method App\\\\Repository\\\\BookmarkRepository\\:\\:deleteBookmark\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/BookmarkRepository.php
-
message: "#^Method App\\\\Repository\\\\BookmarkRepository\\:\\:saveBookmark\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/BookmarkRepository.php
-
message: "#^Parameter \\#1 \\$string of function mb_substr expects string, string\\|null given\\.$#"
count: 1
path: src/Repository/BookmarkRepository.php
-
message: "#^Parameter \\#1 \\$user of method App\\\\Repository\\\\BookmarkRepository\\:\\:clearCache\\(\\) expects App\\\\Entity\\\\User, App\\\\Entity\\\\User\\|null given\\.$#"
count: 2
path: src/Repository/BookmarkRepository.php
-
message: "#^Property App\\\\Repository\\\\BookmarkRepository\\:\\:\\$userCache type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/BookmarkRepository.php
-
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Cannot call method getSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Cannot call method hasSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 2
path: src/Repository/CustomerRepository.php
-
message: "#^Method App\\\\Repository\\\\CustomerRepository\\:\\:addPermissionCriteria\\(\\) has parameter \\$teams with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Method App\\\\Repository\\\\CustomerRepository\\:\\:getComments\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Method App\\\\Repository\\\\CustomerRepository\\:\\:getPermissionCriteria\\(\\) has parameter \\$teams with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Cannot access offset 'counter' on mixed\\.$#"
count: 1
path: src/Repository/InvoiceRepository.php
-
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/InvoiceRepository.php
-
message: "#^Cannot call method getSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/InvoiceRepository.php
-
message: "#^Cannot call method hasSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 2
path: src/Repository/InvoiceRepository.php
-
message: "#^Method App\\\\Repository\\\\InvoiceRepository\\:\\:addPermissionCriteria\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/InvoiceRepository.php
-
message: "#^Method App\\\\Repository\\\\InvoiceRepository\\:\\:addPermissionCriteria\\(\\) has parameter \\$teams with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/InvoiceRepository.php
-
message: "#^Method App\\\\Repository\\\\InvoiceRepository\\:\\:getCounterFor\\(\\) should return int but returns mixed\\.$#"
count: 1
path: src/Repository/InvoiceRepository.php
-
message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Repository/InvoiceRepository.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Repository/Loader/ActivityLoader.php
-
message: "#^Parameter \\#1 \\$results \\(array\\<App\\\\Entity\\\\Activity\\|int\\>\\) of method App\\\\Repository\\\\Loader\\\\ActivityLoader\\:\\:loadResults\\(\\) should be contravariant with parameter \\$results \\(array\\) of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\)$#"
count: 1
path: src/Repository/Loader/ActivityLoader.php
-
message: "#^Parameter \\#1 \\$results \\(array\\<App\\\\Entity\\\\Customer\\|int\\>\\) of method App\\\\Repository\\\\Loader\\\\CustomerLoader\\:\\:loadResults\\(\\) should be contravariant with parameter \\$results \\(array\\) of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\)$#"
count: 1
path: src/Repository/Loader/CustomerLoader.php
-
message: "#^Method App\\\\Repository\\\\Loader\\\\DefaultLoader\\:\\:loadResults\\(\\) has parameter \\$results with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/Loader/DefaultLoader.php
-
message: "#^Parameter \\#1 \\$results \\(array\\<App\\\\Entity\\\\Invoice\\|int\\>\\) of method App\\\\Repository\\\\Loader\\\\InvoiceLoader\\:\\:loadResults\\(\\) should be contravariant with parameter \\$results \\(array\\) of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\)$#"
count: 1
path: src/Repository/Loader/InvoiceLoader.php
-
message: "#^Method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\) has parameter \\$results with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/Loader/LoaderInterface.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Repository/Loader/ProjectLoader.php
-
message: "#^Parameter \\#1 \\$results \\(array\\<App\\\\Entity\\\\Project\\|int\\>\\) of method App\\\\Repository\\\\Loader\\\\ProjectLoader\\:\\:loadResults\\(\\) should be contravariant with parameter \\$results \\(array\\) of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\)$#"
count: 1
path: src/Repository/Loader/ProjectLoader.php
-
message: "#^Parameter \\#1 \\$results \\(array\\<App\\\\Entity\\\\Team\\|int\\>\\) of method App\\\\Repository\\\\Loader\\\\TeamLoader\\:\\:loadResults\\(\\) should be contravariant with parameter \\$results \\(array\\) of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\)$#"
count: 1
path: src/Repository/Loader/TeamLoader.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Repository/Loader/TimesheetLoader.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1
path: src/Repository/Loader/TimesheetLoader.php
-
message: "#^Parameter \\#1 \\$results \\(array\\<App\\\\Entity\\\\Timesheet\\|int\\>\\) of method App\\\\Repository\\\\Loader\\\\TimesheetLoader\\:\\:loadResults\\(\\) should be contravariant with parameter \\$results \\(array\\) of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\)$#"
count: 1
path: src/Repository/Loader/TimesheetLoader.php
-
message: "#^Parameter \\#1 \\$results \\(array\\<App\\\\Entity\\\\User\\|int\\>\\) of method App\\\\Repository\\\\Loader\\\\UserLoader\\:\\:loadResults\\(\\) should be contravariant with parameter \\$results \\(array\\) of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\)$#"
count: 1
path: src/Repository/Loader/UserLoader.php
-
message: "#^Method App\\\\Repository\\\\Paginator\\\\LoaderPaginator\\:\\:getAll\\(\\) return type has no value type specified in iterable type iterable\\.$#"
count: 1
path: src/Repository/Paginator/LoaderPaginator.php
-
message: "#^Method App\\\\Repository\\\\Paginator\\\\LoaderPaginator\\:\\:getNbResults\\(\\) should return int\\<0, max\\> but returns int\\.$#"
count: 1
path: src/Repository/Paginator/LoaderPaginator.php
-
message: "#^Parameter \\#1 \\$results of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\) expects array, mixed given\\.$#"
count: 1
path: src/Repository/Paginator/LoaderPaginator.php
-
message: "#^Interface App\\\\Repository\\\\Paginator\\\\PaginatorInterface extends generic interface Pagerfanta\\\\Adapter\\\\AdapterInterface but does not specify its types\\: T$#"
count: 1
path: src/Repository/Paginator/PaginatorInterface.php
-
message: "#^Method App\\\\Repository\\\\Paginator\\\\PaginatorInterface\\:\\:getAll\\(\\) return type has no value type specified in iterable type iterable\\.$#"
count: 1
path: src/Repository/Paginator/PaginatorInterface.php
-
message: "#^Method App\\\\Repository\\\\Paginator\\\\QueryBuilderPaginator\\:\\:getAll\\(\\) return type has no value type specified in iterable type iterable\\.$#"
count: 1
path: src/Repository/Paginator/QueryBuilderPaginator.php
-
message: "#^Method App\\\\Repository\\\\Paginator\\\\QueryBuilderPaginator\\:\\:getNbResults\\(\\) should return int\\<0, max\\> but returns int\\.$#"
count: 1
path: src/Repository/Paginator/QueryBuilderPaginator.php
-
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Cannot call method getSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Cannot call method hasSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 2
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:addPermissionCriteria\\(\\) has parameter \\$teams with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:deleteComment\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:deleteProject\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:getComments\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:getPermissionCriteria\\(\\) has parameter \\$teams with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:getProjectsForQuery\\(\\) should return iterable\\<App\\\\Entity\\\\Project\\> but returns mixed\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:saveComment\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:saveProject\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Parameter \\#1 \\$results of method App\\\\Repository\\\\Loader\\\\ProjectLoader\\:\\:loadResults\\(\\) expects array\\<App\\\\Entity\\\\Project\\|int\\>, mixed given\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\Query\\\\BaseQuery\\:\\:getOrderGroups\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -4468,46 +3939,6 @@ parameters:
count: 1
path: src/Repository/Query/TimesheetQuery.php
-
message: "#^Cannot access offset 'counter' on mixed\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Cannot access offset 'duration' on mixed\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Method App\\\\Repository\\\\Result\\\\TimesheetResult\\:\\:getResults\\(\\) should return array\\<App\\\\Entity\\\\Timesheet\\> but returns mixed\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Method App\\\\Repository\\\\Result\\\\TimesheetResult\\:\\:toIterable\\(\\) return type has no value type specified in iterable type iterable\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Parameter \\#1 \\$count of class App\\\\Repository\\\\Result\\\\TimesheetResultStatistic constructor expects int, mixed given\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Parameter \\#1 \\$results of method App\\\\Repository\\\\Loader\\\\TimesheetLoader\\:\\:loadResults\\(\\) expects array\\<App\\\\Entity\\\\Timesheet\\|int\\>, mixed given\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Parameter \\#2 \\$duration of class App\\\\Repository\\\\Result\\\\TimesheetResultStatistic constructor expects int, mixed given\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Property App\\\\Repository\\\\Result\\\\TimesheetResult\\:\\:\\$resultCache \\(array\\<App\\\\Entity\\\\Timesheet\\>\\|null\\) does not accept mixed\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Method App\\\\Repository\\\\RoleRepository\\:\\:deleteRole\\(\\) has no return type specified\\.$#"
count: 1
@@ -4518,46 +3949,6 @@ parameters:
count: 1
path: src/Repository/RoleRepository.php
-
message: "#^Cannot call method getSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/TagRepository.php
-
message: "#^Cannot call method hasSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/TagRepository.php
-
message: "#^Method App\\\\Repository\\\\TeamRepository\\:\\:addPermissionCriteria\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/TeamRepository.php
-
message: "#^Method App\\\\Repository\\\\TeamRepository\\:\\:deleteTeam\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/TeamRepository.php
-
message: "#^Method App\\\\Repository\\\\TeamRepository\\:\\:removeTeamMember\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/TeamRepository.php
-
message: "#^Method App\\\\Repository\\\\TeamRepository\\:\\:saveTeam\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/TeamRepository.php
-
message: "#^Return type \\(array\\<App\\\\Entity\\\\Team\\>\\) of method App\\\\Repository\\\\TeamRepository\\:\\:findAll\\(\\) should be covariant with return type \\(array\\<int, App\\\\Entity\\\\Team\\>\\) of method Doctrine\\\\Persistence\\\\ObjectRepository\\<App\\\\Entity\\\\Team\\>\\:\\:findAll\\(\\)$#"
count: 1
path: src/Repository/TeamRepository.php
-
message: "#^Return type \\(array\\<App\\\\Entity\\\\Team\\>\\) of method App\\\\Repository\\\\TeamRepository\\:\\:findAll\\(\\) should be covariant with return type \\(list\\<App\\\\Entity\\\\Team\\>\\) of method Doctrine\\\\ORM\\\\EntityRepository\\<App\\\\Entity\\\\Team\\>\\:\\:findAll\\(\\)$#"
count: 1
path: src/Repository/TeamRepository.php
-
message: "#^Argument of an invalid type array\\<string\\>\\|string supplied for foreach, only iterables are supported\\.$#"
count: 1
@@ -4603,41 +3994,11 @@ parameters:
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Cannot call method getSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Cannot call method hasSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 2
path: src/Repository/TimesheetRepository.php
-
message: "#^Cannot clone DateTime\\|null\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:findTimesheetsById\\(\\) should return array\\<App\\\\Entity\\\\Timesheet\\> but returns iterable\\<App\\\\Entity\\\\Timesheet\\>\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:getActiveEntries\\(\\) should return array\\<App\\\\Entity\\\\Timesheet\\> but returns iterable\\<App\\\\Entity\\\\Timesheet\\>\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:getHydratedResultsByQuery\\(\\) should return iterable\\<App\\\\Entity\\\\Timesheet\\> but returns mixed\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:getRawData\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -4648,11 +4009,6 @@ parameters:
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:setExported\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Parameter \\#1 \\$amountThisMonth of method App\\\\Model\\\\TimesheetStatistic\\:\\:setAmountThisMonth\\(\\) expects float\\|int, mixed given\\.$#"
count: 1
@@ -4683,56 +4039,11 @@ parameters:
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Parameter \\#1 \\$results of method App\\\\Repository\\\\Loader\\\\TimesheetLoader\\:\\:loadResults\\(\\) expects array\\<App\\\\Entity\\\\Timesheet\\|int\\>, mixed given\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Parameter \\#2 \\$amount of class App\\\\Model\\\\Revenue constructor expects float, mixed given\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/UserRepository.php
-
message: "#^Cannot call method getSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/UserRepository.php
-
message: "#^Cannot call method hasSearchTerm\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
path: src/Repository/UserRepository.php
-
message: "#^Method App\\\\Repository\\\\UserRepository\\:\\:addPermissionCriteria\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/UserRepository.php
-
message: "#^Method App\\\\Repository\\\\UserRepository\\:\\:deleteUser\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/UserRepository.php
-
message: "#^Method App\\\\Repository\\\\UserRepository\\:\\:findByUsername\\(\\) has parameter \\$username with no type specified\\.$#"
count: 1
path: src/Repository/UserRepository.php
-
message: "#^Parameter \\#1 \\$identifier of method App\\\\Repository\\\\UserRepository\\:\\:loadUserByIdentifier\\(\\) expects string, mixed given\\.$#"
count: 1
path: src/Repository/UserRepository.php
-
message: "#^Parameter \\#1 \\$message of class Symfony\\\\Component\\\\Security\\\\Core\\\\Exception\\\\AuthenticationException constructor expects string, string\\|null given\\.$#"
count: 1
@@ -4818,41 +4129,6 @@ parameters:
count: 1
path: src/Security/SessionHandler.php
-
message: "#^Method App\\\\Timesheet\\\\DateTimeFactory\\:\\:createWeekDateTime\\(\\) has no return type specified\\.$#"
count: 1
path: src/Timesheet/DateTimeFactory.php
-
message: "#^Method App\\\\Timesheet\\\\DateTimeFactory\\:\\:createWeekDateTime\\(\\) has parameter \\$day with no type specified\\.$#"
count: 1
path: src/Timesheet/DateTimeFactory.php
-
message: "#^Method App\\\\Timesheet\\\\DateTimeFactory\\:\\:createWeekDateTime\\(\\) has parameter \\$hour with no type specified\\.$#"
count: 1
path: src/Timesheet/DateTimeFactory.php
-
message: "#^Method App\\\\Timesheet\\\\DateTimeFactory\\:\\:createWeekDateTime\\(\\) has parameter \\$minute with no type specified\\.$#"
count: 1
path: src/Timesheet/DateTimeFactory.php
-
message: "#^Method App\\\\Timesheet\\\\DateTimeFactory\\:\\:createWeekDateTime\\(\\) has parameter \\$second with no type specified\\.$#"
count: 1
path: src/Timesheet/DateTimeFactory.php
-
message: "#^Method App\\\\Timesheet\\\\DateTimeFactory\\:\\:createWeekDateTime\\(\\) has parameter \\$week with no type specified\\.$#"
count: 1
path: src/Timesheet/DateTimeFactory.php
-
message: "#^Method App\\\\Timesheet\\\\DateTimeFactory\\:\\:createWeekDateTime\\(\\) has parameter \\$year with no type specified\\.$#"
count: 1
path: src/Timesheet/DateTimeFactory.php
-
message: "#^Parameter \\#2 \\$isFavorite of class App\\\\Model\\\\FavoriteTimesheet constructor expects bool, App\\\\Model\\\\FavoriteTimesheet\\|bool given\\.$#"
count: 1
@@ -5583,16 +4859,6 @@ parameters:
count: 1
path: src/Widget/Type/PaginatedWorkingTimeChart.php
-
message: "#^Parameter \\#1 \\$results of method App\\\\Repository\\\\Loader\\\\TeamLoader\\:\\:loadResults\\(\\) expects array\\<App\\\\Entity\\\\Team\\|int\\>, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Widget/Type/UserTeamProjects.php
-
message: "#^Parameter \\#1 \\$results of method App\\\\Repository\\\\Loader\\\\UserLoader\\:\\:loadResults\\(\\) expects array\\<App\\\\Entity\\\\User\\|int\\>, array\\<int, int\\|null\\> given\\.$#"
count: 1
path: src/Widget/Type/UserTeams.php
-
message: "#^Property App\\\\Widget\\\\WidgetService\\:\\:\\$widgets type has no value type specified in iterable type array\\.$#"
count: 1

View File

@@ -36,9 +36,14 @@ DirectoryIndex index.php
RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$
RewriteRule .* - [E=BASE:%1]
# Sets the HTTP_AUTHORIZATION header removed by Apache
# Sets the HTTP_AUTHORIZATION header removed by Apache, if PHP is installed as CGI
# Required for the API and below are 3 different methods to achieve it
RewriteCond %{HTTP:Authorization} .+
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
# This might be the only required line
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
# Apache 2.4.13+
# CGIPassAuth On
# Redirect to URI without front controller to prevent duplicate content
# (with and without `/index.php`). Only do this redirect on the initial

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,10 @@
"app": {
"js": [
"/build/runtime.74179306.js",
"/build/app.cde83469.js"
"/build/app.3611eec9.js"
],
"css": [
"/build/app.ee4f7e19.css"
"/build/app.c4fc7cc8.css"
]
},
"app-rtl": {
@@ -15,7 +15,7 @@
"/build/app-rtl.97153087.js"
],
"css": [
"/build/app-rtl.db588f2b.css"
"/build/app-rtl.83358c8a.css"
]
},
"export-pdf": {
@@ -54,7 +54,7 @@
"calendar": {
"js": [
"/build/runtime.74179306.js",
"/build/calendar.bb629e51.js"
"/build/calendar.e52c9d34.js"
],
"css": [
"/build/calendar.bb473428.css"
@@ -72,10 +72,10 @@
},
"integrity": {
"/build/runtime.74179306.js": "sha384-OC1hTNUXUalKJcvmzrZ0TMCOIwnhxCxgG9dQkbcwR7WcBBCkl2H8bs3giiT2pAwG",
"/build/app.cde83469.js": "sha384-829jYMX4FgmjcB0IV2HWYYOEviCyDD55lOX4fXXWUmUgDuDXXFZMyPFEEcK93dWR",
"/build/app.ee4f7e19.css": "sha384-7E3ujVsOCkGatHu6khaWWqYhPkc85k22zuXZOwOVo1MlWcPSEBhS65Y0qbeWXdAp",
"/build/app.3611eec9.js": "sha384-uBLSZpUnOLWU2TLPGZ0gZDqmiAEa2xrKhWVkHheU+vBjcStIq8ZQg3/iqxLS9Su0",
"/build/app.c4fc7cc8.css": "sha384-icbl3mA5CyeJXjF1YzLObStpJHF5qZ5GdqALtvDYN4KPOCqREwGdFWDu27GYkI+U",
"/build/app-rtl.97153087.js": "sha384-jX7jRUAa8rH29Eg8jLIUKGfGcOT6RBz/P90plXmZPadf2CXKUBdcNGrspaejCHkr",
"/build/app-rtl.db588f2b.css": "sha384-OwYS82WpkkoVqI38n3gGpvMTiTFT+dUscgVglMw2vcDvIxAe2B3IcXMjHoCGm/gl",
"/build/app-rtl.83358c8a.css": "sha384-eKnlBDnocNJ68nIc2l4AWCLn3oUEEbsRJoZ1TUP3mii+ToGt9xs08ih8wEmvO+Dq",
"/build/export-pdf.1442bee7.js": "sha384-C6agvjJnQUsMCxaZ/J7dUZU3cpC7uHKI9ZtnfGpRqYbjyw91YLy1oV3iNYgwYyCF",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.7ef8a0c8.js": "sha384-z4lZ1Ig3+NPigrRyGPZoff0gG3n5PnCjaDJ73ATnzdUYk0lOCEtNV9fg35VRD0vG",
@@ -83,7 +83,7 @@
"/build/invoice-pdf.6091a528.js": "sha384-yJz/MDBgBpNHNxF7DuSyFbJN9EL7oDJjraoPRB7VYlHMdb1T0ZfTcI0L8VMDB46U",
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
"/build/chart.80ee190c.js": "sha384-KrQszq859PR0u+KHpGy+RO6K1rcMqCFZx+sNiLVNVZHwEi7RwTXqnYbu8zvfWzUI",
"/build/calendar.bb629e51.js": "sha384-uihkC7VSrD8l4gSiveJxM2g8dLFCR9+VQwVmcIb2LlNmA2PKDQ/uV0TN5gzmgpo+",
"/build/calendar.e52c9d34.js": "sha384-a6P/nyvUkmPZglNqIfSKEyon791oFKB2+zgfUZHpy1awKi/FBchr5TqeRYupRDQn",
"/build/calendar.bb473428.css": "sha384-900W9o8666Qpw21rLP7hn5Ql8tWd40k0IcE3QpFm7E7V6bKva7xQgGlyzVuuh4GK",
"/build/dashboard.10118fb0.js": "sha384-pasZR1GYf8/7lMFS9OrsxX8nJ9tSlRYr32fWKUhGoF2mNAGrokLEWdgtbYMRHgFB",
"/build/dashboard.18f5a8b7.css": "sha384-PBD8ftb2yBoSjRe2sN5Xb4dEz045kOEUfcIufdSis+tWoWdAx5j4Yic+F/6CYbrP"

View File

@@ -1,7 +1,7 @@
{
"build/app.css": "/build/app.ee4f7e19.css",
"build/app.js": "/build/app.cde83469.js",
"build/app-rtl.css": "/build/app-rtl.db588f2b.css",
"build/app.css": "/build/app.c4fc7cc8.css",
"build/app.js": "/build/app.3611eec9.js",
"build/app-rtl.css": "/build/app-rtl.83358c8a.css",
"build/app-rtl.js": "/build/app-rtl.97153087.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.1442bee7.js",
@@ -11,7 +11,7 @@
"build/invoice-pdf.js": "/build/invoice-pdf.6091a528.js",
"build/chart.js": "/build/chart.80ee190c.js",
"build/calendar.css": "/build/calendar.bb473428.css",
"build/calendar.js": "/build/calendar.bb629e51.js",
"build/calendar.js": "/build/calendar.e52c9d34.js",
"build/dashboard.css": "/build/dashboard.18f5a8b7.css",
"build/dashboard.js": "/build/dashboard.10118fb0.js",
"build/runtime.js": "/build/runtime.74179306.js",

View File

@@ -4,6 +4,9 @@ use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
// TODO remove once PARTIAL usage was replaced entirely
\Doctrine\Deprecations\Deprecation::ignoreDeprecations('https://github.com/doctrine/orm/issues/8471');
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

View File

@@ -62,13 +62,13 @@ final class ActivityController extends BaseApiController
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name|project', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name, project (default: name)')]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
public function cgetAction(ParamFetcherInterface $paramFetcher, ProjectRepository $projectRepository): Response
{
/** @var User $user */
$user = $this->getUser();
$query = new ActivityQuery();
$query->setCurrentUser($user);
$query->loadTeams();
$this->prepareQuery($query, $paramFetcher);
$order = $paramFetcher->get('order');
if (\is_string($order) && $order !== '') {
@@ -109,9 +109,9 @@ final class ActivityController extends BaseApiController
$query->setSearchTerm(new SearchTerm($term));
}
$query->setIsApiCall(true);
$data = $this->repository->getActivitiesForQuery($query);
$view = new View($data, 200);
$pagination = $this->repository->getPagerfantaForQuery($query);
$view = $this->createPaginatedView($pagination);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
return $this->viewHandler->handle($view);

View File

@@ -16,16 +16,19 @@ final class ApiRequestMatcher implements RequestMatcherInterface
{
public function matches(Request $request): bool
{
// we do not want to handle URLs that
// we do not want to handle URLs that are not in the API scope
if (!str_starts_with($request->getRequestUri(), '/api/')) {
return false;
}
// API documentation is only available to registered users
// API documentation is only available to registered and logged-in users
if (str_starts_with($request->getRequestUri(), '/api/doc')) {
return false;
}
// ------------------------------------------------------------------------------------
// the next two checks are primarily here to make sure to return proper error messages
// let's use this firewall if a Bearer token is set in the header
// other cases like "bearer" are rejected earlier
if (($auth = $request->headers->get('Authorization')) !== null && str_starts_with($auth, 'Bearer ')) {
@@ -37,9 +40,11 @@ final class ApiRequestMatcher implements RequestMatcherInterface
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) {
return true;
}
// ------------------------------------------------------------------------------------
// checking for a previous session allows us to skip the API firewall and token access handler
// we simply re-use the existing session when doing API calls from the frontend
// we simply re-use the existing session when doing API calls from the frontend.
// it is not necessary to check headers. if there is no valid session, we should always use this firewall
return !$request->hasPreviousSession();
}
}

View File

@@ -13,6 +13,7 @@ use App\Entity\User;
use App\Repository\Query\BaseQuery;
use App\Timesheet\DateTimeFactory;
use App\Utils\Pagination;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
@@ -52,6 +53,46 @@ abstract class BaseApiController extends AbstractController
return DateTimeFactory::createByUser($user);
}
protected function prepareQuery(BaseQuery $query, ParamFetcherInterface $paramFetcher): void
{
$query->setIsApiCall(true);
$query->setCurrentUser($this->getUser());
// there is no function has() in ParamFetcherInterface, so we need to use all() and check for the key
$all = $paramFetcher->all(true);
if (\array_key_exists('page', $all)) {
$page = $all['page'];
if (is_numeric($page)) {
$query->setPage((int) $page);
}
}
if (\array_key_exists('size', $all)) {
$size = $all['size'];
if (is_numeric($size)) {
$query->setPageSize((int) $size);
}
}
if (\array_key_exists('pageSize', $all)) {
$size = $all['pageSize'];
if (is_numeric($size)) {
$query->setPageSize((int) $size);
}
}
}
protected function createPaginatedView(Pagination $pagination): View
{
$results = (array) $pagination->getCurrentPageResults();
$view = new View($results, 200);
$this->addPagination($view, $pagination);
return $view;
}
protected function addPagination(View $view, Pagination $pagination): void
{
$view->setHeader('X-Page', (string) $pagination->getCurrentPage());

View File

@@ -31,7 +31,7 @@ final class ConfigurationController extends BaseApiController
* Returns the timesheet configuration
*/
#[OA\Response(response: 200, description: 'Returns the instance specific timesheet configuration', content: new OA\JsonContent(ref: new Model(type: TimesheetConfig::class)))]
#[Route(methods: ['GET'], path: '/config/timesheet')]
#[Route(path: '/config/timesheet', methods: ['GET'])]
public function timesheetConfigAction(SystemConfiguration $configuration): Response
{
$model = new TimesheetConfig();
@@ -46,4 +46,17 @@ final class ConfigurationController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Returns the configured color codes and names
*/
#[OA\Response(response: 200, description: 'Returns the configured color codes and names', content: new OA\JsonContent(type: 'object', example: ['Red' => '#ff0000'], additionalProperties: new OA\AdditionalProperties(type: 'string')))]
#[Route(path: '/config/colors', methods: ['GET'])]
public function colorConfigAction(SystemConfiguration $configuration): Response
{
$view = new View($configuration->getThemeColors(), 200);
$view->getContext()->setGroups(['Default']);
return $this->viewHandler->handle($view);
}
}

View File

@@ -59,13 +59,13 @@ final class CustomerController extends BaseApiController
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name (default: name)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
/** @var User $user */
$user = $this->getUser();
$query = new CustomerQuery();
$query->setCurrentUser($user);
$query->loadTeams();
$this->prepareQuery($query, $paramFetcher);
$order = $paramFetcher->get('order');
if (\is_string($order) && $order !== '') {
@@ -87,9 +87,9 @@ final class CustomerController extends BaseApiController
$query->setSearchTerm(new SearchTerm($term));
}
$query->setIsApiCall(true);
$data = $this->repository->getCustomersForQuery($query);
$view = new View($data, 200);
$pagination = $this->repository->getPagerfantaForQuery($query);
$view = $this->createPaginatedView($pagination);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
return $this->viewHandler->handle($view);

View File

@@ -0,0 +1,106 @@
<?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\API;
use App\Entity\Invoice;
use App\Repository\CustomerRepository;
use App\Repository\InvoiceRepository;
use App\Repository\Query\InvoiceArchiveQuery;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints;
#[Route(path: '/invoices')]
#[IsGranted('API')]
#[OA\Tag(name: 'Invoice')]
final class InvoiceController extends BaseApiController
{
public const GROUPS_ENTITY = ['Default', 'Entity', 'Invoice', 'Invoice_Entity'];
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Invoice'];
public function __construct(
private readonly ViewHandlerInterface $viewHandler,
private readonly InvoiceRepository $repository,
) {
}
/**
* Returns a paginated collection of invoices.
*
* Needs permission: view_invoice
*/
#[IsGranted('view_invoice')]
#[OA\Response(response: 200, description: 'Returns a collection of invoices', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/InvoiceCollection')))]
#[Route(methods: ['GET'], path: '', name: 'get_invoices')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'customers', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of customer IDs to filter, e.g.: customers[]=1&customers[]=2')]
#[Rest\QueryParam(name: 'status', map: true, requirements: 'pending|paid|canceled|new', strict: true, nullable: true, default: [], description: 'Invoice status: pending, paid, canceled, new. Default: all')]
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository): Response
{
$query = new InvoiceArchiveQuery();
$this->prepareQuery($query, $paramFetcher);
$factory = $this->getDateTimeFactory();
$begin = $paramFetcher->get('begin');
if (\is_string($begin) && $begin !== '') {
$query->setBegin($factory->createDateTime($begin));
}
$end = $paramFetcher->get('end');
if (\is_string($end) && $end !== '') {
$query->setEnd($factory->createDateTime($end));
}
/** @var array<string> $status */
$status = $paramFetcher->get('status');
if (\is_array($status)) {
foreach ($status as $s) {
$query->addStatus($s);
}
}
/** @var array<int> $customers */
$customers = $paramFetcher->get('customers');
foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
$query->addCustomer($customer);
}
$data = $this->repository->getPagerfantaForQuery($query);
$view = $this->createPaginatedView($data);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
return $this->viewHandler->handle($view);
}
/**
* Returns one invoice.
*
* Needs permission: view_invoice
*/
#[IsGranted('view_invoice')]
#[OA\Response(response: 200, description: 'Returns one invoice', content: new OA\JsonContent(ref: '#/components/schemas/Invoice'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_invoice', requirements: ['id' => '\d+'])]
public function getAction(Invoice $invoice): Response
{
$view = new View($invoice, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
return $this->viewHandler->handle($view);
}
}

View File

@@ -68,13 +68,13 @@ final class ProjectController extends BaseApiController
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name|customer', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name, customer (default: name)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository): Response
{
/** @var User $user */
$user = $this->getUser();
$query = new ProjectQuery();
$query->setCurrentUser($user);
$query->loadTeams();
$this->prepareQuery($query, $paramFetcher);
$order = $paramFetcher->get('order');
if (\is_string($order) && $order !== '') {
@@ -141,9 +141,9 @@ final class ProjectController extends BaseApiController
$query->setSearchTerm(new SearchTerm($term));
}
$query->setIsApiCall(true);
$data = $this->repository->getProjectsForQuery($query);
$view = new View($data, 200);
$pagination = $this->repository->getPagerfantaForQuery($query);
$view = $this->createPaginatedView($pagination);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
return $this->viewHandler->handle($view);

View File

@@ -39,13 +39,14 @@ final class TagController extends BaseApiController
}
/**
* Fetch all existing tags
* Deprecated: Fetch tags by filter as string collection
*/
#[OA\Response(response: 200, description: 'Returns the collection of all existing tags as string array', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'string')))]
#[Route(methods: ['GET'], name: 'get_tags')]
#[Rest\QueryParam(name: 'name', strict: true, nullable: true, description: 'Search term to filter tag list')]
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
// not using a query, as these are too expensive to fetch, the simple "name" list is easier to use
$filter = $paramFetcher->get('name');
$data = $this->repository->findAllTagNames($filter);
@@ -56,6 +57,27 @@ final class TagController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Fetch tags by filter (as full entities)
*/
#[OA\Response(response: 200, description: 'Find the collection of all matching tags', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TagEntity')))]
#[Route(path: '/find', name: 'get_tags_full', methods: ['GET'])]
#[Rest\QueryParam(name: 'name', strict: true, nullable: true, description: 'Search term to filter tag list')]
public function findTags(ParamFetcherInterface $paramFetcher): Response
{
$filter = $paramFetcher->get('name');
$data = [];
if (\is_string($filter)) {
$data = $this->repository->findAllTags($filter);
}
$view = new View($data, 200);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
return $this->viewHandler->handle($view);
}
/**
* Creates a new tag
*/

View File

@@ -18,6 +18,7 @@ use App\Form\API\TeamApiEditForm;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
@@ -53,7 +54,10 @@ final class TeamController extends BaseApiController
#[Route(methods: ['GET'], path: '', name: 'get_teams')]
public function cgetAction(): Response
{
$data = $this->repository->findAll();
$query = new TeamQuery();
$query->setCurrentUser($this->getUser());
$data = $this->repository->getTeamsForQuery($query);
$view = new View($data, 200);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);

View File

@@ -96,7 +96,7 @@ final class TimesheetController extends BaseApiController
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response
{
$query = new TimesheetQuery(false);
$query->setCurrentUser($this->getUser());
$this->prepareQuery($query, $paramFetcher);
$seeAll = false;
if ($this->isGranted('view_other_timesheet')) {
@@ -168,16 +168,6 @@ final class TimesheetController extends BaseApiController
$query->addActivity($activity);
}
$page = $paramFetcher->get('page');
if (\is_string($page) && $page !== '') {
$query->setPage((int) $page);
}
$size = $paramFetcher->get('size');
if (\is_string($size) && $size !== '') {
$query->setPageSize((int) $size);
}
/** @var array<string> $tags */
$tags = $paramFetcher->get('tags');
if (\is_array($tags) && \count($tags) > 0) {
@@ -248,12 +238,8 @@ final class TimesheetController extends BaseApiController
$query->setModifiedAfter($factory->createDateTime($modifiedAfter));
}
$query->setIsApiCall(true);
$data = $this->repository->getPagerfantaForQuery($query);
$results = (array) $data->getCurrentPageResults();
$view = new View($results, 200);
$this->addPagination($view, $data);
$pagination = $this->repository->getPagerfantaForQuery($query);
$view = $this->createPaginatedView($pagination);
$full = $paramFetcher->get('full');
if ($full === '1' || $full === 'true') {

View File

@@ -9,7 +9,6 @@
namespace App\API;
use App\Configuration\SystemConfiguration;
use App\Entity\AccessToken;
use App\Entity\User;
use App\Event\PrepareUserEvent;
@@ -18,6 +17,7 @@ use App\Form\API\UserApiEditForm;
use App\Repository\AccessTokenRepository;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\User\UserService;
use App\Utils\SearchTerm;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface;
@@ -27,8 +27,6 @@ use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -45,8 +43,6 @@ final class UserController extends BaseApiController
public function __construct(
private readonly ViewHandlerInterface $viewHandler,
private readonly UserRepository $repository,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly SystemConfiguration $configuration
) {
}
@@ -61,10 +57,12 @@ final class UserController extends BaseApiController
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')]
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
$query = new UserQuery();
$query->setCurrentUser($this->getUser());
$this->prepareQuery($query, $paramFetcher);
$visible = $paramFetcher->get('visible');
if (\is_string($visible) && $visible !== '') {
@@ -86,9 +84,8 @@ final class UserController extends BaseApiController
$query->setSearchTerm(new SearchTerm($term));
}
$query->setIsApiCall(true);
$data = $this->repository->getUsersForQuery($query);
$view = new View($data, 200);
$pagination = $this->repository->getPagerfantaForQuery($query);
$view = $this->createPaginatedView($pagination);
$full = $paramFetcher->get('full');
if ($full === '1' || $full === 'true') {
@@ -139,13 +136,9 @@ final class UserController extends BaseApiController
#[OA\Post(description: 'Creates a new user and returns it afterwards')]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserCreateForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_user')]
public function postAction(Request $request): Response
public function postAction(Request $request, UserService $userService): Response
{
$user = new User();
$user->setEnabled(true);
$user->setRoles([User::DEFAULT_ROLE]);
$user->setTimezone($this->configuration->getUserDefaultTimezone());
$user->setLanguage($this->configuration->getUserDefaultLanguage());
$user = $userService->createNewUser();
$form = $this->createForm(UserApiCreateForm::class, $user, [
'include_roles' => $this->isGranted('roles', $user),
@@ -156,24 +149,11 @@ final class UserController extends BaseApiController
$form->submit($request->request->all());
if ($form->isValid()) {
$plainPassword = $user->getPlainPassword();
if ($plainPassword === null) {
throw new BadRequestHttpException('Password cannot be empty');
}
$password = $this->passwordHasher->hashPassword($user, $plainPassword);
$user->setPassword($password);
if ($user->getPlainApiToken() !== null) {
$user->setApiToken($this->passwordHasher->hashPassword($user, $user->getPlainApiToken()));
}
$this->repository->saveUser($user);
$user = $userService->saveNewUser($user);
$view = new View($user, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
$user->eraseCredentials();
return $this->viewHandler->handle($view);
}

View File

@@ -117,7 +117,7 @@ class ActivityStatisticService
/**
* @param Activity[] $activities
* @return array<int, ActivityStatistic>
* @return array<int|string, ActivityStatistic>
*/
private function getBudgetStatistic(array $activities, ?DateTimeInterface $begin = null, ?DateTimeInterface $end = null): array
{
@@ -159,6 +159,9 @@ class ActivityStatisticService
return $statistics;
}
/**
* @param Activity[] $activities
*/
private function createStatisticQueryBuilder(array $activities, \DateTimeInterface $begin = null, ?\DateTimeInterface $end = null): QueryBuilder
{
$qb = $this->timesheetRepository->createQueryBuilder('t');

View File

@@ -72,7 +72,7 @@ abstract class AbstractBundleInstallerCommand extends Command
*/
protected function getInstallerCommandName(): string
{
return sprintf('kimai:bundle:%s:install', $this->getBundleCommandNamePart());
return \sprintf('kimai:bundle:%s:install', $this->getBundleCommandNamePart());
}
/**
@@ -87,7 +87,7 @@ abstract class AbstractBundleInstallerCommand extends Command
if ($parts[0] !== 'KimaiPlugin') {
throw new LogicException(
sprintf('Unsupported namespace given, expected "KimaiPlugin" but received "%s". Please overwrite getBundleName() and return the correct bundle name.', $parts[0])
\sprintf('Unsupported namespace given, expected "KimaiPlugin" but received "%s". Please overwrite getBundleName() and return the correct bundle name.', $parts[0])
);
}
@@ -114,14 +114,14 @@ abstract class AbstractBundleInstallerCommand extends Command
$bundleName = $this->getBundleName();
$io->title(
sprintf('Starting installation of plugin: %s ...', $bundleName)
\sprintf('Starting installation of plugin: %s ...', $bundleName)
);
try {
$this->importMigrations($io, $output);
} catch (\Exception $ex) {
$io->error(
sprintf('Failed to install database for bundle %s. %s', $bundleName, $ex->getMessage())
\sprintf('Failed to install database for bundle %s. %s', $bundleName, $ex->getMessage())
);
return Command::FAILURE;
@@ -132,7 +132,7 @@ abstract class AbstractBundleInstallerCommand extends Command
$this->installAssets($io, $output);
} catch (\Exception $ex) {
$io->error(
sprintf('Failed to install assets for bundle %s. %s', $bundleName, $ex->getMessage())
\sprintf('Failed to install assets for bundle %s. %s', $bundleName, $ex->getMessage())
);
return Command::FAILURE;
@@ -142,7 +142,7 @@ abstract class AbstractBundleInstallerCommand extends Command
chdir($path);
$io->success(
sprintf('Congratulations! Plugin was successful installed: %s', $bundleName)
\sprintf('Congratulations! Plugin was successful installed: %s', $bundleName)
);
return Command::SUCCESS;

View File

@@ -56,5 +56,5 @@ abstract class AbstractRoleCommand extends Command
return Command::SUCCESS;
}
abstract protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role): void;
abstract protected function executeRoleCommand(UserService $userService, SymfonyStyle $output, User $user, bool $super, $role): void;
}

View File

@@ -51,9 +51,9 @@ final class ActivateUserCommand extends Command
if (!$user->isEnabled()) {
$user->setEnabled(true);
$this->userService->saveUser($user);
$io->success(sprintf('User "%s" has been activated.', $username));
$io->success(\sprintf('User "%s" has been activated.', $username));
} else {
$io->warning(sprintf('User "%s" is already active.', $username));
$io->warning(\sprintf('User "%s" is already active.', $username));
}
return Command::SUCCESS;

View File

@@ -67,7 +67,7 @@ final class ChangePasswordCommand extends AbstractUserCommand
try {
$user->setPlainPassword($password);
$this->userService->updateUser($user, ['PasswordUpdate']);
$io->success(sprintf('Changed password for user "%s".', $username));
$io->success(\sprintf('Changed password for user "%s".', $username));
} catch (ValidationFailedException $ex) {
$this->validationError($ex, $io);

View File

@@ -77,7 +77,7 @@ final class CreateUserCommand extends AbstractUserCommand
try {
$this->userService->saveUser($user);
$io->success(sprintf('Success! Created user: %s', $username));
$io->success(\sprintf('Success! Created user: %s', $username));
} catch (ValidationFailedException $ex) {
$this->validationError($ex, $io);

View File

@@ -51,9 +51,9 @@ final class DeactivateUserCommand extends Command
if ($user->isEnabled()) {
$user->setEnabled(false);
$this->userService->saveUser($user);
$io->success(sprintf('User "%s" has been deactivated.', $username));
$io->success(\sprintf('User "%s" has been deactivated.', $username));
} else {
$io->warning(sprintf('User "%s" is already deactivated.', $username));
$io->warning(\sprintf('User "%s" is already deactivated.', $username));
}
return Command::SUCCESS;

View File

@@ -33,24 +33,24 @@ final class DemoteUserCommand extends AbstractRoleCommand
);
}
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role): void
protected function executeRoleCommand(UserService $userService, SymfonyStyle $output, User $user, bool $super, $role): void
{
$username = $user->getUserIdentifier();
if ($super) {
if ($user->isSuperAdmin()) {
$user->setSuperAdmin(false);
$manipulator->saveUser($user);
$output->success(sprintf('Super administrator role has been removed from the user "%s".', $username));
$userService->saveUser($user);
$output->success(\sprintf('Super administrator role has been removed from the user "%s".', $username));
} else {
$output->warning(sprintf('User "%s" doesn\'t have the super administrator role.', $username));
$output->warning(\sprintf('User "%s" doesn\'t have the super administrator role.', $username));
}
} else {
if ($user->hasRole($role)) {
$user->removeRole($role);
$manipulator->saveUser($user);
$output->success(sprintf('Role "%s" has been removed from user "%s".', $role, $username));
$userService->saveUser($user);
$output->success(\sprintf('Role "%s" has been removed from user "%s".', $role, $username));
} else {
$output->warning(sprintf('User "%s" didn\'t have "%s" role.', $username, $role));
$output->warning(\sprintf('User "%s" didn\'t have "%s" role.', $username, $role));
}
}
}

View File

@@ -35,13 +35,13 @@ use Symfony\Contracts\Translation\TranslatorInterface;
final class ExportCreateCommand extends Command
{
public function __construct(
private ServiceExport $serviceExport,
private CustomerRepository $customerRepository,
private ProjectRepository $projectRepository,
private TeamRepository $teamRepository,
private UserRepository $userRepository,
private TranslatorInterface $translator,
private MailerInterface $mailer
private readonly ServiceExport $serviceExport,
private readonly CustomerRepository $customerRepository,
private readonly ProjectRepository $projectRepository,
private readonly TeamRepository $teamRepository,
private readonly UserRepository $userRepository,
private readonly TranslatorInterface $translator,
private readonly MailerInterface $mailer
) {
parent::__construct();
}
@@ -106,25 +106,25 @@ final class ExportCreateCommand extends Command
$timezone = new \DateTimeZone($timezone);
$dateFactory = new DateTimeFactory($timezone);
$customerIDs = $input->getOption('customer');
$customerIDs = $this->optionToIntArray($input, 'customer');
$customers = [];
if (\count($customerIDs) > 0) {
$customers = $this->customerRepository->findByIds($customerIDs);
}
$projectIDs = $input->getOption('project');
$projectIDs = $this->optionToIntArray($input, 'project');
$projects = [];
if (\count($projectIDs) > 0) {
$projects = $this->projectRepository->findByIds($projectIDs);
}
$teamIDs = $input->getOption('team');
$teamIDs = $this->optionToIntArray($input, 'team');
$teams = [];
if (\count($teamIDs) > 0) {
$teams = $this->teamRepository->findByIds($teamIDs);
}
$userIDs = $input->getOption('user');
$userIDs = $this->optionToIntArray($input, 'user');
$users = [];
if (\count($userIDs) > 0) {
$users = $this->userRepository->findByIds($userIDs);
@@ -197,17 +197,19 @@ final class ExportCreateCommand extends Command
$subject = 'Export data available';
$body = 'Your exported data is available, please find it attached to this email.';
/** @var array<string> $emails */
$emails = [];
/** @var array<string> $tmp */
$tmp = $input->getOption('email');
if (\count($tmp) > 0) {
foreach ($tmp as $email) {
$result = filter_var($email, FILTER_VALIDATE_EMAIL);
if ($result === false) {
$io->error('Invalid "email" given: ' . $email);
$io->error('Invalid "email" given');
return Command::FAILURE;
}
$emails[] = $email;
$emails[] = (string) $email;
}
}
@@ -227,7 +229,7 @@ final class ExportCreateCommand extends Command
$user = $this->userRepository->loadUserByIdentifier($username);
} catch(\Exception) {
$io->error(
sprintf('The given username "%s" could not be resolved', $username)
\sprintf('The given username "%s" could not be resolved', $username)
);
return Command::FAILURE;
@@ -285,6 +287,25 @@ final class ExportCreateCommand extends Command
return Command::SUCCESS;
}
/**
* @return array<int>
*/
private function optionToIntArray(InputInterface $input, string $name): array
{
$results = [];
$options = $input->getOption($name);
if (\is_array($options) && \count($options) > 0) {
foreach ($options as $option) {
if (is_numeric($option)) {
$results[] = (int) $option;
}
}
}
return $results;
}
private function savePreview(Response $response, string $directory): string
{
$filename = uniqid('invoice_');

View File

@@ -73,7 +73,7 @@ final class InstallCommand extends Command
}
$io->success(
sprintf('Congratulations! Successfully installed %s version %s', Constants::SOFTWARE, Constants::VERSION)
\sprintf('Congratulations! Successfully installed %s version %s', Constants::SOFTWARE, Constants::VERSION)
);
return Command::SUCCESS;
@@ -118,12 +118,12 @@ final class InstallCommand extends Command
{
try {
if ($this->connection->isConnected()) {
$io->note(sprintf('Database is existing and connection could be established'));
$io->note(\sprintf('Database is existing and connection could be established'));
return;
}
if (!$this->askConfirmation($input, $output, sprintf('Create the database "%s" (yes) or skip (no)?', $this->connection->getDatabase()), true)) {
if (!$this->askConfirmation($input, $output, \sprintf('Create the database "%s" (yes) or skip (no)?', $this->connection->getDatabase()), true)) {
throw new \Exception('Skipped database creation, aborting installation');
}
} catch (\Exception $exception) {
@@ -152,7 +152,7 @@ final class InstallCommand extends Command
{
/** @var QuestionHelper $questionHelper */
$questionHelper = $this->getHelperSet()->get('question');
$text = sprintf('<info>%s (yes/no)</info> [<comment>%s</comment>]:', $question, $default ? 'yes' : 'no');
$text = \sprintf('<info>%s (yes/no)</info> [<comment>%s</comment>]:', $question, $default ? 'yes' : 'no');
$question = new ConfirmationQuestion(' ' . $text . ' ', $default, '/^y|yes/i');
return $questionHelper->ask($input, $output, $question);

View File

@@ -90,7 +90,7 @@ final class InvoiceCreateCommand extends Command
$user = $this->userRepository->loadUserByIdentifier($username);
} catch (\Exception $exception) {
$io->error(
sprintf('The given username "%s" could not be resolved', $username)
\sprintf('The given username "%s" could not be resolved', $username)
);
return Command::FAILURE;
@@ -281,7 +281,7 @@ final class InvoiceCreateCommand extends Command
$tpl = $this->getTemplateForCustomer($input, $customer);
if (null === $tpl) {
$io->warning(sprintf('Could not find invoice template for project "%s", skipping!', $project->getName()));
$io->warning(\sprintf('Could not find invoice template for project "%s", skipping!', $project->getName()));
continue;
}
$query->setTemplate($tpl);
@@ -293,7 +293,7 @@ final class InvoiceCreateCommand extends Command
$invoices[] = $this->serviceInvoice->createInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher);
}
} catch (\Exception $ex) {
$io->error(sprintf('Failed to create invoice for project "%s" with: %s', $project->getName(), $ex->getMessage()));
$io->error(\sprintf('Failed to create invoice for project "%s" with: %s', $project->getName(), $ex->getMessage()));
}
}
@@ -352,7 +352,7 @@ final class InvoiceCreateCommand extends Command
$tpl = $this->getTemplateForCustomer($input, $customer);
if (null === $tpl) {
$io->warning(sprintf('Could not find invoice template for customer "%s", skipping!', $customer->getName()));
$io->warning(\sprintf('Could not find invoice template for customer "%s", skipping!', $customer->getName()));
continue;
}
$query->setTemplate($tpl);
@@ -364,7 +364,7 @@ final class InvoiceCreateCommand extends Command
$invoices[] = $this->serviceInvoice->createInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher);
}
} catch (\Exception $ex) {
$io->error(sprintf('Failed to create invoice for customer "%s" with: %s', $customer->getName(), $ex->getMessage()));
$io->error(\sprintf('Failed to create invoice for customer "%s" with: %s', $customer->getName(), $ex->getMessage()));
}
}
@@ -391,7 +391,7 @@ final class InvoiceCreateCommand extends Command
$columns = ['Filename'];
$table = new Table($output);
$table->setHeaderTitle(sprintf('Created %s invoice(s)', \count($invoices)));
$table->setHeaderTitle(\sprintf('Created %s invoice(s)', \count($invoices)));
$table->setHeaders($columns);
foreach ($invoices as $invoiceFile) {
@@ -406,14 +406,14 @@ final class InvoiceCreateCommand extends Command
$columns = ['ID', 'Customer', 'Total', 'Filename'];
$table = new Table($output);
$table->setHeaderTitle(sprintf('Created %s invoice(s)', \count($invoices)));
$table->setHeaderTitle(\sprintf('Created %s invoice(s)', \count($invoices)));
$table->setHeaders($columns);
foreach ($invoices as $invoice) {
$file = $this->serviceInvoice->getInvoiceFile($invoice);
if (null === $file) {
$io->warning(
sprintf('Created invoice with ID %s, but file was not found %s', $invoice->getId(), $invoice->getInvoiceFilename())
\sprintf('Created invoice with ID %s, but file was not found %s', $invoice->getId(), $invoice->getInvoiceFilename())
);
continue;
}

View File

@@ -33,24 +33,24 @@ final class PromoteUserCommand extends AbstractRoleCommand
);
}
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role): void
protected function executeRoleCommand(UserService $userService, SymfonyStyle $output, User $user, bool $super, $role): void
{
$username = $user->getUserIdentifier();
if ($super) {
if (!$user->isSuperAdmin()) {
$user->setSuperAdmin(true);
$manipulator->saveUser($user);
$output->success(sprintf('User "%s" has been promoted as a super administrator.', $username));
$userService->saveUser($user);
$output->success(\sprintf('User "%s" has been promoted as a super administrator.', $username));
} else {
$output->warning(sprintf('User "%s" does already have the super administrator role.', $username));
$output->warning(\sprintf('User "%s" does already have the super administrator role.', $username));
}
} else {
if (!$user->hasRole($role)) {
$user->addRole($role);
$manipulator->saveUser($user);
$output->success(sprintf('Role "%s" has been added to user "%s".', $role, $username));
$userService->saveUser($user);
$output->success(\sprintf('Role "%s" has been added to user "%s".', $role, $username));
} else {
$output->warning(sprintf('User "%s" did already have "%s" role.', $username, $role));
$output->warning(\sprintf('User "%s" did already have "%s" role.', $username, $role));
}
}
}

View File

@@ -39,13 +39,19 @@ final class RegenerateLocalesCommand extends Command
*
* @var string[]
*/
private array $noRegionCode = ['ar', 'id', 'pa', 'sl'];
private array $noRegionCode = ['ar', 'id', 'pa', 'sl', 'ca'];
/**
* A list of locales that will be activated, not matter if translation files exist for them.
* A list of locales that will be activated, no matter if translation files exist for them.
*
* @var string[]
*/
private array $addLocaleToList = ['zh_Hant_TW'];
/**
* A list of locales that will NOT be activated, as no translations exist by now.
*
* @var string[]
*/
private array $skipLocale = ['ca'];
public function __construct(
private readonly string $projectDirectory,
@@ -78,7 +84,11 @@ final class RegenerateLocalesCommand extends Command
}
$firstLevelLocales = [];
foreach ($translationFilenames as $file) {
$firstLevelLocales[] = explode('.', basename($file))[1];
$l = explode('.', basename($file))[1];
if (\in_array($l, $this->skipLocale, true)) {
continue;
}
$firstLevelLocales[] = $l;
}
$firstLevelLocales = array_unique(array_merge($firstLevelLocales, $this->addLocaleToList));
$io->title('First level locales found');
@@ -185,8 +195,8 @@ final class RegenerateLocalesCommand extends Command
// in the future this list should be reduced to the list of available translations, but for a long time users
// could choose from the entire list of all locales, so we likely have to keep that forever ...
$io->title('List of app_locales for services.yaml');
$io->writeln(implode('|', $locales));
$io->title('List of "kimai_locales" for services.yaml');
$io->writeln("['" . implode("', '", $locales) . "']");
ksort($appLocales);

View File

@@ -110,7 +110,7 @@ final class ReloadCommand extends Command
}
$io->success(
sprintf('Kimai config was reloaded')
\sprintf('Kimai config was reloaded')
);
return Command::SUCCESS;

View File

@@ -38,7 +38,7 @@ final class TimesheetStopAllCommand extends Command
if (!$output->isQuiet()) {
$io = new SymfonyStyle($input, $output);
$io->success(sprintf('Stopped %s timesheet records.', $amount));
$io->success(\sprintf('Stopped %s timesheet records.', $amount));
}
return Command::SUCCESS;

View File

@@ -28,7 +28,11 @@ use Symfony\Component\HttpClient\HttpClient;
#[AsCommand(name: 'kimai:translations')]
final class TranslationCommand extends Command
{
public function __construct(private string $projectDirectory, private string $kernelEnvironment, private LocaleService $localeService)
public function __construct(
private readonly string $projectDirectory,
private readonly string $kernelEnvironment,
private readonly LocaleService $localeService
)
{
parent::__construct();
}
@@ -70,6 +74,7 @@ final class TranslationCommand extends Command
$sources = [];
if ($input->getOption('source') !== null) {
/** @var string $tmp */
$tmp = $input->getOption('source');
foreach ($bases as $directory) {
$files = glob($directory);
@@ -95,6 +100,7 @@ final class TranslationCommand extends Command
$targets = [];
if ($input->getOption('target') !== null) {
/** @var string $tmp */
$tmp = $input->getOption('target');
foreach ($bases as $directory) {
$files = glob($directory);
@@ -402,7 +408,7 @@ final class TranslationCommand extends Command
$xmlContent = '';
foreach ($translations as $id => $values) {
$xmlContent .= sprintf(
$xmlContent .= \sprintf(
'<trans-unit id="%s" resname="%s"><source>%s</source><target>%s</target></trans-unit>',
$id,
$values['resname'],
@@ -463,7 +469,7 @@ final class TranslationCommand extends Command
if (!\array_key_exists($key, $translations)) {
throw new \Exception(
sprintf('Missing english translation for key: %s in file %s', $key, $file)
\sprintf('Missing english translation for key: %s in file %s', $key, $file)
);
}
$unit->target[0] = $translations[$key];

View File

@@ -91,7 +91,7 @@ final class UpdateCommand extends Command
if ($cacheResult !== Command::SUCCESS) {
$io->warning(
[
sprintf('Updated %s to version %s but the cache could not be rebuilt.', Constants::SOFTWARE, Constants::VERSION),
\sprintf('Updated %s to version %s but the cache could not be rebuilt.', Constants::SOFTWARE, Constants::VERSION),
'Please run the cache commands manually:',
'bin/console cache:clear --env=' . $environment . PHP_EOL .
'bin/console cache:warmup --env=' . $environment
@@ -99,7 +99,7 @@ final class UpdateCommand extends Command
);
} else {
$io->success(
sprintf('Congratulations! Successfully updated %s to version %s', Constants::SOFTWARE, Constants::VERSION)
\sprintf('Congratulations! Successfully updated %s to version %s', Constants::SOFTWARE, Constants::VERSION)
);
}

View File

@@ -21,10 +21,10 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
#[AsCommand(name: 'kimai:user:login-link', description: 'Create a URL that can be used to login as that user', hidden: true)]
/**
* @CloudRequired
*/
#[AsCommand(name: 'kimai:user:login-link', description: 'Create a URL that can be used to login as that user', hidden: true)]
final class UserLoginLinkCommand extends Command
{
public function __construct(

View File

@@ -46,7 +46,7 @@ final class VersionCommand extends Command
return Command::SUCCESS;
}
$io->writeln(sprintf('%s <info>%s</info> by Kevin Papst.', Constants::SOFTWARE, Constants::VERSION));
$io->writeln(\sprintf('%s <info>%s</info> by Kevin Papst.', Constants::SOFTWARE, Constants::VERSION));
return Command::SUCCESS;
}

View File

@@ -140,11 +140,11 @@ final class LocaleService
private function getConfig(string $key, string $locale): string|bool
{
if (!isset($this->languageSettings[$locale])) {
throw new \InvalidArgumentException(sprintf('Unknown locale given: %s', $locale));
throw new \InvalidArgumentException(\sprintf('Unknown locale given: %s', $locale));
}
if (!isset($this->languageSettings[$locale][$key])) {
throw new \InvalidArgumentException(sprintf('Unknown setting for locale %s: %s', $locale, $key));
throw new \InvalidArgumentException(\sprintf('Unknown setting for locale %s: %s', $locale, $key));
}
return $this->languageSettings[$locale][$key];

View File

@@ -9,6 +9,8 @@
namespace App\Configuration;
use App\Constants;
final class SystemConfiguration
{
private bool $initialized = false;
@@ -479,6 +481,9 @@ final class SystemConfiguration
return (bool) $this->find('theme.avatar_url');
}
/**
* @internal will be made private soon after 2.18.0 - do not access this method directly, but through getThemeColors()
*/
public function getThemeColorChoices(): string
{
$config = $this->find('theme.color_choices');
@@ -489,6 +494,40 @@ final class SystemConfiguration
return 'Silver|#c0c0c0';
}
/**
* @return array<string, string>
*/
public function getThemeColors(): array
{
$config = explode(',', $this->getThemeColorChoices());
$colors = [];
foreach ($config as $item) {
if (empty($item)) {
continue;
}
$item = explode('|', $item);
$key = $item[0];
$value = $key;
if (\count($item) > 1) {
$value = $item[1];
}
if (empty($key)) {
$key = $value;
}
if ($value === Constants::DEFAULT_COLOR) {
continue;
}
$colors[$key] = $value;
}
return array_unique($colors);
}
// ========== Projects ==========
public function isProjectCopyTeamsOnCreate(): bool

View File

@@ -33,6 +33,6 @@ final class ConsoleApplication extends Application
*/
public function getLongVersion(): string
{
return sprintf('%s <info>%s</info> (env: <comment>%s</>, debug: <comment>%s</>)', $this->getName(), $this->getVersion(), $this->getKernel()->getEnvironment(), $this->getKernel()->isDebug() ? 'true' : 'false');
return \sprintf('%s <info>%s</info> (env: <comment>%s</>, debug: <comment>%s</>)', $this->getName(), $this->getVersion(), $this->getKernel()->getEnvironment(), $this->getKernel()->isDebug() ? 'true' : 'false');
}
}

View File

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

View File

@@ -51,7 +51,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/admin/activity')]
final class ActivityController extends AbstractController
{
public function __construct(private ActivityRepository $repository, private SystemConfiguration $configuration, private EventDispatcherInterface $dispatcher, private ActivityService $activityService)
public function __construct(
private readonly ActivityRepository $repository,
private readonly SystemConfiguration $configuration,
private readonly EventDispatcherInterface $dispatcher,
private readonly ActivityService $activityService
)
{
}
@@ -61,6 +66,7 @@ final class ActivityController extends AbstractController
public function indexAction(int $page, Request $request): Response
{
$query = new ActivityQuery();
$query->loadTeams();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
@@ -443,7 +449,6 @@ final class ActivityController extends AbstractController
}
/**
* @param ActivityQuery $query
* @return FormInterface<ActivityQuery>
*/
private function getToolbarForm(ActivityQuery $query): FormInterface
@@ -456,19 +461,22 @@ final class ActivityController extends AbstractController
}
/**
* @param Activity $activity
* @return FormInterface<ActivityEditForm>
* @return FormInterface<mixed>
*/
private function createEditForm(Activity $activity): FormInterface
{
$currency = $this->configuration->getCustomerDefaultCurrency();
$url = $this->generateUrl('admin_activity_create');
if ($activity->getProject()?->getId() !== null) {
$url = $this->generateUrl('admin_activity_create_with_project', ['project' => $activity->getProject()->getId()]);
}
if ($activity->getId() !== null) {
$url = $this->generateUrl('admin_activity_edit', ['id' => $activity->getId()]);
if (null !== $activity->getProject()) {
$currency = $activity->getProject()->getCustomer()->getCurrency();
}
}
if (null !== $activity->getProject()) {
$currency = $activity->getProject()->getCustomer()->getCurrency();
}
return $this->createForm(ActivityEditForm::class, $activity, [

View File

@@ -47,7 +47,7 @@ final class SamlController extends AbstractController
$session->remove($authErrorKey);
}
if ($error) {
if ($error !== null) {
if (\is_object($error) && method_exists($error, 'getMessage')) {
$error = $error->getMessage();
}
@@ -60,13 +60,12 @@ final class SamlController extends AbstractController
$redirectTarget = $this->generateUrl('homepage', [], UrlGeneratorInterface::ABSOLUTE_URL);
}
$url = $this->authFactory->create()->login($redirectTarget);
$url = $this->authFactory->create()->login($redirectTarget, [], false, false, true);
if ($url === null) {
throw new \RuntimeException('SAML login failed');
}
// this line is not (yet) reached, as the previous call will exit
return $this->redirect($url);
}

View File

@@ -88,7 +88,7 @@ final class BookmarkController extends AbstractController
}
if (\count($enabled) > 50) {
throw new RuntimeException(sprintf('Too many columns provided, expected maximum 50, received %s.', \count($enabled)));
throw new RuntimeException(\sprintf('Too many columns provided, expected maximum 50, received %s.', \count($enabled)));
}
$user = $this->getUser();

View File

@@ -31,7 +31,7 @@ final class ContractController extends AbstractController
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
$canChangeUser = $this->isGranted('contract_other_profile');
$canChangeUser = $this->isGranted('hours_other_profile');
$defaultDate = $dateTimeFactory->createStartOfYear();
$now = $dateTimeFactory->createDateTime();
@@ -53,7 +53,7 @@ final class ContractController extends AbstractController
/** @var User $profile */
$profile = $values->getUser();
if ($this->getUser() !== $profile && !$canChangeUser) {
if (!$this->isGranted('hours', $profile)) {
throw $this->createAccessDeniedException('Cannot access user contract settings');
}
@@ -79,9 +79,10 @@ final class ContractController extends AbstractController
$boxConfiguration = new BoxConfiguration();
$boxConfiguration->setDecimal(false);
$boxConfiguration->setCollapsed($profile->hasWorkHourConfiguration() && $summary->count() > 0);
$boxConfiguration->setCollapsed($summary->count() > 0);
return $this->render('contract/status.html.twig', [
'withWorkHourConfiguration' => $profile->hasWorkHourConfiguration(),
'box_configuration' => $boxConfiguration,
'page_setup' => $page,
'decimal' => $boxConfiguration->isDecimal(),
@@ -90,7 +91,6 @@ final class ContractController extends AbstractController
'boxes' => $controllerEvent->getController(),
'year' => $year,
'user' => $profile,
'form' => $form->createView(),
]);
}
}

View File

@@ -54,7 +54,10 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/admin/customer')]
final class CustomerController extends AbstractController
{
public function __construct(private CustomerRepository $repository, private EventDispatcherInterface $dispatcher)
public function __construct(
private readonly CustomerRepository $repository,
private readonly EventDispatcherInterface $dispatcher
)
{
}
@@ -64,6 +67,7 @@ final class CustomerController extends AbstractController
public function indexAction(int $page, Request $request): Response
{
$query = new CustomerQuery();
$query->loadTeams();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
@@ -126,7 +130,6 @@ final class CustomerController extends AbstractController
}
/**
* @param CustomerQuery $query
* @return MetaTableTypeInterface[]
*/
private function findMetaColumns(CustomerQuery $query): array
@@ -185,13 +188,13 @@ final class CustomerController extends AbstractController
{
$customerId = $comment->getCustomer()->getId();
if (!$csrfTokenManager->isTokenValid(new CsrfToken('customer.delete_comment', $token))) {
if (!$csrfTokenManager->isTokenValid(new CsrfToken('comment.delete', $token))) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
}
$csrfTokenManager->refreshToken('customer.delete_comment');
$csrfTokenManager->refreshToken('comment.delete');
try {
$this->repository->deleteComment($comment);
@@ -228,13 +231,13 @@ final class CustomerController extends AbstractController
{
$customerId = $comment->getCustomer()->getId();
if (!$csrfTokenManager->isTokenValid(new CsrfToken('customer.pin_comment', $token))) {
if (!$csrfTokenManager->isTokenValid(new CsrfToken('comment.pin', $token))) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
}
$csrfTokenManager->refreshToken('customer.pin_comment');
$csrfTokenManager->refreshToken('comment.pin');
$comment->setPinned(!$comment->isPinned());
try {
@@ -539,7 +542,7 @@ final class CustomerController extends AbstractController
}
/**
* @return FormInterface<CustomerComment>
* @return FormInterface<mixed>
*/
private function getCommentForm(CustomerComment $comment): FormInterface
{

View File

@@ -304,12 +304,12 @@ final class DoctorController extends AbstractController
)) {
foreach ($matches as $match) {
$fn = $plainText;
if (isset($match[3])) {
if (isset($match[2]) && isset($match[3])) {
$keys1 = array_keys($phpinfo);
$phpinfo[end($keys1)][$fn($match[2])] = isset($match[4]) ? [$fn($match[3]), $fn($match[4])] : $fn($match[3]);
} else {
$keys1 = array_keys($phpinfo);
$phpinfo[end($keys1)][] = $fn($match[2]);
$phpinfo[end($keys1)][] = $fn($match[2]); // @phpstan-ignore-line
}
}
}

View File

@@ -32,7 +32,7 @@ final class FavoriteController extends AbstractController
{
$favoriteRecordService->addFavorite($timesheet);
return $this->render('favorite/index.html.twig');
return $this->redirectToRoute('favorites_timesheets');
}
#[Route(path: '/timesheet/remove/{id}', name: 'favorites_timesheets_remove', methods: ['GET'])]
@@ -41,6 +41,6 @@ final class FavoriteController extends AbstractController
{
$favoriteRecordService->removeFavorite($timesheet);
return $this->render('favorite/index.html.twig');
return $this->redirectToRoute('favorites_timesheets');
}
}

View File

@@ -319,7 +319,7 @@ final class InvoiceController extends AbstractController
if (null === $file) {
throw $this->createNotFoundException(
sprintf('Invoice file "%s" could not be found for invoice ID "%s"', $invoice->getInvoiceFilename(), $invoice->getId())
\sprintf('Invoice file "%s" could not be found for invoice ID "%s"', $invoice->getInvoiceFilename(), $invoice->getId())
);
}

View File

@@ -41,12 +41,14 @@ final class PermissionController extends AbstractController
{
public const TOKEN_NAME = 'user_role_permissions';
public function __construct(private RolePermissionManager $manager, private RoleRepository $roleRepository)
public function __construct(
private readonly RolePermissionManager $manager,
private readonly RoleRepository $roleRepository
)
{
}
#[Route(path: '', name: 'admin_user_permissions', methods: ['GET', 'POST'])]
#[IsGranted('role_permissions')]
public function permissions(EventDispatcherInterface $dispatcher, CsrfTokenManagerInterface $csrfTokenManager, RoleService $roleService, UserRepository $userRepository): Response
{
$all = $this->roleRepository->findAll();
@@ -167,7 +169,6 @@ final class PermissionController extends AbstractController
}
#[Route(path: '/roles/create', name: 'admin_user_roles', methods: ['GET', 'POST'])]
#[IsGranted('role_permissions')]
public function createRole(Request $request): Response
{
$role = new Role();
@@ -200,8 +201,7 @@ final class PermissionController extends AbstractController
]);
}
#[Route(path: '/roles/{id}/delete/{csrfToken}', name: 'admin_user_role_delete', methods: ['GET', 'POST'])]
#[IsGranted('role_permissions')]
#[Route(path: '/roles/{role}/delete/{csrfToken}', name: 'admin_user_role_delete', methods: ['GET', 'POST'])]
public function deleteRole(Role $role, string $csrfToken, UserRepository $userRepository, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) {
@@ -230,8 +230,7 @@ final class PermissionController extends AbstractController
return $this->redirectToRoute('admin_user_permissions');
}
#[Route(path: '/roles/{id}/{name}/{value}/{csrfToken}', name: 'admin_user_permission_save', methods: ['POST'])]
#[IsGranted('role_permissions')]
#[Route(path: '/roles/{role}/{name}/{value}/{csrfToken}', name: 'admin_user_permission_save', methods: ['POST'])]
public function savePermission(Role $role, string $name, bool $value, string $csrfToken, PermissionService $permissionService, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) {
@@ -243,7 +242,7 @@ final class PermissionController extends AbstractController
}
if (false === $value && $role->getName() === User::ROLE_SUPER_ADMIN && \array_key_exists($name, RolePermissionManager::SUPER_ADMIN_PERMISSIONS)) {
throw new BadRequestHttpException(sprintf('Permission "%s" cannot be deactivated for role "%s"', $name, $role->getName()));
throw new BadRequestHttpException(\sprintf('Permission "%s" cannot be deactivated for role "%s"', $name, $role->getName()));
}
try {

View File

@@ -15,6 +15,7 @@ use App\Entity\UserPreference;
use App\Event\PrepareUserEvent;
use App\Form\AccessTokenForm;
use App\Form\Model\TotpActivation;
use App\Form\Model\UserContractModel;
use App\Form\UserApiPasswordType;
use App\Form\UserContractType;
use App\Form\UserEditType;
@@ -267,7 +268,7 @@ final class ProfileController extends AbstractController
#[IsGranted('contract', 'profile')]
public function contractAction(User $profile, Request $request, UserRepository $userRepository): Response
{
$form = $this->createForm(UserContractType::class, $profile, [
$form = $this->createForm(UserContractType::class, new UserContractModel($profile), [
'action' => $this->generateUrl('user_profile_contract', ['username' => $profile->getUserIdentifier()]),
'method' => 'POST',
]);

View File

@@ -58,7 +58,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/admin/project')]
final class ProjectController extends AbstractController
{
public function __construct(private ProjectRepository $repository, private SystemConfiguration $configuration, private EventDispatcherInterface $dispatcher, private ProjectService $projectService)
public function __construct(
private readonly ProjectRepository $repository,
private readonly SystemConfiguration $configuration,
private readonly EventDispatcherInterface $dispatcher,
private readonly ProjectService $projectService
)
{
}
@@ -68,6 +73,7 @@ final class ProjectController extends AbstractController
public function indexAction(int $page, Request $request): Response
{
$query = new ProjectQuery();
$query->loadTeams();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
@@ -213,13 +219,13 @@ final class ProjectController extends AbstractController
{
$projectId = $comment->getProject()->getId();
if (!$csrfTokenManager->isTokenValid(new CsrfToken('project.delete_comment', $token))) {
if (!$csrfTokenManager->isTokenValid(new CsrfToken('comment.delete', $token))) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('project_details', ['id' => $projectId]);
}
$csrfTokenManager->refreshToken('project.delete_comment');
$csrfTokenManager->refreshToken('comment.delete');
try {
$this->repository->deleteComment($comment);
@@ -256,13 +262,13 @@ final class ProjectController extends AbstractController
{
$projectId = $comment->getProject()->getId();
if (!$csrfTokenManager->isTokenValid(new CsrfToken('project.pin_comment', $token))) {
if (!$csrfTokenManager->isTokenValid(new CsrfToken('comment.pin', $token))) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('project_details', ['id' => $projectId]);
}
$csrfTokenManager->refreshToken('project.pin_comment');
$csrfTokenManager->refreshToken('comment.pin');
$comment->setPinned(!$comment->isPinned());
try {
@@ -480,11 +486,17 @@ final class ProjectController extends AbstractController
$csrfTokenManager->refreshToken('project.duplicate');
$newProject = $projectDuplicationService->duplicate($project, $project->getName() . ' [COPY]');
try {
$newProject = $projectDuplicationService->duplicate($project, $project->getName() . ' [COPY]');
$this->flashSuccess('action.update.success');
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('project_details', ['id' => $newProject->getId()]);
} catch (\Exception $ex) {
$this->logException($ex);
$this->flashError('action.update.error', 'Failed to copy project: ' . $ex->getMessage());
}
return $this->redirectToRoute('project_details', ['id' => $newProject->getId()]);
return $this->redirectToRoute('admin_project');
}
#[Route(path: '/{id}/delete', name: 'admin_project_delete', methods: ['GET', 'POST'])]

View File

@@ -11,7 +11,9 @@ namespace App\Controller;
use App\Configuration\SystemConfiguration;
use App\Form\QuickEntryForm;
use App\Form\WeekByUserForm;
use App\Model\QuickEntryWeek;
use App\Reporting\WeekByUser\WeekByUser;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository;
use App\Timesheet\FavoriteRecordService;
@@ -25,7 +27,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Controller used to enter times in weekly form.
*/
#[Route(path: '/quick_entry')]
#[IsGranted('quick-entry')]
final class QuickEntryController extends AbstractController
{
@@ -38,18 +39,30 @@ final class QuickEntryController extends AbstractController
{
}
#[Route(path: '/{begin}', name: 'quick_entry', methods: ['GET', 'POST'])]
public function quickEntry(Request $request, ?string $begin = null): Response
#[Route(path: '/quick_entry/', name: 'quick_entry', methods: ['GET', 'POST'])]
public function quickEntry(Request $request): Response
{
$factory = $this->getDateTimeFactory();
$user = $this->getUser();
$factory = $this->getDateTimeFactory($user);
$defaultDate = $factory->createDateTime();
if ($begin !== null) {
try {
$begin = $factory->createDateTime($begin);
} catch (\Exception $ex) {
$begin = null;
}
}
$values = new WeekByUser();
$values->setUser($user);
$values->setDate($defaultDate);
$weeklyForm = $this->createFormForGetRequest(WeekByUserForm::class, $values, [
'include_user' => $this->isGranted('view_other_timesheet'),
'timezone' => $factory->getTimezone()->getName(),
'start_date' => $values->getDate(),
'attr' => ['name' => 'quick_entry_weekrange_form']
]);
$weeklyForm->submit($request->query->all(), false);
$user = $values->getUser() ?? $user;
$factory = $this->getDateTimeFactory($user);
$begin = $values->getDate();
if ($begin === null) {
$begin = $factory->createDateTime();
@@ -57,7 +70,6 @@ final class QuickEntryController extends AbstractController
$startWeek = $factory->getStartOfWeek($begin);
$endWeek = $factory->getEndOfWeek($begin);
$user = $this->getUser();
$tmpDay = clone $startWeek;
$week = [];
@@ -76,7 +88,7 @@ final class QuickEntryController extends AbstractController
$result = $this->repository->getTimesheetResult($query);
$rows = [];
foreach ($result->getResults(true) as $timesheet) {
foreach ($result->getResults() as $timesheet) {
$i = 0;
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
$day = $timesheet->getBegin()->format('Y-m-d');
@@ -106,7 +118,7 @@ final class QuickEntryController extends AbstractController
$startFrom = null;
if ($takeOverWeeks !== null && \intval($takeOverWeeks) > 0) {
$startFrom = clone $startWeek;
$startFrom->modify(sprintf('-%s weeks', $takeOverWeeks));
$startFrom->modify(\sprintf('-%s weeks', $takeOverWeeks));
}
$favorites = $this->favoriteRecordService->favoriteEntries($user, $amount);
@@ -235,7 +247,7 @@ final class QuickEntryController extends AbstractController
if ($saved) {
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('quick_entry', ['begin' => $begin->format('Y-m-d')]);
return $this->redirectToRoute('quick_entry', ['date' => $begin->format('Y-m-d'), 'user' => $user->getId()]);
}
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
@@ -244,6 +256,8 @@ final class QuickEntryController extends AbstractController
$page = new PageSetup('quick_entry.title');
$page->setHelp('weekly-times.html');
$page->setPaginationForm($weeklyForm);
$page->setActionName('weekly-times');
return $this->render('quick-entry/index.html.twig', [
'page_setup' => $page,

View File

@@ -10,21 +10,42 @@
namespace App\Controller\Reporting;
use App\Controller\AbstractController;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Project\ProjectStatisticService;
use App\Reporting\ProjectView\ProjectViewForm;
use App\Reporting\ProjectView\ProjectViewQuery;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/reporting/project_view')]
final class ProjectViewController extends AbstractController
{
#[Route(path: '/reporting/project_view', name: 'report_project_view', methods: ['GET', 'POST'])]
#[Route(path: '', name: 'report_project_view', methods: ['GET', 'POST'])]
#[IsGranted('report:project')]
#[IsGranted(new Expression("is_granted('budget_any', 'project')"))]
public function __invoke(Request $request, ProjectStatisticService $service): Response
{
$data = $this->getData($request, $service);
return $this->render('reporting/project_view.html.twig', array_merge($data, [
'report_title' => 'report_project_view',
'tableName' => 'project_view_reporting',
'export_route' => 'report_project_view_export',
]));
}
/**
* @param Request $request
* @param ProjectStatisticService $service
* @return array{'entries': array<mixed>, 'form': FormView, 'now': \DateTimeInterface}
*/
private function getData(Request $request, ProjectStatisticService $service): array
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
@@ -45,12 +66,30 @@ final class ProjectViewController extends AbstractController
$byCustomer[$customer->getId()]['projects'][] = $entry;
}
return $this->render('reporting/project_view.html.twig', [
return [
'entries' => $byCustomer,
'form' => $form->createView(),
'report_title' => 'report_project_view',
'tableName' => 'project_view_reporting',
'now' => $dateFactory->createDateTime(),
]);
];
}
#[Route(path: '/export', name: 'report_project_view_export', methods: ['GET', 'POST'])]
public function export(Request $request, ProjectStatisticService $service): Response
{
$data = $this->getData($request, $service);
// Projektübersicht inkl. dem was Projektdetails anzeigen
// Budget / Zeitbudget
// Abrechenbar
// Interner Preis
$content = $this->renderView('reporting/project_list_export.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-project-overview');
return $writer->getFileResponse($spreadsheet);
}
}

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