Compare commits

..

4 Commits
2.53.0 ... main

Author SHA1 Message Date
Weblate (bot)
d456cd3ce2 Translated using Weblate (#5892)
Co-authored-by: Lasse Edsvik <lasse@lasseedsvik.se>
Co-authored-by: Milo Ivir <mail@milotype.de>
2026-04-13 21:23:49 +02:00
Kevin Papst
bad92d7215 Release 2.54 (#5896) 2026-04-13 21:22:06 +02:00
Kevin Papst
16703081cd Exporter/Invoice formula cleanup (#5899) 2026-04-12 09:27:05 +02:00
Kevin Papst
cbdf91f316 Team API docs (#5897)
* let view_team permission be handled by global ACLs
* code style and API docs
* improve permission check
2026-04-11 17:22:59 +02:00
40 changed files with 331 additions and 206 deletions

View File

@@ -1,5 +1,10 @@
framework:
rate_limiter:
old_api_tokens:
policy: 'fixed_window'
limit: 5
interval: '1 minute'
lock_factory: null
session_prediction:
policy: 'fixed_window'
limit: 250

View File

@@ -2782,7 +2782,7 @@ parameters:
-
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, mixed given\\.$#"
count: 5
count: 4
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
-

View File

@@ -71,6 +71,7 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/timesheet/{id}/{view}/{locale}', name: 'get_timesheet_actions', requirements: ['id' => '\d+'])]
#[IsGranted('view', 'timesheet')]
public function getTimesheetActions(Timesheet $timesheet, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['timesheet' => $timesheet], 'timesheet', $view);
@@ -90,6 +91,7 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/activity/{id}/{view}/{locale}', name: 'get_activity_actions', requirements: ['id' => '\d+'])]
#[IsGranted('view', 'activity')]
public function getActivityActions(Activity $activity, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['activity' => $activity], 'activity', $view);
@@ -109,6 +111,7 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/project/{id}/{view}/{locale}', name: 'get_project_actions', requirements: ['id' => '\d+'])]
#[IsGranted('view', 'project')]
public function getProjectActions(Project $project, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['project' => $project], 'project', $view);
@@ -128,6 +131,7 @@ final class ActionsController extends BaseApiController
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
#[OA\Get(x: ['internal' => true])]
#[Route(methods: ['GET'], path: '/customer/{id}/{view}/{locale}', name: 'get_customer_actions', requirements: ['id' => '\d+'])]
#[IsGranted('view', 'customer')]
public function getCustomerActions(Customer $customer, string $view, string $locale): Response
{
$event = new PageActionsEvent($this->getUser(), ['customer' => $customer], 'customer', $view);

View File

@@ -36,8 +36,8 @@ final class ApiRequestMatcher implements RequestMatcherInterface
}
// let's use this firewall if the deprecated username & token combination is available
if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) &&
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) {
if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) && // @phpstan-ignore classConstant.deprecatedClass
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) { // @phpstan-ignore classConstant.deprecatedClass
return true;
}
// ------------------------------------------------------------------------------------

View File

@@ -14,6 +14,9 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
/**
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
*/
final class ApiTokenMigratingListener implements EventSubscriberInterface
{
public function __construct(private PasswordHasherFactoryInterface $hasherFactory)

View File

@@ -13,6 +13,9 @@ use Symfony\Component\Security\Core\Exception\LogicException;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
/**
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
*/
final class ApiTokenUpgradeBadge implements BadgeInterface
{
public function __construct(private ?string $plaintextApiToken, private readonly PasswordUpgraderInterface $passwordUpgrader)

View File

@@ -13,17 +13,24 @@ use App\Entity\User;
use App\Repository\ApiUserRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
/**
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
*/
final class TokenAuthenticator extends AbstractAuthenticator
{
public const HEADER_USERNAME = 'X-AUTH-USER';
@@ -31,7 +38,9 @@ final class TokenAuthenticator extends AbstractAuthenticator
public function __construct(
private readonly ApiUserRepository $userProvider,
private readonly PasswordHasherFactoryInterface $passwordHasherFactory
private readonly PasswordHasherFactoryInterface $passwordHasherFactory,
private readonly RateLimiterFactory $oldApiTokensLimiter,
private readonly RequestStack $requestStack,
)
{
}
@@ -44,8 +53,6 @@ final class TokenAuthenticator extends AbstractAuthenticator
}
if ($request->headers->has(self::HEADER_USERNAME) && $request->headers->has(self::HEADER_TOKEN)) {
@trigger_error('You are using deprecated API access, please upgrade your APP to use API tokens instead.', E_USER_DEPRECATED);
return true;
}
}
@@ -77,10 +84,12 @@ final class TokenAuthenticator extends AbstractAuthenticator
$checkCredentials = function (?string $presentedPassword, User $user) {
if ('' === $presentedPassword) {
$this->rateLimitInvalidLogin();
throw new BadCredentialsException('The presented password cannot be empty.');
}
if (null === $user->getApiToken()) {
$this->rateLimitInvalidLogin();
throw new BadCredentialsException('The user has no activated API account.');
}
@@ -88,11 +97,17 @@ final class TokenAuthenticator extends AbstractAuthenticator
return true;
}
$this->rateLimitInvalidLogin();
throw new BadCredentialsException('The presented password is invalid.');
};
// users should really move away from this auth endpoint
// see https://www.kimai.org/en/blog/2026/removing-api-passwords
@trigger_error('Using deprecated API passwords, upgrade your APP to use API tokens instead.', E_USER_DEPRECATED);
usleep(mt_rand(200000, 500000));
$passport = new Passport(
new UserBadge($credentials['username'], [$this->userProvider, 'loadUserByIdentifier']),
new UserBadge($credentials['username'], [$this, 'loadUserByIdentifier']),
new CustomCredentials($checkCredentials, $credentials['password'])
);
@@ -101,6 +116,30 @@ final class TokenAuthenticator extends AbstractAuthenticator
return $passport;
}
public function loadUserByIdentifier(string $identifier): ?UserInterface
{
$user = $this->userProvider->loadUserByIdentifier($identifier);
if ($user === null) {
// we could use usleep(500000); to slow down potential attacks, but using a hashing makes timing attacks more difficult
$this->passwordHasherFactory->getPasswordHasher(User::class)->verify('$2y$13$vwn35gUbbivoS75wcByBzObCNjX4vwkBihbdXQuK23HzK1R6J5WKW', uniqid());
$this->rateLimitInvalidLogin();
}
return $user;
}
private function rateLimitInvalidLogin(): void
{
$limiter = $this->oldApiTokensLimiter->create($this->requestStack->getMainRequest()?->getClientIp());
$limit = $limiter->consume();
if (false === $limit->isAccepted()) {
throw new BadRequestHttpException('Too many API requests with invalid username. Possible attack?');
}
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;

View File

@@ -71,7 +71,7 @@ final class TeamController extends BaseApiController
* Fetch team
*/
#[IsGranted('view_team')]
#[OA\Response(response: 200, description: 'Returns one team entity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))]
#[OA\Response(response: 200, description: 'Returns the team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))]
#[Route(methods: ['GET'], path: '/{id}', name: 'get_team', requirements: ['id' => '\d+'])]
public function getAction(Team $team): Response
{
@@ -84,8 +84,8 @@ final class TeamController extends BaseApiController
/**
* Delete team
*/
#[IsGranted('delete_team')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one team')])]
#[IsGranted('delete', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Empty')])]
#[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to delete', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_team', requirements: ['id' => '\d+'])]
public function deleteAction(Team $team): Response
@@ -129,7 +129,7 @@ final class TeamController extends BaseApiController
/**
* Update team
*/
#[IsGranted('edit_team')]
#[IsGranted('edit', 'team')]
#[OA\Patch(description: 'Update an existing team, you can pass all or just a subset of all attributes (passing members will replace all existing ones)', responses: [new OA\Response(response: 200, description: 'Returns the updated team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TeamEditForm'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to update', required: true)]
@@ -169,7 +169,7 @@ final class TeamController extends BaseApiController
/**
* Add team member
*/
#[IsGranted('edit_team')]
#[IsGranted('edit', 'team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new user to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team which will receive the new member', required: true)]
#[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to add (User ID)', required: true)]
@@ -193,7 +193,7 @@ final class TeamController extends BaseApiController
/**
* Remove team member
*/
#[IsGranted('edit_team')]
#[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a user from the team. The teamlead cannot be removed.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team from which the member will be removed', required: true)]
#[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to remove (User ID)', required: true)]
@@ -219,10 +219,12 @@ final class TeamController extends BaseApiController
}
/**
* Grant team access to customer
* Grant customer access
*
* The team is granted access to the customer.
*/
#[IsGranted('edit_team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new customer to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the customer', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
#[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to grant acecess to (Customer ID)', required: true)]
#[Route(methods: ['POST'], path: '/{id}/customers/{customerId}', name: 'post_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])]
@@ -242,10 +244,12 @@ final class TeamController extends BaseApiController
}
/**
* Revoke customer access from team
* Revoke customer access
*
* This removes access to the customer from the team.
*/
#[IsGranted('edit_team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a customer from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Returns the team without the customer', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
#[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to remove (Customer ID)', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}/customers/{customerId}', name: 'delete_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])]
@@ -265,10 +269,12 @@ final class TeamController extends BaseApiController
}
/**
* Grant team access to project
* Grant project access
*
* The team is granted access to the project.
*/
#[IsGranted('edit_team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new project to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the project', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
#[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to grant acecess to (Project ID)', required: true)]
#[Route(methods: ['POST'], path: '/{id}/projects/{projectId}', name: 'post_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])]
@@ -288,10 +294,12 @@ final class TeamController extends BaseApiController
}
/**
* Revoke project access from team
* Revoke project access
*
* This removes access to the project from the team.
*/
#[IsGranted('edit_team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a project from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Returns the team without the project', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
#[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to remove (Project ID)', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}/projects/{projectId}', name: 'delete_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])]
@@ -311,10 +319,12 @@ final class TeamController extends BaseApiController
}
/**
* Grant team access to activity
* Grant activity access
*
* The team is granted access to the activity.
*/
#[IsGranted('edit_team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new activity to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the activity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
#[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to grant acecess to (Activity ID)', required: true)]
#[Route(methods: ['POST'], path: '/{id}/activities/{activityId}', name: 'post_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])]
@@ -334,10 +344,12 @@ final class TeamController extends BaseApiController
}
/**
* Revoke activity access from team
* Revoke activity access
*
* This removes access to the activity from the team.
*/
#[IsGranted('edit_team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a activity from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[IsGranted('edit', 'team')]
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Returns the team without the activity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
#[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to remove (Activity ID)', required: true)]
#[Route(methods: ['DELETE'], path: '/{id}/activities/{activityId}', name: 'delete_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])]

