Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d456cd3ce2 | ||
|
|
bad92d7215 | ||
|
|
16703081cd | ||
|
|
cbdf91f316 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
-
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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+'])]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
43
src/Validator/Constraints/NoSpecialCharactersValidator.php
Normal file
43
src/Validator/Constraints/NoSpecialCharactersValidator.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user