Release 2.1.0 (#4321)

* fix deprecations
* remove unused config
* replace invalid annotation type with attribute
* use AsDoctrineListener to fix deprecation
* new ModifiedSubscriber to support custom logic and fix deprecation
* removed inheritdoc comment
* new ModifiedSubscriber to support custom logic and fix deprecation
* cleanup event dispatcher interface
* re-order annotation params
* one more doctrine based deprecation
* fix query to count active timesheets
* link to "all times" to identify active timesheets
* link icon instead of text
* fix "skin" translation in wizard
* use duration filter to show duration
* added login link command and controller
* bump tabler theme to 1.0
* added wizard to force password reset by user
* allow to configure that new accounts need to reset their password
* prevent uploading twig templates by default
* bump composer packages
* enable sandbox and basic security measures for custom twig templates for invoice and export
* bump to symfony 6.3.5
* allow to export single user reports to excel
* removed broken method to reload twig cache
* added api parameter to fetch user collection fully serialized
* allow to replace or append description via timesheet batch update
* show api username above form
This commit is contained in:
Kevin Papst
2023-10-19 11:21:50 +02:00
committed by GitHub
parent 7a5b12762a
commit 38e37f1c2e
210 changed files with 1784 additions and 1147 deletions

View File

@@ -17,7 +17,6 @@ use App\Timesheet\DateTimeFactory;
use App\Validator\ValidationFailedException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormTypeInterface;
@@ -76,13 +75,13 @@ abstract class AbstractController extends BaseAbstractController implements Serv
/**
* @template TFormType of FormTypeInterface<TData>
* @template TData of mixed
* @template TData of array|object
* @param class-string<TFormType> $type
* @param TData|null $data
* @param TData $data
* @param array<mixed> $options
* @return FormInterface<TData|null>
* @return FormInterface<TData>
*/
protected function createFormWithName(string $name, string $type = FormType::class, mixed $data = null, array $options = []): FormInterface
protected function createFormWithName(string $name, string $type, mixed $data, array $options = []): FormInterface
{
return $this->container->get('form.factory')->createNamed($name, $type, $data, $options);
}

View File

@@ -35,7 +35,7 @@ use App\Repository\TeamRepository;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Exception;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;

View File

@@ -16,10 +16,10 @@ use App\Reporting\YearByUser\YearByUser;
use App\Utils\PageSetup;
use App\WorkingTime\Model\BoxConfiguration;
use App\WorkingTime\WorkingTimeService;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Users can control their working time statistics

View File

@@ -36,7 +36,7 @@ use App\Repository\Query\ProjectQuery;
use App\Repository\TeamRepository;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;

View File

@@ -16,7 +16,7 @@ use App\Repository\BookmarkRepository;
use App\Utils\PageSetup;
use App\Widget\WidgetInterface;
use App\Widget\WidgetService;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;

View File

@@ -13,7 +13,7 @@ use App\Configuration\LocaleService;
use App\Entity\User;
use App\Event\ConfigureMainMenuEvent;
use App\Repository\UserRepository;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

View File

@@ -38,6 +38,7 @@ use App\Repository\Query\InvoiceQuery;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Exception;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
@@ -48,7 +49,6 @@ use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Twig\Environment;
/**
@@ -468,36 +468,6 @@ final class InvoiceController extends AbstractController
throw $this->createNotFoundException('Unknown document: ' . $document);
}
#[Route(path: '/document_reload/{document}', name: 'admin_invoice_document_reload', methods: ['GET', 'POST'])]
#[IsGranted('upload_invoice_template')]
public function reloadDocument(string $document, Environment $twig): Response
{
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true));
$this->dispatcher->dispatch($event);
$reloaded = false;
foreach ($event->getInvoiceDocuments() as $doc) {
if ($document === $doc->getId() && $doc->isTwig()) {
$reloaded = true;
try {
$twig->enableAutoReload();
$twig->load('@invoice/' . basename($doc->getFilename()));
$twig->disableAutoReload();
$this->flashSuccess('Reloaded template');
} catch (Exception $ex) {
$this->flashException($ex, 'Failed to reload template: ' . $ex->getMessage());
}
}
}
if (!$reloaded) {
throw $this->createNotFoundException('Unknown document: ' . $document);
}
return $this->redirectToRoute('admin_invoice_document_upload');
}
#[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])]
#[IsGranted('upload_invoice_template')]
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration): Response

View File

@@ -22,7 +22,7 @@ use App\Security\RolePermissionManager;
use App\Security\RoleService;
use App\User\PermissionService;
use App\Utils\PageSetup;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

View File

@@ -32,8 +32,8 @@ use Endroid\QrCode\Encoding\Encoding;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh;
use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeMargin;
use Endroid\QrCode\Writer\PngWriter;
use Psr\EventDispatcher\EventDispatcherInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;

View File

@@ -40,7 +40,7 @@ use App\Repository\TeamRepository;
use App\Utils\Context;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;

View File

@@ -42,7 +42,7 @@ final class ReportUsersMonthController extends AbstractController
{
$data = $this->getData($request, $statisticService, $userRepository);
$content = $this->container->get('twig')->render('reporting/report_user_list_export.html.twig', $data);
$content = $this->renderView('reporting/report_user_list_export.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);

View File

@@ -42,7 +42,7 @@ final class ReportUsersWeekController extends AbstractController
{
$data = $this->getData($request, $statisticService, $userRepository);
$content = $this->container->get('twig')->render('reporting/report_user_list_export.html.twig', $data);
$content = $this->renderView('reporting/report_user_list_export.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);

View File

@@ -19,7 +19,6 @@ use App\Reporting\YearlyUserList\YearlyUserListForm;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
use Exception;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -30,11 +29,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('report:other')]
final class ReportUsersYearController extends AbstractController
{
/**
* @param Request $request
* @return Response
* @throws Exception
*/
#[Route(path: '/year', name: 'report_yearly_users', methods: ['GET', 'POST'])]
public function report(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
@@ -44,17 +38,12 @@ final class ReportUsersYearController extends AbstractController
);
}
/**
* @param Request $request
* @return Response
* @throws Exception
*/
#[Route(path: '/year_export', name: 'report_yearly_users_export', methods: ['GET', 'POST'])]
public function export(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
$data = $this->getData($request, $systemConfiguration, $statisticService, $userRepository);
$content = $this->container->get('twig')->render('reporting/report_user_list_monthly_export.html.twig', $data);
$content = $this->renderView('reporting/report_user_list_monthly_export.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);

View File

@@ -10,10 +10,13 @@
namespace App\Controller\Reporting;
use App\Entity\User;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Model\DailyStatistic;
use App\Reporting\MonthByUser\MonthByUser;
use App\Reporting\MonthByUser\MonthByUserForm;
use Exception;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
@@ -35,6 +38,21 @@ final class UserMonthController extends AbstractUserReportController
return $this->render('reporting/report_by_user.html.twig', $this->getData($request));
}
#[Route(path: '/month_export', name: 'report_user_month_export', methods: ['GET', 'POST'])]
public function export(Request $request): Response
{
$data = $this->getData($request);
$content = $this->renderView('reporting/report_by_user_data.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-user-monthly');
return $writer->getFileResponse($spreadsheet);
}
private function getData(Request $request): array
{
$currentUser = $this->getUser();
@@ -95,6 +113,7 @@ final class UserMonthController extends AbstractUserReportController
'current' => $start,
'next' => $nextMonth,
'previous' => $previousMonth,
'export_route' => 'report_user_month_export',
];
}
}