View File

@@ -147,7 +147,6 @@ final class ResetTestCommand extends AbstractResetCommand
$userSuperAdmin->setPreferenceValue(UserPreference::HOURLY_RATE, 46);
$userSuperAdmin->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$userSuperAdmin->setTitle('Super Administrator');
$userSuperAdmin->setAvatar('/bundles/avanzuadmintheme/img/avatar.png');
$userSuperAdmin->setEnabled(true);
$userSuperAdmin->setRoles(['ROLE_SUPER_ADMIN']);
$userSuperAdmin->setUserIdentifier(UserFixtures::USERNAME_SUPER_ADMIN);

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.53.0';
public const VERSION = '2.54.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 25300;
public const VERSION_ID = 25400;
/**
* The software name
*/

View File

@@ -38,15 +38,9 @@ final class TeamController extends AbstractController
{
}
/**
* @param TeamRepository $repository
* @param Request $request
* @param int $page
* @return Response
*/
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_team', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_team_paginated', methods: ['GET'])]
public function listTeams(TeamRepository $repository, Request $request, $page): Response
public function listTeams(int $page, TeamRepository $repository, Request $request): Response
{
$query = new TeamQuery();
$query->setPage($page);
@@ -81,10 +75,6 @@ final class TeamController extends AbstractController
]);
}
/**
* @param Request $request
* @return Response
*/
#[Route(path: '/create', name: 'admin_team_create', methods: ['GET', 'POST'])]
#[IsGranted('create_team')]
public function createTeam(Request $request): Response

