release 2.0 beta (#3722)
* remove twitter link * remove WIP file * adjust release draft message * reset code coverage threshold back to 0.5 * changed wordings * re-activate wizard for fixture accounts * fix repo url and license * license identifier * bump version * moved Kimai 1 import command from core to plugin * do not traverse into invoice template subdirectories (#3735) * fix branch alias * composer update * switch language on wizard select * new twig function to create qr code * fix daily stats in timesheet listing * improved html invoice templates
This commit is contained in:
@@ -9,12 +9,17 @@
|
||||
|
||||
namespace App\API\Authentication;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\Security\Core\Exception\LogicException;
|
||||
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
|
||||
|
||||
final class ApiTokenUpgradeBadge implements BadgeInterface
|
||||
{
|
||||
/**
|
||||
* @param string|null $plaintextApiToken
|
||||
* @param PasswordUpgraderInterface<User> $passwordUpgrader
|
||||
*/
|
||||
public function __construct(private ?string $plaintextApiToken, private PasswordUpgraderInterface $passwordUpgrader)
|
||||
{
|
||||
}
|
||||
@@ -31,6 +36,9 @@ final class ApiTokenUpgradeBadge implements BadgeInterface
|
||||
return $password;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PasswordUpgraderInterface<User>
|
||||
*/
|
||||
public function getPasswordUpgrader(): PasswordUpgraderInterface
|
||||
{
|
||||
return $this->passwordUpgrader;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -77,18 +77,18 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
$table->setPaginationRoute($paginationRoute);
|
||||
$table->setReloadEvents('kimai.timesheetUpdate kimai.timesheetDelete');
|
||||
|
||||
$table->addColumn('date', ['class' => 'alwaysVisible', 'orderBy' => 'begin']);
|
||||
$table->addColumn('date', ['class' => 'alwaysVisible text-nowrap', 'orderBy' => 'begin']);
|
||||
|
||||
if ($this->canSeeStartEndTime()) {
|
||||
$table->addColumn('starttime', ['class' => 'd-none d-sm-table-cell text-center', 'orderBy' => 'begin']);
|
||||
$table->addColumn('endtime', ['class' => 'd-none d-sm-table-cell text-center', 'orderBy' => 'end']);
|
||||
$table->addColumn('starttime', ['class' => 'd-none d-sm-table-cell text-center text-nowrap', 'orderBy' => 'begin']);
|
||||
$table->addColumn('endtime', ['class' => 'd-none d-sm-table-cell text-center text-nowrap', 'orderBy' => 'end']);
|
||||
}
|
||||
|
||||
$table->addColumn('duration', ['class' => 'text-end text-nowrap']);
|
||||
|
||||
if ($canSeeRate) {
|
||||
$table->addColumn('hourlyRate', ['class' => 'text-end d-none']);
|
||||
$table->addColumn('rate', ['class' => 'text-end']);
|
||||
$table->addColumn('hourlyRate', ['class' => 'text-end d-none text-nowrap']);
|
||||
$table->addColumn('rate', ['class' => 'text-end text-nowrap']);
|
||||
}
|
||||
|
||||
$table->addColumn('customer', ['class' => 'd-none d-md-table-cell']);
|
||||
@@ -98,7 +98,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
$table->addColumn('tags', ['class' => 'd-none badges', 'orderBy' => false]);
|
||||
|
||||
foreach ($metaColumns as $metaColumn) {
|
||||
$table->addColumn('mf_' . $metaColumn->getName(), ['title' => $metaColumn->getLabel(), 'class' => 'd-none', 'orderBy' => false]);
|
||||
$table->addColumn('mf_' . $metaColumn->getName(), ['title' => $metaColumn->getLabel(), 'class' => 'd-none', 'orderBy' => false, 'data' => $metaColumn]);
|
||||
}
|
||||
|
||||
if ($canSeeUsername) {
|
||||
@@ -116,11 +116,8 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
'page_setup' => $page,
|
||||
'dataTable' => $table,
|
||||
'action_single' => $this->getActionNameSingle(),
|
||||
'canSeeUsername' => $canSeeUsername,
|
||||
'canSeeRate' => $canSeeRate,
|
||||
'stats' => $result->getStatistic(),
|
||||
'showSummary' => $this->includeSummary(),
|
||||
'showStartEndTime' => $this->canSeeStartEndTime(),
|
||||
'metaColumns' => $metaColumns,
|
||||
'allowMarkdown' => $this->hasMarkdownSupport(),
|
||||
'editRoute' => $this->getEditRoute()
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Form\Type\SkinType;
|
||||
use App\Form\Type\TimezoneType;
|
||||
use App\User\UserService;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
@@ -42,18 +43,17 @@ final class WizardController extends AbstractController
|
||||
|
||||
if ($wizard === 'profile') {
|
||||
$data = [
|
||||
'language' => $request->getLocale(),
|
||||
'timezone' => $user->getTimezone(),
|
||||
UserPreference::LOCALE => $request->getLocale(),
|
||||
UserPreference::TIMEZONE => $user->getTimezone(),
|
||||
UserPreference::SKIN => $user->getSkin(),
|
||||
'reload' => '0',
|
||||
];
|
||||
|
||||
$form = $this->createFormBuilder($data)
|
||||
->add(UserPreference::LOCALE, LanguageType::class)
|
||||
->add(UserPreference::TIMEZONE, TimezoneType::class)
|
||||
->add(UserPreference::SKIN, SkinType::class, [
|
||||
'attr' => [
|
||||
'onchange' => "document.body.classList.remove('theme-light');document.body.classList.remove('theme-light');"
|
||||
],
|
||||
])
|
||||
->add(UserPreference::SKIN, SkinType::class)
|
||||
->add('reload', HiddenType::class)
|
||||
->setAction($this->generateUrl('wizard', ['wizard' => 'profile']))
|
||||
->setMethod('POST')
|
||||
->getForm();
|
||||
@@ -69,7 +69,11 @@ final class WizardController extends AbstractController
|
||||
$user->setWizardAsSeen('profile');
|
||||
$userService->updateUser($user);
|
||||
|
||||
return $this->redirectToRoute('wizard', ['wizard' => 'done', '_locale' => $data['language']]);
|
||||
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->render('wizard/profile.html.twig', [
|
||||
|
||||
@@ -80,9 +80,12 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
|
||||
$prefs = $this->getUserPreferences($user, $userData[7]);
|
||||
$user->setPreferences($prefs);
|
||||
// better to be able to test the wizard in demo installations
|
||||
/*
|
||||
foreach (User::WIZARDS as $wizard) {
|
||||
$user->setWizardAsSeen($wizard);
|
||||
}
|
||||
*/
|
||||
$manager->persist($prefs[0]);
|
||||
$manager->persist($prefs[1]);
|
||||
}
|
||||
|
||||
@@ -439,6 +439,11 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return (bool) $this->getPreferenceValue('export_decimal', false, false);
|
||||
}
|
||||
|
||||
public function getSkin(): string
|
||||
{
|
||||
return (string) $this->getPreferenceValue(UserPreference::SKIN, 'default', false);
|
||||
}
|
||||
|
||||
public function setTimezone(?string $timezone)
|
||||
{
|
||||
if ($timezone === null) {
|
||||
|
||||
@@ -15,6 +15,9 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* @template-implements PasswordUpgraderInterface<User>
|
||||
*/
|
||||
class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterface
|
||||
{
|
||||
public function __construct(private UserRepository $userRepository)
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Repository;
|
||||
|
||||
use App\Model\InvoiceDocument;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Symfony\Component\Finder\SplFileInfo;
|
||||
|
||||
final class InvoiceDocumentRepository
|
||||
{
|
||||
@@ -21,6 +22,9 @@ final class InvoiceDocumentRepository
|
||||
*/
|
||||
private array $documentDirs = [];
|
||||
|
||||
/**
|
||||
* @param array<string> $directories
|
||||
*/
|
||||
public function __construct(array $directories)
|
||||
{
|
||||
foreach ($directories as $directory) {
|
||||
@@ -31,23 +35,19 @@ final class InvoiceDocumentRepository
|
||||
/**
|
||||
* @CloudRequired
|
||||
*/
|
||||
public function addDirectory(string $directory)
|
||||
public function addDirectory(string $directory): void
|
||||
{
|
||||
$this->documentDirs[] = $directory;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @CloudRequired
|
||||
*/
|
||||
public function removeDirectory(string $directory)
|
||||
public function removeDirectory(string $directory): void
|
||||
{
|
||||
if (($key = array_search($directory, $this->documentDirs)) !== false) {
|
||||
unset($this->documentDirs[$key]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +59,12 @@ final class InvoiceDocumentRepository
|
||||
throw new \InvalidArgumentException('Cannot delete built-in invoice template');
|
||||
}
|
||||
|
||||
@unlink(realpath($invoiceDocument->getFilename()));
|
||||
$realpath = realpath($invoiceDocument->getFilename());
|
||||
if ($realpath === false) {
|
||||
throw new \InvalidArgumentException('Template does not exist: ' . $invoiceDocument->getFilename());
|
||||
}
|
||||
|
||||
@unlink($realpath);
|
||||
}
|
||||
|
||||
public function getUploadDirectory(): string
|
||||
@@ -135,6 +140,7 @@ final class InvoiceDocumentRepository
|
||||
/**
|
||||
* Returns an array of invoice documents.
|
||||
*
|
||||
* @param array<string> $paths
|
||||
* @return InvoiceDocument[]
|
||||
*/
|
||||
private function findByPaths(array $paths): array
|
||||
@@ -153,7 +159,8 @@ final class InvoiceDocumentRepository
|
||||
continue;
|
||||
}
|
||||
|
||||
$finder = Finder::create()->ignoreDotFiles(true)->files()->in($searchDir)->name('*.*');
|
||||
$finder = Finder::create()->ignoreDotFiles(true)->files()->in($searchDir)->depth(0)->name('*.*');
|
||||
/** @var SplFileInfo $file */
|
||||
foreach ($finder->getIterator() as $file) {
|
||||
$doc = new InvoiceDocument($file);
|
||||
// the first found invoice document wins
|
||||
|
||||
@@ -24,6 +24,9 @@ final class LoaderPaginator implements PaginatorInterface
|
||||
return $this->results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<array-key, iterable<mixed>>
|
||||
*/
|
||||
public function getSlice(int $offset, int $length): iterable
|
||||
{
|
||||
$query = $this->query
|
||||
@@ -34,13 +37,17 @@ final class LoaderPaginator implements PaginatorInterface
|
||||
return $this->getResults($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Query<null, mixed> $query
|
||||
* @return iterable<array-key, iterable<mixed>>
|
||||
*/
|
||||
private function getResults(Query $query)
|
||||
{
|
||||
$results = $query->execute();
|
||||
|
||||
$this->loader->loadResults($results);
|
||||
|
||||
return $results;
|
||||
return $results; // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
public function getAll(): iterable
|
||||
|
||||
@@ -23,6 +23,9 @@ final class QueryBuilderPaginator implements PaginatorInterface
|
||||
return $this->results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<array-key, iterable<mixed>>
|
||||
*/
|
||||
public function getSlice(int $offset, int $length): iterable
|
||||
{
|
||||
$query = $this->query
|
||||
@@ -33,9 +36,13 @@ final class QueryBuilderPaginator implements PaginatorInterface
|
||||
return $this->getResults($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Query<null, mixed> $query
|
||||
* @return iterable<array-key, iterable<mixed>>
|
||||
*/
|
||||
private function getResults(Query $query)
|
||||
{
|
||||
return $query->execute();
|
||||
return $query->execute(); // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
public function getAll(): iterable
|
||||
|
||||
@@ -34,6 +34,7 @@ use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||
|
||||
/**
|
||||
* @extends \Doctrine\ORM\EntityRepository<User>
|
||||
* @template-implements PasswordUpgraderInterface<User>
|
||||
*/
|
||||
class UserRepository extends EntityRepository implements UserLoaderInterface, UserProviderInterface, PasswordUpgraderInterface
|
||||
{
|
||||
@@ -58,7 +59,7 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
public function upgradePassword(PasswordAuthenticatedUserInterface|UserInterface $user, string $newHashedPassword): void
|
||||
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
|
||||
{
|
||||
if (!($user instanceof User)) {
|
||||
return;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Security;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\User;
|
||||
use App\Ldap\LdapUserProvider;
|
||||
use Symfony\Component\Security\Core\User\ChainUserProvider;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
@@ -17,6 +18,9 @@ use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||
|
||||
/**
|
||||
* @template-implements PasswordUpgraderInterface<User>
|
||||
*/
|
||||
final class KimaiUserProvider implements UserProviderInterface, PasswordUpgraderInterface
|
||||
{
|
||||
private ?ChainUserProvider $provider = null;
|
||||
|
||||
39
src/Twig/Runtime/QrCodeExtension.php
Normal file
39
src/Twig/Runtime/QrCodeExtension.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?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\Twig\Runtime;
|
||||
|
||||
use Endroid\QrCode\Builder\Builder;
|
||||
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelMedium;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
use Twig\Extension\RuntimeExtensionInterface;
|
||||
|
||||
final class QrCodeExtension implements RuntimeExtensionInterface
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $data
|
||||
* @param array<string, mixed> $writerOptions
|
||||
* @return string
|
||||
*/
|
||||
public function qrCodeDataUriFunction(string $data, array $writerOptions = []): string
|
||||
{
|
||||
return Builder::create()
|
||||
->writer(new PngWriter())
|
||||
->writerOptions($writerOptions)
|
||||
->data($data)
|
||||
// if this causes errors at some point and needs to be configurable, keep this default!
|
||||
->errorCorrectionLevel(new ErrorCorrectionLevelMedium())
|
||||
->build()
|
||||
->getDataUri();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ namespace App\Twig;
|
||||
|
||||
use App\Twig\Runtime\EncoreExtension;
|
||||
use App\Twig\Runtime\MarkdownExtension;
|
||||
use App\Twig\Runtime\QrCodeExtension;
|
||||
use App\Twig\Runtime\ThemeExtension;
|
||||
use App\Twig\Runtime\TimesheetExtension;
|
||||
use App\Twig\Runtime\WidgetExtension;
|
||||
@@ -35,6 +36,7 @@ final class RuntimeExtensions extends AbstractExtension
|
||||
new TwigFunction('encore_entry_css_source', [EncoreExtension::class, 'getEncoreEntryCssSource']),
|
||||
new TwigFunction('render_widget', [WidgetExtension::class, 'renderWidget'], ['is_safe' => ['html'], 'needs_environment' => true]),
|
||||
new TwigFunction('icon', [RuntimeExtension::class, 'createIcon'], ['is_safe' => ['html']]),
|
||||
new TwigFunction('qr_code_data_uri', [QrCodeExtension::class, 'qrCodeDataUriFunction']),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user