View File

@@ -9,10 +9,13 @@
namespace App\Controller\Reporting;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Model\DailyStatistic;
use App\Reporting\WeekByUser\WeekByUser;
use App\Reporting\WeekByUser\WeekByUserForm;
use Exception;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
@@ -34,6 +37,21 @@ final class UserWeekController extends AbstractUserReportController
return $this->render('reporting/report_by_user.html.twig', $this->getData($request));
}
#[Route(path: '/week_export', name: 'report_user_week_export', methods: ['GET', 'POST'])]
public function export(Request $request): Response
{
$data = $this->getData($request);
$content = $this->renderView('reporting/report_by_user_data.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-user-weekly');
return $writer->getFileResponse($spreadsheet);
}
private function getData(Request $request): array
{
$currentUser = $this->getUser();
@@ -88,6 +106,7 @@ final class UserWeekController extends AbstractUserReportController
'current' => $start,
'next' => $next,
'previous' => $previous,
'export_route' => 'report_user_week_export',
];
}
}

View File

@@ -11,6 +11,8 @@ namespace App\Controller\Reporting;
use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Model\DateStatisticInterface;
use App\Model\MonthlyStatistic;
use App\Reporting\YearByUser\YearByUser;
@@ -18,6 +20,7 @@ use App\Reporting\YearByUser\YearByUserForm;
use DateTime;
use DateTimeInterface;
use Exception;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
@@ -39,6 +42,21 @@ final class UserYearController extends AbstractUserReportController
return $this->render('reporting/report_by_user_year.html.twig', $this->getData($request, $systemConfiguration));
}
#[Route(path: '/year_export', name: 'report_user_year_export', methods: ['GET', 'POST'])]
public function export(Request $request, SystemConfiguration $systemConfiguration): Response
{
$data = $this->getData($request, $systemConfiguration);
$content = $this->renderView('reporting/report_by_user_year_export.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-user-yearly');
return $writer->getFileResponse($spreadsheet);
}
private function getData(Request $request, SystemConfiguration $systemConfiguration): array
{
$currentUser = $this->getUser();
@@ -104,6 +122,7 @@ final class UserYearController extends AbstractUserReportController
'current' => $start,
'next' => $next,
'previous' => $previous,
'export_route' => 'report_user_year_export',
];
}