View File

@@ -60,6 +60,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
* Name of this activity
*/
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 150)]
#[Serializer\Expose]
@@ -117,6 +118,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\Column(name: 'invoice_text', type: Types::TEXT, nullable: true)]
private ?string $invoiceText = null;
#[ORM\Column(name: 'number', type: Types::STRING, length: 10, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 10)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]

View File

@@ -45,6 +45,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Exporter\Expose(label: 'id', type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 150)]
#[Serializer\Expose]
@@ -52,6 +53,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Exporter\Expose(label: 'name')]
private ?string $name = null;
#[ORM\Column(name: 'number', type: Types::STRING, length: 50, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 50)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]

View File

@@ -10,6 +10,7 @@
namespace App\Entity;
use App\Form\Type\YesNoType;
use App\Validator\Constraints as Constraints;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
@@ -30,6 +31,7 @@ trait MetaTableTypeTrait
* Name of the meta (custom) field
*/
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotNull]
#[Assert\Length(min: 2, max: 50)]
#[Serializer\Expose]

View File

@@ -63,6 +63,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
* Project name
*/
#[ORM\Column(name: 'name', type: Types::STRING, length: 150, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotNull]
#[Assert\Length(min: 2, max: 150)]
#[Serializer\Expose]
@@ -170,6 +171,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Serializer\Groups(['Default'])]
private bool $globalActivities = true;
#[ORM\Column(name: 'number', type: Types::STRING, length: 10, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 10)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]

View File

@@ -10,6 +10,7 @@
namespace App\Entity;
use App\Repository\TagRepository;
use App\Validator\Constraints as Constraints;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
@@ -26,7 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
class Tag
{
/**
* Internal Tag ID
* Tag ID
*/
#[ORM\Column(name: 'id', type: Types::INTEGER)]
#[ORM\Id]
@@ -35,9 +36,10 @@ class Tag
#[Serializer\Groups(['Default'])]
private ?int $id = null;
/**
* The tag name
* Tag name cannot contain the character: " < > = ,
*/
#[ORM\Column(name: 'name', type: Types::STRING, length: 100, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100, normalizer: 'trim')]
#[Assert\Regex(pattern: '/,/', message: 'Tag name cannot contain comma', match: false)]
@@ -52,10 +54,6 @@ class Tag
use ColorTrait;
public function __construct()
{
}
public function getId(): ?int
{
return $this->id;

View File

@@ -39,6 +39,7 @@ class Team
* Team name
*/
#[ORM\Column(name: 'name', type: Types::STRING, length: 100, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100)]
#[Serializer\Expose]

View File

@@ -81,7 +81,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
*/
#[ORM\Column(name: 'alias', type: Types::STRING, length: 60, nullable: true)]
#[Assert\Length(max: 60)]
#[Constraints\NoHtmlSpecialCharacters]
#[Constraints\NoSpecialCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'alias')]
@@ -97,15 +97,16 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
*/
#[ORM\Column(name: 'title', type: Types::STRING, length: 50, nullable: true)]
#[Assert\Length(max: 50)]
#[Constraints\NoHtmlSpecialCharacters]
#[Constraints\NoSpecialCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'title')]
private ?string $title = null;
/**
* URL to the user avatar, will be auto-generated if empty
* URL to the user avatar
*/
#[ORM\Column(name: 'avatar', type: Types::STRING, length: 255, nullable: true)]
#[Assert\Url]
#[Assert\Length(max: 255, groups: ['Profile'])]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
@@ -164,7 +165,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Assert\NotBlank(groups: ['Registration', 'UserCreate', 'Profile'])]
#[Assert\Regex(pattern: '/\//', match: false, groups: ['Registration', 'UserCreate', 'Profile'])]
#[Assert\Length(min: 2, max: 64, groups: ['Registration', 'UserCreate', 'Profile'])]
#[Constraints\NoHtmlSpecialCharacters]
#[Constraints\NoSpecialCharacters]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?string $username = null;
@@ -176,6 +177,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Serializer\Groups(['Default'])]
private ?string $email = null;
#[ORM\Column(name: 'account', type: Types::STRING, length: 30, nullable: true)]
#[Constraints\NoSpecialCharacters]
#[Assert\Length(max: 30)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]

