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:
Kevin Papst
2023-01-12 12:10:11 +01:00
committed by GitHub
parent cbd65f1f1d
commit 8069e332fe
38 changed files with 386 additions and 2850 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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', [

View File

@@ -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]);
}

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View 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();
}
}

View File

@@ -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']),
];
}