View File

@@ -0,0 +1,27 @@
<?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\Controller\Security;
use App\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[Route(path: '/auth/link')]
/**
* @CloudRequired
*/
final class LoginLinkController extends AbstractController
{
#[Route(path: '/check', name: 'link_login_check', methods: ['GET'])]
public function check(): Response
{
return new Response();
}
}

View File

@@ -18,6 +18,7 @@ use App\Form\PasswordResetForm;
use App\User\LoginManager;
use App\User\UserService;
use DateTime;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
@@ -26,7 +27,6 @@ use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[Route(path: '/resetting')]
@@ -108,7 +108,7 @@ final class PasswordResetController extends AbstractController
}
return $this->render('security/password-reset/check_email.html.twig', [
'tokenLifetime' => ceil($this->configuration->getPasswordResetRetryLifetime() / 3600),
'tokenLifetime' => $this->configuration->getPasswordResetRetryLifetime(),
]);
}

View File

@@ -17,6 +17,7 @@ use App\Event\EmailSelfRegistrationEvent;
use App\Form\SelfRegistrationForm;
use App\User\LoginManager;
use App\User\UserService;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
@@ -27,7 +28,6 @@ use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[Route(path: '/register')]

View File

@@ -36,7 +36,7 @@ use App\Utils\PageSetup;
use App\Validator\Constraints\ColorChoices;
use App\Validator\Constraints\DateTimeFormat;
use App\Validator\Constraints\TimeFormat;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;

View File

@@ -33,7 +33,7 @@ use App\Timesheet\TimesheetService;
use App\Timesheet\TrackingMode\TrackingModeInterface;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
@@ -334,6 +334,13 @@ abstract class TimesheetAbstractController extends AbstractController
$execute = false;
/** @var Timesheet $timesheet */
foreach ($timesheets as $timesheet) {
if ($dto->isReplaceDescription()) {
$timesheet->setDescription($dto->getDescription());
$execute = true;
} elseif($dto->getDescription() !== null && $dto->getDescription() !== '') {
$timesheet->setDescription($timesheet->getDescription() . PHP_EOL . $dto->getDescription());
$execute = true;
}
if ($dto->isReplaceTags()) {
foreach ($timesheet->getTags() as $tag) {
$timesheet->removeTag($tag);

View File

@@ -25,7 +25,7 @@ use App\Repository\UserRepository;
use App\User\UserService;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

View File

@@ -14,6 +14,7 @@ use App\Entity\UserPreference;
use App\Form\Type\LanguageType;
use App\Form\Type\SkinType;
use App\Form\Type\TimezoneType;
use App\Form\UserPasswordType;
use App\User\UserService;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\HttpFoundation\Request;
@@ -58,6 +59,11 @@ final class WizardController extends AbstractController
->setMethod('POST')
->getForm();
$next = 'done';
if ($user->requiresPasswordReset()) {
$next = 'password';
}
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
@@ -72,13 +78,44 @@ final class WizardController extends AbstractController
if ($data['reload'] === '1') {
return $this->redirectToRoute('wizard', ['wizard' => 'profile', '_locale' => $data['language']]);
} else {
return $this->redirectToRoute('wizard', ['wizard' => 'done', '_locale' => $data['language']]);
return $this->redirectToRoute('wizard', ['wizard' => $next, '_locale' => $data['language']]);
}
}
return $this->render('wizard/profile.html.twig', [
'percent' => \intval(100 / \count(User::WIZARDS) * 1),
'previous' => 'intro',
'next' => $next,
'form' => $form->createView(),
]);
}
if ($wizard === 'password' || $user->requiresPasswordReset()) {
$form = $this->createForm(UserPasswordType::class, $user, [
'action' => $this->generateUrl('wizard', ['wizard' => 'password']),
'method' => 'POST',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user->setRequiresPasswordReset(false);
$userService->updateUser($user);
return $this->redirectToRoute('wizard', ['wizard' => 'done']);
}
$previous = 'profile';
$percent = \intval(100 / \count(User::WIZARDS) * 1);
if ($user->requiresPasswordReset()) {
$previous = null;
$percent = null;
}
return $this->render('wizard/password.html.twig', [
'percent' => $percent,
'previous' => $previous,
'next' => 'done',
'form' => $form->createView(),
]);