View File

@@ -10,6 +10,7 @@
namespace App\Entity;
use App\Form\Type\YesNoType;
use App\Validator\Constraints as Constraints;
use App\WorkingTime\Calculator\WorkingTimeCalculatorDay;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -61,6 +62,7 @@ class UserPreference
#[Assert\NotNull]
private ?User $user = null;
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
#[Constraints\NoSpecialCharacters]
#[Assert\NotNull]
#[Assert\Length(min: 2, max: 50)]
#[Serializer\Expose]

View File

@@ -11,6 +11,7 @@ namespace App\Export\Package;
use App\Constants;
use OpenSpout\Common\Entity\Cell;
use OpenSpout\Common\Entity\Cell\StringCell;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Common\Entity\Style\Border;
use OpenSpout\Common\Entity\Style\BorderPart;
@@ -98,7 +99,8 @@ class SpoutSpreadsheet implements SpreadsheetPackage
$style->setShouldWrapText(false);
$style->setShouldShrinkToFit(true);
if (\array_key_exists('totals', $options) && $options['totals'] === true) {
$isTotalsRow = \array_key_exists('totals', $options) && $options['totals'] === true;
if ($isTotalsRow) {
if ($this->writer instanceof CSVWriter) {
return;
}
@@ -109,7 +111,11 @@ class SpoutSpreadsheet implements SpreadsheetPackage
$tmp = [];
$i = 0;
foreach ($columns as $column) {
$tmp[] = Cell::fromValue($column, $this->styles[$i++]); // @phpstan-ignore argument.type
if (!$isTotalsRow && \is_string($column)) {
$tmp[] = new StringCell($column, $style);
} else {
$tmp[] = Cell::fromValue($column, $this->styles[$i++]); // @phpstan-ignore argument.type
}
}
$this->writer->addRow(new Row($tmp, $style));

View File

@@ -67,10 +67,6 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
continue;
}
$replacer = null;
$firstReplacerPos = stripos($value, '${');
if ($firstReplacerPos === false) {
continue;
}
if (stripos($value, '${entry.') !== false) {
if ($sheetValues === false && isset($entries[$entryRow])) {
@@ -94,13 +90,14 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
if (stripos($value, $searchKey) === false) {
continue;
}
if (\is_string($content) && str_starts_with($content, '=')) {
// we ONLY check if the given replacer content contains a formula character
if (\is_string($content) && \in_array($content[0], ['=', '-', '+', '@', "\t", "\r"])) {
$contentLooksLikeFormula = true;
}
$value = str_replace($searchKey, $content ?? '', $value);
}
if ($contentLooksLikeFormula && $firstReplacerPos === 0) {
if ($contentLooksLikeFormula) {
// see https://github.com/kimai/kimai/pull/2054
$cell->setValueExplicit($value, DataType::TYPE_STRING);
} else {

View File

@@ -11,10 +11,14 @@ namespace App\Repository;
use App\Entity\User;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @deprecated since 2.54 - see https://www.kimai.org/en/blog/2026/removing-api-passwords
*/
class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterface
{
public function __construct(private readonly UserRepository $userRepository)
@@ -23,7 +27,11 @@ class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterfac
public function loadUserByIdentifier(string $identifier): ?UserInterface
{
return $this->userRepository->loadUserByIdentifier($identifier);
try {
return $this->userRepository->loadUserByIdentifier($identifier);
} catch (UserNotFoundException $ex) {
return null;
}
}
public function upgradePassword(PasswordAuthenticatedUserInterface|UserInterface $user, string $newHashedPassword): void

View File

@@ -29,20 +29,30 @@ final class ContractExtensions extends AbstractExtension
return [
/* @var array{user: User, date: \DateTimeInterface} $values */
new TwigTest('work_day', function (array $values): bool {
$user = $values['user'];
if ($user->getId() === null) {
return false;
// TODO remove me in 3.0, deprecate with 2.55
if (!\array_key_exists('user', $values) || !\array_key_exists('date', $values)) {
throw new \Exception('Missing variable "user" or "date" to check for "is work_day');
}
$id = 'user_' . $user->getId();
if (!\array_key_exists($id, $this->calculators)) {
$this->calculators[$id] = $this->workingTimeService->getContractMode($user)->getCalculator($user);
}
$date = $values['date'];
return $this->calculators[$id]->isWorkDay($date);
return $this->isWorkingDay($values['date'], $values['user']);
}),
new TwigTest('working_day', function (\DateTimeInterface $date, User $user): bool {
return $this->isWorkingDay($date, $user);
}),
];
}
private function isWorkingDay(\DateTimeInterface $date, User $user): bool
{
if ($user->getId() === null) {
return false;
}
$id = 'user_' . $user->getId();
if (!\array_key_exists($id, $this->calculators)) {
$this->calculators[$id] = $this->workingTimeService->getContractMode($user)->getCalculator($user);
}
return $this->calculators[$id]->isWorkDay($date);
}
}

View File

@@ -1,40 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class NoHtmlSpecialCharactersValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof NoHtmlSpecialCharacters)) {
throw new UnexpectedTypeException($constraint, NoHtmlSpecialCharacters::class);
}
if (!\is_string($value)) {
return;
}
if (str_contains($value, '<')
|| str_contains($value, '>')
|| str_contains($value, '"')
// there are many family names that use the ' (like O'Hara), so we cannot forbid them
) {
$this->context->buildViolation(NoHtmlSpecialCharacters::getErrorName(NoHtmlSpecialCharacters::SPECIAL_CHARACTERS_FOUND))
->setTranslationDomain('validators')
->setParameter('{{ chars }}', '< " >')
->setCode(NoHtmlSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
->addViolation();
}
}
}

View File

@@ -12,7 +12,7 @@ namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class NoHtmlSpecialCharacters extends Constraint
final class NoSpecialCharacters extends Constraint
{
public const SPECIAL_CHARACTERS_FOUND = 'kimai-html-character-001';
@@ -20,8 +20,32 @@ final class NoHtmlSpecialCharacters extends Constraint
self::SPECIAL_CHARACTERS_FOUND => 'These characters are not allowed: {{ chars }}',
];
/** @var string[] */
public array $characters = [
'<', // XSS
'>', // XSS
'"', // XSS
'=', // DDE
];
public string $message = 'These characters are not allowed: {{ chars }}';
/**
* @param string[]|null $character
*/
public function __construct(
mixed $options = null,
?string $message = null,
?array $character = null,
?array $groups = null,
mixed $payload = null
)
{
parent::__construct($options, $groups, $payload);
$this->message = $message ?? $this->message;
$this->characters = $character ?? $this->characters;
}
public function getTargets(): string
{
return self::PROPERTY_CONSTRAINT;

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class NoSpecialCharactersValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof NoSpecialCharacters)) {
throw new UnexpectedTypeException($constraint, NoSpecialCharacters::class);
}
if (!\is_string($value) || $value === '') {
return;
}
$found = [];
foreach ($constraint->characters as $character) {
if (str_contains($value, $character)) {
$found[] = $character;
}
}
if (\count($found) > 0) {
$this->context->buildViolation(NoSpecialCharacters::getErrorName(NoSpecialCharacters::SPECIAL_CHARACTERS_FOUND))
->setTranslationDomain('validators')
->setParameter('{{ chars }}', implode(' ', $constraint->characters))
->setCode(NoSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
->addViolation();
}
}
}

