Release 2.18 (#4878)

This commit is contained in:
Kevin Papst
2024-06-16 13:15:49 +02:00
committed by GitHub
parent 8792a1df09
commit 987b46bf8f
46 changed files with 1768 additions and 814 deletions

View File

@@ -26,6 +26,22 @@ final class ApiRequestMatcher implements RequestMatcherInterface
return false;
}
// ------------------------------------------------------------------------------------
// the next two checks are primarily here to make sure to return proper error messages
// let's use this firewall if a Bearer token is set in the header
// other cases like "bearer" are rejected earlier
if (($auth = $request->headers->get('Authorization')) !== null && str_starts_with($auth, 'Bearer ')) {
return true;
}
// 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)) {
return true;
}
// ------------------------------------------------------------------------------------
// checking for a previous session allows us to skip the API firewall and token access handler
// we simply re-use the existing session when doing API calls from the frontend.
// it is not necessary to check headers. if there is no valid session, we should always use this firewall

View File

@@ -31,7 +31,7 @@ final class ConfigurationController extends BaseApiController
* Returns the timesheet configuration
*/
#[OA\Response(response: 200, description: 'Returns the instance specific timesheet configuration', content: new OA\JsonContent(ref: new Model(type: TimesheetConfig::class)))]
#[Route(methods: ['GET'], path: '/config/timesheet')]
#[Route(path: '/config/timesheet', methods: ['GET'])]
public function timesheetConfigAction(SystemConfiguration $configuration): Response
{
$model = new TimesheetConfig();
@@ -46,4 +46,17 @@ final class ConfigurationController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Returns the configured color codes and names
*/
#[OA\Response(response: 200, description: 'Returns the configured color codes and names', content: new OA\JsonContent(type: 'object', example: ['Red' => '#ff0000'], additionalProperties: new OA\AdditionalProperties(type: 'string')))]
#[Route(path: '/config/colors', methods: ['GET'])]
public function colorConfigAction(SystemConfiguration $configuration): Response
{
$view = new View($configuration->getThemeColors(), 200);
$view->getContext()->setGroups(['Default']);
return $this->viewHandler->handle($view);
}
}

View File

@@ -39,7 +39,7 @@ final class TagController extends BaseApiController
}
/**
* Fetch all existing tags
* Deprecated: Fetch tags by filter as string collection
*/
#[OA\Response(response: 200, description: 'Returns the collection of all existing tags as string array', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'string')))]
#[Route(methods: ['GET'], name: 'get_tags')]
@@ -56,6 +56,27 @@ final class TagController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Fetch tags by filter as entities
*/
#[OA\Response(response: 200, description: 'Find the collection of all matching tags', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TagEntity')))]
#[Route(path: '/find', name: 'get_tags_full', methods: ['GET'])]
#[Rest\QueryParam(name: 'name', strict: true, nullable: true, description: 'Search term to filter tag list')]
public function findTags(ParamFetcherInterface $paramFetcher): Response
{
$filter = $paramFetcher->get('name');
$data = [];
if (\is_string($filter)) {
$data = $this->repository->findAllTags($filter);
}
$view = new View($data, 200);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
return $this->viewHandler->handle($view);
}
/**
* Creates a new tag
*/

View File

