Release 2.26 (#5189)

* bring back deprecated methods
* bump packages
* fix SAML redirect
* config flag for break times
* use class constant instead of string in attributes
* throw if all tags were not found - fixes #4792
This commit is contained in:
Kevin Papst
2024-12-05 10:42:07 +01:00
committed by GitHub
parent 70741eebfd
commit 82a3b99a31
39 changed files with 437 additions and 397 deletions

View File

@@ -37,6 +37,7 @@ use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints;
@@ -181,8 +182,11 @@ final class TimesheetController extends BaseApiController
/** @var array<string> $tags */
$tags = $paramFetcher->get('tags');
if (\is_array($tags) && \count($tags) > 0) {
$tags = $this->tagRepository->findTagsByName($tags, true);
foreach ($tags as $tag) {
$tagsByName = $this->tagRepository->findTagsByName($tags, true);
if (\count($tagsByName) === 0) {
throw new BadRequestHttpException('Given tags were not found');
}
foreach ($tagsByName as $tag) {
$query->addTag($tag);
}
}

View File

@@ -26,7 +26,7 @@ use Symfony\Component\Intl\Locales;
*
* @codeCoverageIgnore
*/
#[AsCommand(name: 'kimai:reset:locales')]
#[AsCommand(name: 'kimai:reset:locales', description: 'Regenerate the locale definition file')]
final class RegenerateLocalesCommand extends Command
{
/**
@@ -66,11 +66,6 @@ final class RegenerateLocalesCommand extends Command
return $this->kernelEnvironment !== 'prod';
}
protected function configure(): void
{
$this->setDescription('Regenerate the locale definition file');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.25.0';
public const VERSION = '2.26.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 22500;
public const VERSION_ID = 22600;
/**
* The software name
*/

View File

@@ -12,18 +12,24 @@ namespace App\Controller\Auth;
use App\Configuration\SamlConfigurationInterface;
use App\Saml\SamlAuthFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
#[Route(path: '/saml')]
final class SamlController extends AbstractController
{
use TargetPathTrait;
public function __construct(
private readonly SamlAuthFactory $authFactory,
private readonly SamlConfigurationInterface $samlConfiguration
private readonly SamlConfigurationInterface $samlConfiguration,
private readonly Security $security,
)
{
}
@@ -54,8 +60,12 @@ final class SamlController extends AbstractController
throw new \RuntimeException($error);
}
// this does set headers and exit as $stay is not set to true
$redirectTarget = $session->get('_security.main.target_path');
$firewallName = $this->security->getFirewallConfig($request)?->getName();
if ($firewallName === null || $firewallName === '') {
throw new ServiceUnavailableHttpException(message: 'Unknown firewall.');
}
$redirectTarget = $this->getTargetPath($session, $firewallName);
if ($redirectTarget === null || $redirectTarget === '') {
$redirectTarget = $this->generateUrl('homepage', [], UrlGeneratorInterface::ABSOLUTE_URL);
}

View File

@@ -253,7 +253,6 @@ final class DoctorController extends AbstractController
'max_execution_time',
'date.timezone',
'allow_url_fopen',
'allow_url_include',
'default_charset',
'default_mimetype',
'display_errors',

View File

@@ -331,6 +331,9 @@ final class Configuration implements ConfigurationInterface
->booleanNode('require_activity')
->defaultTrue()
->end()
->booleanNode('break_time_active')
->defaultFalse()
->end()
->end()
->end()
->end()

View File

@@ -9,12 +9,13 @@
namespace App\Entity;
use App\Repository\AccessTokenRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_access_token')]
#[ORM\Entity(repositoryClass: 'App\Repository\AccessTokenRepository')]
#[ORM\Entity(repositoryClass: AccessTokenRepository::class)]
#[ORM\UniqueConstraint(columns: ['token'])]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(fields: ['token'])]

View File

@@ -12,6 +12,7 @@ namespace App\Entity;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\CreatedTrait;
use App\Export\Annotation as Exporter;
use App\Repository\ActivityRepository;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -24,7 +25,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Index(columns: ['visible', 'project_id'])]
#[ORM\Index(columns: ['visible', 'project_id', 'name'])]
#[ORM\Index(columns: ['visible', 'name'])]
#[ORM\Entity(repositoryClass: 'App\Repository\ActivityRepository')]
#[ORM\Entity(repositoryClass: ActivityRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
#[Serializer\VirtualProperty('ProjectName', exp: 'object.getProject() === null ? null : object.getProject().getName()', options: [new Serializer\SerializedName('parentTitle'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Activity'])])]

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Repository\ActivityRateRepository;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -16,7 +17,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_activities_rates')]
#[ORM\UniqueConstraint(columns: ['user_id', 'activity_id'])]
#[ORM\Entity(repositoryClass: 'App\Repository\ActivityRateRepository')]
#[ORM\Entity(repositoryClass: ActivityRateRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(['user', 'activity'], ignoreNull: false)]
#[Serializer\ExclusionPolicy('all')]

View File

@@ -9,13 +9,14 @@
namespace App\Entity;
use App\Repository\BookmarkRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_bookmarks')]
#[ORM\UniqueConstraint(columns: ['user_id', 'name'])]
#[ORM\Entity(repositoryClass: 'App\Repository\BookmarkRepository')]
#[ORM\Entity(repositoryClass: BookmarkRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(fields: ['user', 'name'])]
class Bookmark

View File

@@ -9,13 +9,14 @@
namespace App\Entity;
use App\Repository\ConfigurationRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_configuration')]
#[ORM\UniqueConstraint(columns: ['name'])]
#[ORM\Entity(repositoryClass: 'App\Repository\ConfigurationRepository')]
#[ORM\Entity(repositoryClass: ConfigurationRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
class Configuration

View File

@@ -12,6 +12,7 @@ namespace App\Entity;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\CreatedTrait;
use App\Export\Annotation as Exporter;
use App\Repository\CustomerRepository;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -22,7 +23,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_customers')]
#[ORM\Index(columns: ['visible'])]
#[ORM\Entity(repositoryClass: 'App\Repository\CustomerRepository')]
#[ORM\Entity(repositoryClass: CustomerRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Repository\CustomerRateRepository;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -16,7 +17,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_customers_rates')]
#[ORM\UniqueConstraint(columns: ['user_id', 'customer_id'])]
#[ORM\Entity(repositoryClass: 'App\Repository\CustomerRateRepository')]
#[ORM\Entity(repositoryClass: CustomerRateRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(['user', 'customer'], ignoreNull: false)]
#[Serializer\ExclusionPolicy('all')]

View File

@@ -11,17 +11,19 @@ namespace App\Entity;
use App\Export\Annotation as Exporter;
use App\Invoice\InvoiceModel;
use App\Repository\InvoiceRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use OpenApi\Attributes as OA;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_invoices')]
#[ORM\UniqueConstraint(columns: ['invoice_number'])]
#[ORM\UniqueConstraint(columns: ['invoice_filename'])]
#[ORM\Entity(repositoryClass: 'App\Repository\InvoiceRepository')]
#[ORM\Entity(repositoryClass: InvoiceRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('invoiceNumber')]
#[UniqueEntity('invoiceFilename')]
@@ -65,12 +67,14 @@ class Invoice implements EntityWithMetaFields
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[OA\Property(ref: '#/components/schemas/Customer')]
private ?Customer $customer = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[OA\Property(ref: '#/components/schemas/User')]
private ?User $user = null;
#[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)]
#[Assert\NotNull]

View File

@@ -9,13 +9,14 @@
namespace App\Entity;
use App\Repository\InvoiceTemplateRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_invoice_templates')]
#[ORM\UniqueConstraint(columns: ['name'])]
#[ORM\Entity(repositoryClass: 'App\Repository\InvoiceTemplateRepository')]
#[ORM\Entity(repositoryClass: InvoiceTemplateRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
class InvoiceTemplate

View File

@@ -12,6 +12,7 @@ namespace App\Entity;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\CreatedTrait;
use App\Export\Annotation as Exporter;
use App\Repository\ProjectRepository;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -23,7 +24,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_projects')]
#[ORM\Index(columns: ['customer_id', 'visible', 'name'])]
#[ORM\Index(columns: ['customer_id', 'visible', 'id'])]
#[ORM\Entity(repositoryClass: 'App\Repository\ProjectRepository')]
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
#[Serializer\VirtualProperty('CustomerName', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()', options: [new Serializer\SerializedName('parentTitle'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Project'])])]

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Repository\ProjectRateRepository;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -16,7 +17,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_projects_rates')]
#[ORM\UniqueConstraint(columns: ['user_id', 'project_id'])]
#[ORM\Entity(repositoryClass: 'App\Repository\ProjectRateRepository')]
#[ORM\Entity(repositoryClass: ProjectRateRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(['user', 'project'], ignoreNull: false)]
#[Serializer\ExclusionPolicy('all')]

View File

@@ -9,13 +9,14 @@
namespace App\Entity;
use App\Repository\RoleRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_roles')]
#[ORM\UniqueConstraint(name: 'roles_name', columns: ['name'])]
#[ORM\Entity(repositoryClass: 'App\Repository\RoleRepository')]
#[ORM\Entity(repositoryClass: RoleRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
class Role

View File

@@ -9,13 +9,14 @@
namespace App\Entity;
use App\Repository\RolePermissionRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_roles_permissions')]
#[ORM\UniqueConstraint(name: 'role_permission', columns: ['role_id', 'permission'])]
#[ORM\Entity(repositoryClass: 'App\Repository\RolePermissionRepository')]
#[ORM\Entity(repositoryClass: RolePermissionRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(['role', 'permission'])]
class RolePermission

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Repository\TagRepository;
use App\Utils\Color;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
@@ -17,7 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_tags')]
#[ORM\UniqueConstraint(columns: ['name'])]
#[ORM\Entity(repositoryClass: 'App\Repository\TagRepository')]
#[ORM\Entity(repositoryClass: TagRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
#[Serializer\ExclusionPolicy('all')]

View File

@@ -11,6 +11,7 @@ namespace App\Entity;
use App\Doctrine\Behavior\ModifiedAt;
use App\Doctrine\Behavior\ModifiedTrait;
use App\Repository\TimesheetRepository;
use App\Validator\Constraints as Constraints;
use DateTime;
use DateTimeZone;
@@ -37,7 +38,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Index(columns: ['end_time', 'user', 'start_time'], name: 'IDX_TIMESHEET_TICKTAC')]
#[ORM\Index(columns: ['user', 'project_id', 'activity_id'], name: 'IDX_TIMESHEET_RECENT_ACTIVITIES')]
#[ORM\Index(columns: ['user', 'id', 'duration'], name: 'IDX_TIMESHEET_RESULT_STATS')]
#[ORM\Entity(repositoryClass: 'App\Repository\TimesheetRepository')]
#[ORM\Entity(repositoryClass: TimesheetRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[ORM\HasLifecycleCallbacks]
#[Serializer\ExclusionPolicy('all')]

View File

@@ -10,6 +10,7 @@
namespace App\Entity;
use App\Export\Annotation as Exporter;
use App\Repository\UserRepository;
use App\Utils\StringHelper;
use App\Validator\Constraints as Constraints;
use App\WorkingTime\Mode\WorkingTimeModeNone;
@@ -33,7 +34,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_users')]
#[ORM\UniqueConstraint(columns: ['username'])]
#[ORM\UniqueConstraint(columns: ['email'])]
#[ORM\Entity(repositoryClass: 'App\Repository\UserRepository')]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('username')]
#[UniqueEntity('email')]

View File

@@ -66,6 +66,35 @@ class WorkingTimeRepository extends EntityRepository
return $qb->getQuery()->getResult();
}
/**
* @deprecated since 2.25.0 - kept for BC with old plugin versions
*/
public function getLatestApproval(User $user): ?WorkingTime
{
$qb = $this->createQueryBuilder('w');
$qb->select('MAX(DATE(w.date))')
->where($qb->expr()->eq('w.user', ':user'))
->setParameter('user', $user->getId())
->andWhere($qb->expr()->isNotNull('w.approvedAt'))
;
$date = $qb->getQuery()->getSingleScalarResult();
if ($date === null) {
return null;
}
$qb = $this->createQueryBuilder('w');
$qb->select('w')
->where($qb->expr()->eq('w.user', ':user'))
->setParameter('user', $user->getId())
->andWhere($qb->expr()->eq('DATE(w.date)', 'DATE(:date)'))
->setParameter('date', $date)
;
return $qb->getQuery()->getOneOrNullResult();
}
public function getLatestApprovalDate(User $user): ?\DateTimeInterface
{
$qb = $this->createQueryBuilder('w');

View File

@@ -50,6 +50,10 @@ class SamlAuthenticator extends AbstractAuthenticator
return false;
}
if (!$request->isMethod(Request::METHOD_POST)) {
return false;
}
if (!$this->httpUtils->checkRequestPath($request, $this->options['check_path'])) {
return false;
}
@@ -81,7 +85,7 @@ class SamlAuthenticator extends AbstractAuthenticator
// file_put_contents(__DIR__ . '/../../var/log/saml.xml', $oneLoginAuth->getLastResponseXML());
if ($oneLoginAuth->getErrors()) {
if (\count($oneLoginAuth->getErrors()) > 0) {
throw new AuthenticationException($oneLoginAuth->getLastErrorReason());
}

View File

@@ -24,15 +24,12 @@ final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSucces
protected function determineTargetUrl(Request $request): string
{
if ($this->options['always_use_default_target_path']) {
return $this->options['default_target_path'];
}
$relayState = $request->get('RelayState');
$loginUrl = $this->httpUtils->generateUri($request, $this->options['login_path']);
if ($relayState !== null && $relayState !== '' && $relayState !== $loginUrl) {
return $relayState;
if (\is_scalar($relayState)) {
$relayState = (string) $relayState;
if ($relayState !== $this->httpUtils->generateUri($request, (string) $this->options['login_path'])) {
return $relayState;
}
}
return parent::determineTargetUrl($request);

View File

@@ -15,7 +15,7 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class TwoFactorCondition implements TwoFactorConditionInterface
{
public function __construct(private AuthorizationCheckerInterface $authorizationChecker)
public function __construct(private readonly AuthorizationCheckerInterface $authorizationChecker)
{
}

View File

@@ -88,13 +88,12 @@ final class DataTable implements \Countable, \IteratorAggregate
/**
* @param FormInterface<MultiUpdateTableDTO>|null $batchForm
* @return void
*/
public function setBatchForm(?FormInterface $batchForm): void
{
$this->batchForm = $batchForm;
if (!\array_key_exists('id', $this->columns)) {
if ($batchForm !== null && !\array_key_exists('id', $this->columns)) {
$this->addColumn('id', [
'class' => 'alwaysVisible multiCheckbox',
'orderBy' => false,

View File

@@ -32,6 +32,8 @@ final class WorkingTimeService
{
private const LATEST_APPROVAL_PREF = '_latest_approval';
private const LATEST_APPROVAL_FORMAT = 'Y-m-d H:i:s';
/** @var array<string, WorkingTime|null> */
private array $latestApprovals = [];
public function __construct(
private readonly TimesheetRepository $timesheetRepository,
@@ -58,6 +60,24 @@ final class WorkingTimeService
return $yearPerUserSummary;
}
/**
* @deprecated since 2.25.0 - kept for BC with old plugin versions
*/
public function getLatestApproval(User $user): ?WorkingTime
{
if ($user->getId() === null) {
return null;
}
$key = 'u_' . $user->getId();
if (!\array_key_exists($key, $this->latestApprovals)) {
$this->latestApprovals[$key] = $this->workingTimeRepository->getLatestApproval($user);
}
return $this->latestApprovals[$key];
}
public function getLatestApprovalDate(User $user): ?\DateTimeInterface
{
if ($user->getId() === null) {