View File

@@ -24,7 +24,6 @@ final class TeamVoter extends Voter
* support rules based on the given $subject (here: Team)
*/
private const ALLOWED_ATTRIBUTES = [
'view',
'edit',
'delete',
];
@@ -52,17 +51,13 @@ final class TeamVoter extends Voter
{
$user = $token->getUser();
if (!$user instanceof User) {
if (!$user instanceof User || !($subject instanceof Team)) {
return false;
}
switch ($attribute) {
case 'edit':
case 'delete':
// changing existing teams should be limited to admins and teamleads
if (!$user->isAdmin() && !$user->isSuperAdmin() && !$user->isTeamleadOf($subject)) {
return false;
}
// changing existing teams should be limited to admins and teamleads
if (!$user->isAdmin() && !$user->isSuperAdmin() && !$user->isTeamleadOf($subject)) {
return false;
}
return $this->permissionManager->hasRolePermission($user, $attribute . '_team');

View File

@@ -2,7 +2,7 @@
<html lang="{{ invoice['invoice.language'] }}">
<head>
<meta charset="utf-8">
<title>{% block title %}{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}{% endblock %}</title>
<title>{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}</title>
<style>
{{ encore_entry_css_source('invoice-pdf')|raw }}
</style>

View File

@@ -2,7 +2,7 @@
<html lang="{{ invoice['invoice.language'] }}">
<head>
<meta charset="utf-8">
<title>{% block title %}{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}{% endblock %}</title>
<title>{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}</title>
<style>
{{ encore_entry_css_source('invoice')|raw }}
</style>

View File

@@ -2,7 +2,7 @@
<html lang="{{ invoice['invoice.language'] }}">
<head>
<meta charset="utf-8">
<title>{% block title %}{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}{% endblock %}</title>
<title>{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}</title>
<style>
{{ encore_entry_css_source('invoice-pdf')|raw }}
</style>

View File

@@ -19,6 +19,7 @@
{% block form_body %}
{{ form_start(form) }}
<div class="card-body {% block form_body_class %}{% endblock %}">
{{ form_errors(form) }}
{% block form_pre_content %}{% endblock %}
{% block form_content %}
{{ form_widget(form) }}

View File

@@ -10,6 +10,7 @@
{% form_theme form 'form/horizontal.html.twig' %}
{{ form_errors(form) }}
{{ form_row(form._token) }}
<fieldset class="form-fieldset form-fieldset-light">

View File

@@ -16,8 +16,11 @@ use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
@@ -35,7 +38,12 @@ class TokenAuthenticatorTest extends TestCase
$passwordHasher->method('verify')->willReturn($verify);
$passwordHasherFactory->method('getPasswordHasher')->willReturn($passwordHasher);
return new TokenAuthenticator($userProvider, $passwordHasherFactory);
return new TokenAuthenticator(
$userProvider,
$passwordHasherFactory,
new RateLimiterFactory(['id' => 'foo', 'policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 minute'], new InMemoryStorage()),
new RequestStack(),
);
}
public function testSupports(): void

View File

@@ -29,7 +29,7 @@ class ContractExtensionsTest extends TestCase
public function testDefinedMethods(): void
{
self::assertCount(1, $this->getSut()->getTests());
self::assertCount(2, $this->getSut()->getTests());
self::assertCount(0, $this->getSut()->getFilters());
self::assertCount(0, $this->getSut()->getFunctions());
}
@@ -38,6 +38,7 @@ class ContractExtensionsTest extends TestCase
{
$filters = [
'work_day',
'working_day',
];
$i = 0;

View File

@@ -9,8 +9,8 @@
namespace App\Tests\Validator\Constraints;
use App\Validator\Constraints\NoHtmlSpecialCharacters;
use App\Validator\Constraints\NoHtmlSpecialCharactersValidator;
use App\Validator\Constraints\NoSpecialCharacters;
use App\Validator\Constraints\NoSpecialCharactersValidator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Validator\Constraints\NotBlank;
@@ -18,15 +18,15 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @extends ConstraintValidatorTestCase<NoHtmlSpecialCharactersValidator>
* @extends ConstraintValidatorTestCase<NoSpecialCharactersValidator>
*/
#[CoversClass(NoHtmlSpecialCharacters::class)]
#[CoversClass(NoHtmlSpecialCharactersValidator::class)]
class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
#[CoversClass(NoSpecialCharacters::class)]
#[CoversClass(NoSpecialCharactersValidator::class)]
class NoSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): NoHtmlSpecialCharactersValidator
protected function createValidator(): NoSpecialCharactersValidator
{
return new NoHtmlSpecialCharactersValidator();
return new NoSpecialCharactersValidator();
}
public function testConstraintIsInvalid(): void
@@ -38,7 +38,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
public function testGetTargets(): void
{
$constraint = new NoHtmlSpecialCharacters();
$constraint = new NoSpecialCharacters();
self::assertEquals('property', $constraint->getTargets());
}
@@ -47,7 +47,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
return [
[''],
[null],
['asdf-.,123!§$%&/()=?`4567\'890ß'],
['asdf-.,123!§$%&/()?`4567\'890ß'],
];
}
@@ -57,7 +57,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
$this->validator = $this->createValidator();
$this->validator->initialize($this->context);
$this->validator->validate($data, new NoHtmlSpecialCharacters());
$this->validator->validate($data, new NoSpecialCharacters());
$this->assertNoViolation();
}
@@ -68,6 +68,7 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
['Test" onclick="alert(1)"'],
['Test><a href=#>Foo</a>'],
['Test" broken string'],
['I am not = allowed'],
];
}
@@ -77,11 +78,11 @@ class NoHtmlSpecialCharactersValidatorTest extends ConstraintValidatorTestCase
$this->validator = $this->createValidator();
$this->validator->initialize($this->context);
$this->validator->validate($data, new NoHtmlSpecialCharacters());
$this->validator->validate($data, new NoSpecialCharacters());
$this->buildViolation('These characters are not allowed: {{ chars }}')
->setCode(NoHtmlSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
->setParameter('{{ chars }}', '< " >')
->setCode(NoSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
->setParameter('{{ chars }}', '< > " =')
->assertRaised();
}
}