@@ -9,7 +9,6 @@
namespace App\API;
use App\Configuration\SystemConfiguration;
use App\Entity\AccessToken;
use App\Entity\User;
use App\Event\PrepareUserEvent;
@@ -18,6 +17,7 @@ use App\Form\API\UserApiEditForm;
use App\Repository\AccessTokenRepository;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\User\UserService;
use App\Utils\SearchTerm;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface;
@@ -27,8 +27,6 @@ use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -45,8 +43,6 @@ final class UserController extends BaseApiController
public function __construct(
private readonly ViewHandlerInterface $viewHandler,
private readonly UserRepository $repository,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly SystemConfiguration $configuration
) {
}
@@ -139,13 +135,9 @@ final class UserController extends BaseApiController
#[OA\Post(description: 'Creates a new user and returns it afterwards')]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserCreateForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_user')]
public function postAction(Request $request): Response
public function postAction(Request $request, UserService $userService): Response
{
$user = new User();
$user->setEnabled(true);
$user->setRoles([User::DEFAULT_ROLE]);
$user->setTimezone($this->configuration->getUserDefaultTimezone());
$user->setLanguage($this->configuration->getUserDefaultLanguage());
$user = $userService->createNewUser();
$form = $this->createForm(UserApiCreateForm::class, $user, [
'include_roles' => $this->isGranted('roles', $user),
@@ -156,24 +148,11 @@ final class UserController extends BaseApiController
$form->submit($request->request->all());
if ($form->isValid()) {
$plainPassword = $user->getPlainPassword();
if ($plainPassword === null) {
throw new BadRequestHttpException('Password cannot be empty');
}
$password = $this->passwordHasher->hashPassword($user, $plainPassword);
$user->setPassword($password);
if ($user->getPlainApiToken() !== null) {
$user->setApiToken($this->passwordHasher->hashPassword($user, $user->getPlainApiToken()));
}
$this->repository->saveUser($user);
$user = $userService->saveNewUser($user);
$view = new View($user, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
$user->eraseCredentials();
return $this->viewHandler->handle($view);
}

View File

@@ -21,10 +21,10 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
#[AsCommand(name: 'kimai:user:login-link', description: 'Create a URL that can be used to login as that user', hidden: true)]
/**
* @CloudRequired
*/
#[AsCommand(name: 'kimai:user:login-link', description: 'Create a URL that can be used to login as that user', hidden: true)]
final class UserLoginLinkCommand extends Command
{
public function __construct(

View File

@@ -9,6 +9,8 @@
namespace App\Configuration;
use App\Constants;
final class SystemConfiguration
{
private bool $initialized = false;
@@ -479,6 +481,9 @@ final class SystemConfiguration
return (bool) $this->find('theme.avatar_url');
}
/**
* @internal will be made private soon after 2.18.0 - do ot access this method directly, but through getThemeColors()
*/
public function getThemeColorChoices(): string
{
$config = $this->find('theme.color_choices');
@@ -489,6 +494,40 @@ final class SystemConfiguration
return 'Silver|#c0c0c0';
}
/**
* @return array<string, string>
*/
public function getThemeColors(): array
{
$config = explode(',', $this->getThemeColorChoices());
$colors = [];
foreach ($config as $item) {
if (empty($item)) {
continue;
}
$item = explode('|', $item);
$key = $item[0];
$value = $key;
if (\count($item) > 1) {
$value = $item[1];
}
if (empty($key)) {
$key = $value;
}
if ($value === Constants::DEFAULT_COLOR) {
continue;
}
$colors[$key] = $value;
}
return array_unique($colors);
}
// ========== Projects ==========
public function isProjectCopyTeamsOnCreate(): bool

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.17.0';
public const VERSION = '2.18.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 21700;
public const VERSION_ID = 21800;
/**
* The software name
*/

View File

@@ -456,19 +456,22 @@ final class ActivityController extends AbstractController
}
/**
* @param Activity $activity
* @return FormInterface<ActivityEditForm>
*/
private function createEditForm(Activity $activity): FormInterface
{
$currency = $this->configuration->getCustomerDefaultCurrency();
$url = $this->generateUrl('admin_activity_create');
if ($activity->getProject()?->getId() !== null) {
$url = $this->generateUrl('admin_activity_create_with_project', ['project' => $activity->getProject()->getId()]);
}
if ($activity->getId() !== null) {
$url = $this->generateUrl('admin_activity_edit', ['id' => $activity->getId()]);
if (null !== $activity->getProject()) {
$currency = $activity->getProject()->getCustomer()->getCurrency();
}
}
if (null !== $activity->getProject()) {
$currency = $activity->getProject()->getCustomer()->getCurrency();
}
return $this->createForm(ActivityEditForm::class, $activity, [

View File

@@ -47,7 +47,7 @@ final class SamlController extends AbstractController
$session->remove($authErrorKey);
}
if ($error) {
if ($error !== null) {
if (\is_object($error) && method_exists($error, 'getMessage')) {
$error = $error->getMessage();
}
@@ -60,13 +60,12 @@ final class SamlController extends AbstractController
$redirectTarget = $this->generateUrl('homepage', [], UrlGeneratorInterface::ABSOLUTE_URL);
}
$url = $this->authFactory->create()->login($redirectTarget);
$url = $this->authFactory->create()->login($redirectTarget, [], false, false, true);
if ($url === null) {
throw new \RuntimeException('SAML login failed');
}
// this line is not (yet) reached, as the previous call will exit
return $this->redirect($url);
}

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Utils\Color;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -22,6 +23,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
#[Serializer\ExclusionPolicy('all')]
#[Serializer\VirtualProperty('ColorSafe', exp: 'object.getColorSafe()', options: [new Serializer\SerializedName('color-safe'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Default'])])]
class Tag
{
/**
@@ -95,4 +97,9 @@ class Tag
{
return $this->getName();
}
public function getColorSafe(): string
{
return $this->getColor() ?? (new Color())->getRandom($this->getName());
}
}

View File

@@ -298,6 +298,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return $this;
}
/**
* @deprecated since 2.15
*/
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('apiToken')]
#[Serializer\Groups(['Default'])]

View File

@@ -37,7 +37,22 @@ final class TagArrayToStringTransformer implements DataTransformerInterface
return '';
}
return implode(', ', $value);
if (!\is_array($value)) {
return '';
}
$result = [];
foreach ($value as $item) {
if ($item instanceof Tag) {
$result[] = $item->getName();
} elseif (\is_string($item)) {
$result[] = $item;
} else {
throw new TransformationFailedException('Tags must only contain a Tag or a string.');
}
}
return implode(',', $result);
}
/**
@@ -55,6 +70,7 @@ final class TagArrayToStringTransformer implements DataTransformerInterface
if ('' === $value || null === $value) {
return [];
}
if (!\is_array($value)) {
$names = array_filter(array_unique(array_map('trim', explode(',', $value))));
} else {
@@ -68,18 +84,12 @@ final class TagArrayToStringTransformer implements DataTransformerInterface
}
$tagName = trim($tagName);
$tag = null;
if (is_numeric($tagName)) {
$tag = $this->tagRepository->find($tagName);
}
if ($tag === null) {
$tag = $this->tagRepository->findTagByName($tagName);
}
// do not check for numeric values as ID, this form type only submits tag names
$tag = $this->tagRepository->findTagByName($tagName);
// get the current tags and find the new ones that should be created
if ($this->create && $tag === null) {
if ($tag === null && $this->create) {
$tag = new Tag();
$tag->setName(mb_substr($tagName, 0, 100));
$this->tagRepository->saveTag($tag);

View File

@@ -10,7 +10,6 @@
namespace App\Form\Type;
use App\Configuration\SystemConfiguration;
use App\Constants;
use App\Utils\Color;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\DataTransformerInterface;
@@ -22,7 +21,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
final class ColorChoiceType extends AbstractType implements DataTransformerInterface
{
public function __construct(private SystemConfiguration $systemConfiguration)
public function __construct(private readonly SystemConfiguration $systemConfiguration)
{
}
@@ -36,7 +35,7 @@ final class ColorChoiceType extends AbstractType implements DataTransformerInter
$options = [
'documentation' => [
'type' => 'string',
'description' => sprintf('The hexadecimal color code (default: %s)', Constants::DEFAULT_COLOR),
'description' => 'The hexadecimal color code (default: auto-calculated by name)',
],
'label' => 'color',
'empty_data' => null,
@@ -50,7 +49,7 @@ final class ColorChoiceType extends AbstractType implements DataTransformerInter
];
$choices = [];
$colors = $this->convertStringToColorArray($this->systemConfiguration->getThemeColorChoices());
$colors = $this->systemConfiguration->getThemeColors();
foreach ($colors as $name => $color) {
$choices[$name] = $color;
@@ -69,44 +68,9 @@ final class ColorChoiceType extends AbstractType implements DataTransformerInter
]);
}
/**
* @param string $config
* @return array<string, string>
*/
private function convertStringToColorArray(string $config): array
public function transform(mixed $value): mixed
{
$config = explode(',', $config);
$colors = [];
foreach ($config as $item) {
if (empty($item)) {
continue;
}
$item = explode('|', $item);
$key = $item[0];
$value = $key;
if (\count($item) > 1) {
$value = $item[1];
}
if (empty($key)) {
$key = $value;
}
if ($value === Constants::DEFAULT_COLOR) {
continue;
}
$colors[$key] = $value;
}
return array_unique($colors);
}
public function transform(mixed $data): mixed
{
return $data;
return $value;
}
public function reverseTransform(mixed $value): mixed

View File

@@ -54,11 +54,12 @@ final class TagsInputType extends AbstractType
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['attr'] = array_merge($view->vars['attr'], [
'data-autocomplete-url' => $this->router->generate('get_tags'),
'data-autocomplete-url' => $this->router->generate('get_tags_full'),
'data-minimum-character' => 3,
'class' => 'form-select',
'autocomplete' => 'off',
'data-form-widget' => 'autocomplete'
'data-form-widget' => 'tags',
'data-renderer' => 'color',
]);
if ($options['allow_create']) {

View File

@@ -12,7 +12,6 @@ namespace App\Form\Type;
use App\Entity\Tag;
use App\Repository\Query\TagFormTypeQuery;
use App\Repository\TagRepository;
use App\Utils\Color;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -87,17 +86,11 @@ final class TagsSelectType extends AbstractType
return $tag->getId();
},
'choice_attr' => function (Tag $tag) {
$color = $tag->getColor();
if ($color === null) {
$color = (new Color())->getRandom($tag->getName());
}
return ['data-color' => $color];
return ['data-color' => $tag->getColorSafe()];
},
'choice_label' => function (Tag $tag) {
return $tag->getName();
},
'attr' => ['data-renderer' => 'color'],
]);
$resolver->setDefault('query_builder', function (Options $options) {
@@ -119,6 +112,10 @@ final class TagsSelectType extends AbstractType
'data-create' => 'post_tag',
]);
}
$view->vars['attr'] = array_merge($view->vars['attr'], [
'data-renderer' => 'color',
]);
}
public function getParent(): string

View File

@@ -16,6 +16,11 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class TagsType extends AbstractType
{
/**
* See KimaiFormSelect.js (maxOptions) as well.
*/
public const MAX_AMOUNT_SELECT = 500;
private ?int $count = null;
public function __construct(
@@ -37,7 +42,7 @@ final class TagsType extends AbstractType
$this->count = $this->repository->count([]);
}
if ($this->count > TagRepository::MAX_AMOUNT_SELECT) {
if ($this->count > self::MAX_AMOUNT_SELECT) {
return TagsInputType::class;
}

View File

@@ -19,15 +19,10 @@ use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
/**
* @extends \Doctrine\ORM\EntityRepository<Tag>
* @extends EntityRepository<Tag>
*/
class TagRepository extends EntityRepository
{
/**
* See KimaiFormSelect.js (maxOptions) as well.
*/
public const MAX_AMOUNT_SELECT = 500;
public function saveTag(Tag $tag): void
{
$entityManager = $this->getEntityManager();
@@ -64,18 +59,11 @@ class TagRepository extends EntityRepository
return $this->findOneBy(['name' => $tagName, 'visible' => $visible]);
}
/**
* Find all visible tag names in alphabetical order.
*
* @return array<string>
*/
public function findAllTagNames(?string $filter = null): array
private function findAllTagsQuery(?string $filter = null): QueryBuilder
{
$qb = $this->createQueryBuilder('t');
$qb
->select('t.name')
->addOrderBy('t.name', 'ASC');
$qb->addOrderBy('t.name', 'ASC');
$qb->andWhere($qb->expr()->eq('t.visible', ':visible'));
$qb->setParameter('visible', true, ParameterType::BOOLEAN);
@@ -85,7 +73,27 @@ class TagRepository extends EntityRepository
$qb->setParameter('filter', '%' . $filter . '%');
}
return array_column($qb->getQuery()->getScalarResult(), 'name');
return $qb;
}
/**
* Find all visible tag names in alphabetical order.
*
* @return array<Tag>
*/
public function findAllTags(?string $filter = null): array
{
return $this->findAllTagsQuery($filter)->getQuery()->getResult();
}
/**
* Find all visible tag names in alphabetical order.
*
* @return array<string>
*/
public function findAllTagNames(?string $filter = null): array
{
return array_column($this->findAllTagsQuery($filter)->select('t.name')->getQuery()->getScalarResult(), 'name');
}
/**

View File

@@ -48,6 +48,10 @@ final class ApiVoter extends Voter
return false;
}
return $this->permissionManager->hasRolePermission($user, 'api_access');
if ($token->hasAttribute('api-token')) {
return $this->permissionManager->hasRolePermission($user, 'api_access');
}
return true;
}
}