View File

@@ -21,7 +21,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
class TeamVoterTest extends AbstractVoterTestCase
{
#[DataProvider('getTestData')]
public function testVote(User $user, $subject, $attribute, $result): void
public function testVote(User $user, mixed $subject, string $attribute, int $result): void
{
$token = new UsernamePasswordToken($user, 'bar', $user->getRoles());
$sut = $this->getVoter(TeamVoter::class);
@@ -29,7 +29,7 @@ class TeamVoterTest extends AbstractVoterTestCase
self::assertEquals($result, $sut->vote($token, $subject, [$attribute]));
}
public static function getTestData()
public static function getTestData(): iterable
{
$user0 = self::getUser(0, null);
$user1 = self::getUser(1, User::ROLE_USER);
@@ -39,42 +39,42 @@ class TeamVoterTest extends AbstractVoterTestCase
$team = new Team('foo');
$result = VoterInterface::ACCESS_ABSTAIN;
$abstain = VoterInterface::ACCESS_ABSTAIN;
$allTeamPerms = ['view_team', 'create_team', 'edit_team', 'delete_team'];
foreach ($allTeamPerms as $fullPerm) {
yield [$user0, [], $fullPerm, $result];
yield [$user0, new \stdClass(), $fullPerm, $result];
yield [$user0, $team, $fullPerm, $result];
yield [$user1, $team, $fullPerm, $result];
yield [$user2, $team, $fullPerm, $result];
yield [$user3, $team, $fullPerm, $result];
yield [$user4, $team, $fullPerm, $result];
yield [$user0, [], $fullPerm, $abstain];
yield [$user0, new \stdClass(), $fullPerm, $abstain];
yield [$user0, $team, $fullPerm, $abstain];
yield [$user1, $team, $fullPerm, $abstain];
yield [$user2, $team, $fullPerm, $abstain];
yield [$user3, $team, $fullPerm, $abstain];
yield [$user4, $team, $fullPerm, $abstain];
}
$result = VoterInterface::ACCESS_DENIED;
$denied = VoterInterface::ACCESS_DENIED;
yield [$user0, $team, 'view', $result];
yield [$user0, $team, 'edit', $result];
yield [$user0, $team, 'delete', $result];
yield [$user0, $team, 'view', $abstain];
yield [$user0, $team, 'edit', $denied];
yield [$user0, $team, 'delete', $denied];
yield [$user1, $team, 'view', $result];
yield [$user1, $team, 'edit', $result];
yield [$user1, $team, 'delete', $result];
yield [$user1, $team, 'view', $abstain];
yield [$user1, $team, 'edit', $denied];
yield [$user1, $team, 'delete', $denied];
yield [$user2, $team, 'view', $result];
yield [$user2, $team, 'edit', $result];
yield [$user2, $team, 'delete', $result];
yield [$user2, $team, 'view', $abstain];
yield [$user2, $team, 'edit', $denied];
yield [$user2, $team, 'delete', $denied];
$result = VoterInterface::ACCESS_GRANTED;
$granted = VoterInterface::ACCESS_GRANTED;
yield [$user3, $team, 'view', $result];
yield [$user3, $team, 'edit', $result];
yield [$user3, $team, 'delete', $result];
yield [$user3, $team, 'view', $abstain];
yield [$user3, $team, 'edit', $granted];
yield [$user3, $team, 'delete', $granted];
yield [$user4, $team, 'view', $result];
yield [$user4, $team, 'edit', $result];
yield [$user4, $team, 'delete', $result];
yield [$user4, $team, 'view', $abstain];
yield [$user4, $team, 'edit', $granted];
yield [$user4, $team, 'delete', $granted];
}
}

View File

@@ -27,6 +27,26 @@ parameters:
-
message: "#^PHPDoc tag @var with type App\\\\(.*) is not subtype of native type PHPUnit\\\\Framework\\\\MockObject\\\\MockObject\\.$#"
-
identifier: classConstant.deprecatedClass
count: 2
path: API/Authentication/TokenAuthenticatorTest.php
-
identifier: new.deprecatedClass
count: 1
path: API/Authentication/TokenAuthenticatorTest.php
-
identifier: return.deprecatedClass
count: 1
path: API/Authentication/TokenAuthenticatorTest.php
-
identifier: method.deprecatedClass
count: 13
path: API/Authentication/TokenAuthenticatorTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\APIControllerBaseTestCase\\:\\:assertApiException\\(\\) has parameter \\$expectedErrors with no value type specified in iterable type array\\.$#"
count: 1
@@ -2381,26 +2401,6 @@ parameters:
count: 1
path: Voter/RolePermissionVoterTest.php
-
message: "#^Method App\\\\Tests\\\\Voter\\\\TeamVoterTest\\:\\:getTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Voter/TeamVoterTest.php
-
message: "#^Method App\\\\Tests\\\\Voter\\\\TeamVoterTest\\:\\:testVote\\(\\) has parameter \\$attribute with no type specified\\.$#"
count: 1
path: Voter/TeamVoterTest.php
-
message: "#^Method App\\\\Tests\\\\Voter\\\\TeamVoterTest\\:\\:testVote\\(\\) has parameter \\$result with no type specified\\.$#"
count: 1
path: Voter/TeamVoterTest.php
-
message: "#^Method App\\\\Tests\\\\Voter\\\\TeamVoterTest\\:\\:testVote\\(\\) has parameter \\$subject with no type specified\\.$#"
count: 1
path: Voter/TeamVoterTest.php
-
message: "#^Method App\\\\Tests\\\\Voter\\\\TimesheetVoterTest\\:\\:assertVote\\(\\) has parameter \\$attribute with no type specified\\.$#"
count: 1

View File

@@ -522,9 +522,9 @@
<source>company</source>
<target>Ime poduzeća</target>
</trans-unit>
<trans-unit id="fFErSLq" resname="vat">
<trans-unit id="fFErSLq" resname="vat" xml:space="preserve">
<source>vat</source>
<target>PDV</target>
<target state="translated">Porez</target>
</trans-unit>
<trans-unit id="EJDS9HY" resname="vat_id">
<source>vat_id</source>
@@ -836,11 +836,11 @@
</trans-unit>
<trans-unit id="86KyrKO" resname="invoice.subtotal" xml:space="preserve">
<source>invoice.subtotal</source>
<target state="translated">Iznos bez PDV- a</target>
<target state="translated">Iznos bez poreza</target>
</trans-unit>
<trans-unit id="EO57yXg" resname="invoice.tax">
<trans-unit id="EO57yXg" resname="invoice.tax" xml:space="preserve">
<source>invoice.tax</source>
<target>PDV</target>
<target state="translated">Porez</target>
</trans-unit>
<trans-unit id="N68vyvo" resname="invoice.total">
<source>invoice.total</source>
@@ -1314,9 +1314,9 @@
<source>help.globalActivity</source>
<target state="translated">Ako ne odabereš projekt, ova aktivnost postaje globalna i može se kombinirati s bilo kojim projektom. Ako odabereš projekt, aktivnost se može koristiti samo s tim projektom. Postavka se kasnije ne može promijeniti.</target>
</trans-unit>
<trans-unit id="GxuKjNb" resname="help.invoiceTemplate_customer">
<trans-unit id="GxuKjNb" resname="help.invoiceTemplate_customer" xml:space="preserve">
<source>help.invoiceTemplate_customer</source>
<target state="translated">Računi za ovog klijenta se standardno generiraju pomoću ovog predloška. Po potrebi se može promijeniti tijekom generiranja računa.</target>
<target state="translated">Računi za ovu mušteriju se standardno generiraju pomoću ovog predloška. Po potrebi se može promijeniti tijekom generiranja računa.</target>
</trans-unit>
<trans-unit id="Fkrev.q" resname="help.globalActivities">
<source>help.globalActivities</source>
@@ -1482,9 +1482,9 @@
<source>completed_month_pdf</source>
<target state="translated">PDF završenog mjeseca</target>
</trans-unit>
<trans-unit id="s9NPI3v" resname="work_times_should.none_configured">
<trans-unit id="s9NPI3v" resname="work_times_should.none_configured" xml:space="preserve">
<source>work_times_should.none_configured</source>
<target state="translated">U postavkama ugovora o radu nisu konfigurirani ciljani sati za ovog korisnika.</target>
<target state="translated">U postavkama radnog ugovora nisu konfigurirani ciljani sati za ovog korisnika.</target>
</trans-unit>
<trans-unit id="t1r372x" resname="invisible">
<source>invisible</source>
@@ -1736,7 +1736,7 @@
</trans-unit>
<trans-unit id="M_WE1pa" resname="booking_allow_only_work_days" xml:space="preserve">
<source>booking_allow_only_work_days</source>
<target state="translated">Dozvoli unose vremena samo za dane za koje je predviđeno radno vrijeme definirano ugovorom o radu</target>
<target state="translated">Dozvoli unose vremena samo za dane za koje je predviđeno radno vrijeme definirano radnim ugovorom</target>
</trans-unit>
<trans-unit id="dOIWgOr" resname="approve" xml:space="preserve">
<source>approve</source>
@@ -1868,7 +1868,7 @@
</trans-unit>
<trans-unit id="2qbRs28" resname="hours_per_day" xml:space="preserve">
<source>hours_per_day</source>
<target state="translated">Sata na dan</target>
<target state="translated">Sati na dan</target>
</trans-unit>
<trans-unit id="2pbkbn_" resname="work_hours_mode" xml:space="preserve">
<source>work_hours_mode</source>
@@ -1884,7 +1884,7 @@
</trans-unit>
<trans-unit id="y7KtuSS" resname="hours_per_week" xml:space="preserve">
<source>hours_per_week</source>
<target state="translated">Sata na tjedan</target>
<target state="translated">Sati na tjedan</target>
</trans-unit>
<trans-unit id="CE6ZCkm" resname="reject" xml:space="preserve">
<source>reject</source>
@@ -1908,7 +1908,7 @@
</trans-unit>
<trans-unit id="07e8ywS" resname="hours_per_month" xml:space="preserve">
<source>hours_per_month</source>
<target state="translated">Sata na mjesec</target>
<target state="translated">Sati na mjesec</target>
</trans-unit>
<trans-unit id="0jvo0RQ" resname="profit" xml:space="preserve">
<source>profit</source>

View File

@@ -298,9 +298,9 @@
<source>profile.roles</source>
<target state="translated">Roller</target>
</trans-unit>
<trans-unit id="MQKiG33" resname="profile.preferences">
<trans-unit id="MQKiG33" resname="profile.preferences" xml:space="preserve">
<source>profile.preferences</source>
<target state="translated">Inställningar</target>
<target state="translated">Preferenser</target>
</trans-unit>
<trans-unit id="j4Kd9N1" resname="calendar_initial_view">
<source>calendar_initial_view</source>

View File

@@ -166,6 +166,10 @@
<source>Selected period cannot be locked: unconfirmed absence requests are pending.</source>
<target state="translated">Odabrano razdoblje se ne može zaključati: nepotvrđeni zahtjevi za izostanak su u tijeku.</target>
</trans-unit>
<trans-unit id="EDEDQtZ" resname="These characters are not allowed: {{ chars }}" xml:space="preserve">
<source>These characters are not allowed: {{ chars }}</source>
<target state="translated">Sljedeći znakovi nisu dopušteni: {{ chars }}</target>
</trans-unit>
</body>
</file>
</xliff>