prepare release 1.13 (#2290)

* make voters a final class
* upgrade dependencies
* sort project alphabetically in dashboard widget
* open detail page on row click
* do not break on null tag name
* added max height to scrollable widgets on dashboard
* added timesheet duplicate event
* allow to deactivate browser title update
* improve comment box
* moved role permissions to own menu
* removed tabs in user screen
* fix user can remove super-admin from own account
This commit is contained in:
Kevin Papst
2021-02-01 23:43:47 +01:00
committed by GitHub
parent a034b3519e
commit 8d41fa20bd
59 changed files with 693 additions and 232 deletions

View File

@@ -13,6 +13,8 @@ namespace App\API;
use App\Entity\User;
use App\Event\RecentActivityEvent;
use App\Event\TimesheetDuplicatePostEvent;
use App\Event\TimesheetDuplicatePreEvent;
use App\Event\TimesheetMetaDefinitionEvent;
use App\Form\API\TimesheetApiEditForm;
use App\Repository\Query\TimesheetQuery;
@@ -727,8 +729,12 @@ class TimesheetController extends BaseApiController
$copyTimesheet = clone $timesheet;
$this->dispatcher->dispatch(new TimesheetDuplicatePreEvent($copyTimesheet, $timesheet));
$this->service->saveNewTimesheet($copyTimesheet);
$this->dispatcher->dispatch(new TimesheetDuplicatePostEvent($copyTimesheet, $timesheet));
$view = new View($copyTimesheet, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);

View File

@@ -17,7 +17,7 @@ class Constants
/**
* The current release version
*/
public const VERSION = '1.12';
public const VERSION = '1.13';
/**
* The current release status, either "stable" or "dev"
*/

View File

@@ -25,7 +25,6 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
@@ -35,7 +34,7 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
* @Route(path="/profile")
* @Security("is_granted('view_own_profile') or is_granted('view_other_profile')")
*/
class ProfileController extends AbstractController
final class ProfileController extends AbstractController
{
/**
* @var EventDispatcherInterface
@@ -50,11 +49,10 @@ class ProfileController extends AbstractController
*/
private $teams;
public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher, TeamRepository $teams)
public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher)
{
$this->encoder = $encoder;
$this->dispatcher = $dispatcher;
$this->teams = $teams;
}
/**
@@ -104,7 +102,11 @@ class ProfileController extends AbstractController
return $this->redirectToRoute('user_profile_edit', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'settings', $form);
return $this->render('user/profile.html.twig', [
'tab' => 'settings',
'user' => $profile,
'form' => $form->createView(),
]);
}
/**
@@ -129,7 +131,11 @@ class ProfileController extends AbstractController
return $this->redirectToRoute('user_profile_password', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'password', null, $form);
return $this->render('user/profile.html.twig', [
'tab' => 'password',
'user' => $profile,
'form' => $form->createView(),
]);
}
/**
@@ -154,7 +160,11 @@ class ProfileController extends AbstractController
return $this->redirectToRoute('user_profile_api_token', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'api-token', null, null, null, $form);
return $this->render('user/api-token.html.twig', [
'tab' => 'api-token',
'user' => $profile,
'form' => $form->createView(),
]);
}
/**
@@ -163,10 +173,18 @@ class ProfileController extends AbstractController
*/
public function rolesAction(User $profile, Request $request)
{
$isSuperAdmin = $profile->isSuperAdmin();
$form = $this->createRolesForm($profile);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// fix that a super admin cannot remove this role from himself.
// would be a massive problem, in case that there is only one super-admin account existing
if ($isSuperAdmin && !$profile->isSuperAdmin() && $profile->getId() === $this->getUser()->getId()) {
$profile->setSuperAdmin(true);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($profile);
$entityManager->flush();
@@ -176,7 +194,11 @@ class ProfileController extends AbstractController
return $this->redirectToRoute('user_profile_roles', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'roles', null, null, $form);
return $this->render('user/profile.html.twig', [
'tab' => 'roles',
'user' => $profile,
'form' => $form->createView(),
]);
}
/**
@@ -198,7 +220,11 @@ class ProfileController extends AbstractController
return $this->redirectToRoute('user_profile_teams', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'teams', null, null, null, null, $form);
return $this->render('user/profile.html.twig', [
'tab' => 'teams',
'user' => $profile,
'form' => $form->createView(),
]);
}
/**
@@ -283,45 +309,6 @@ class ProfileController extends AbstractController
]);
}
protected function getProfileView(
User $user,
string $tab,
FormInterface $editForm = null,
FormInterface $pwdForm = null,
FormInterface $rolesForm = null,
FormInterface $apiTokenForm = null,
FormInterface $teamsForm = null
): Response {
$forms = [];
if ($this->isGranted('edit', $user)) {
$editForm = $editForm ?: $this->createEditForm($user);
$forms['settings'] = $editForm->createView();
}
if ($this->isGranted('password', $user)) {
$pwdForm = $pwdForm ?: $this->createPasswordForm($user);
$forms['password'] = $pwdForm->createView();
}
if ($this->isGranted('api-token', $user)) {
$apiTokenForm = $apiTokenForm ?: $this->createApiTokenForm($user);
$forms['api-token'] = $apiTokenForm->createView();
}
if ($this->isGranted('teams', $user) && $this->teams->count([]) > 0) {
$teamsForm = $teamsForm ?: $this->createTeamsForm($user);
$forms['teams'] = $teamsForm->createView();
}
if ($this->isGranted('roles', $user)) {
$rolesForm = $rolesForm ?: $this->createRolesForm($user);
$forms['roles'] = $rolesForm->createView();
}
return $this->render('user/profile.html.twig', [
'tab' => $tab,
'user' => $user,
'forms' => $forms
]);
}
private function createPreferencesForm(User $user): FormInterface
{
return $this->createForm(

View File

@@ -51,7 +51,7 @@ class Tag
*
* @ORM\Column(name="name", type="string", length=100, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(min=2, max=100, allowEmptyString=false)
* @Assert\Length(min=2, max=100, allowEmptyString=false, normalizer="trim")
* @Assert\Regex(pattern="/,/",match=false,message="Tag name cannot contain comma")
*/
private $name;
@@ -77,7 +77,7 @@ class Tag
return $this->id;
}
public function setName(string $tagName): Tag
public function setName(?string $tagName): Tag
{
$this->name = $tagName;

View File

@@ -439,6 +439,18 @@ class User extends BaseUser implements UserInterface
return !$this->getTeams()->isEmpty();
}
public function hasTeamMember(User $user): bool
{
/** @var Team $team */
foreach ($this->getTeams() as $team) {
if ($team->hasUser($user)) {
return true;
}
}
return false;
}
/**
* @return Collection<Team>
*/

View File

@@ -0,0 +1,31 @@
<?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\Event;
use App\Entity\Timesheet;
final class TimesheetDuplicatePostEvent extends AbstractTimesheetEvent
{
/**
* @var Timesheet
*/
private $original;
public function __construct(Timesheet $new, Timesheet $original)
{
parent::__construct($new);
$this->original = $original;
}
public function getOriginalTimesheet(): Timesheet
{
return $this->original;
}
}

View File

@@ -0,0 +1,31 @@
<?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\Event;
use App\Entity\Timesheet;
final class TimesheetDuplicatePreEvent extends AbstractTimesheetEvent
{
/**
* @var Timesheet
*/
private $original;
public function __construct(Timesheet $new, Timesheet $original)
{
parent::__construct($new);
$this->original = $original;
}
public function getOriginalTimesheet(): Timesheet
{
return $this->original;
}
}

View File

@@ -130,7 +130,12 @@ final class MenuSubscriber implements EventSubscriberInterface
if ($auth->isGranted('view_user')) {
$users = new MenuItemModel('user_admin', 'menu.admin_user', 'admin_user', [], $this->getIcon('user'));
$users->setChildRoutes(['admin_user_create', 'admin_user_delete', 'admin_user_permissions', 'user_profile', 'user_profile_edit', 'user_profile_password', 'user_profile_api_token', 'user_profile_roles', 'user_profile_teams', 'user_profile_preferences']);
$users->setChildRoutes(['admin_user_create', 'admin_user_delete', 'user_profile', 'user_profile_edit', 'user_profile_password', 'user_profile_api_token', 'user_profile_roles', 'user_profile_teams', 'user_profile_preferences']);
$menu->addChild($users);
}
if ($auth->isGranted('role_permissions')) {
$users = new MenuItemModel('admin_user_permissions', 'profile.roles', 'admin_user_permissions', [], $this->getIcon('permissions'));
$menu->addChild($users);
}

View File

@@ -159,6 +159,13 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setSection('theme')
->setType(CheckboxType::class),
(new UserPreference())
->setName('theme.update_browser_title')
->setValue(true)
->setOrder(550)
->setSection('theme')
->setType(CheckboxType::class),
(new UserPreference())
->setName('calendar.initial_view')
->setValue(CalendarViewType::DEFAULT_VIEW)

View File

@@ -21,9 +21,6 @@ class TagArrayToStringTransformer implements DataTransformerInterface
*/
private $tagRepository;
/**
* @param TagRepository $tagRepository
*/
public function __construct(TagRepository $tagRepository)
{
$this->tagRepository = $tagRepository;
@@ -33,7 +30,6 @@ class TagArrayToStringTransformer implements DataTransformerInterface
* Transforms an array of tags to a string.
*
* @param Tag[]|null $tags
*
* @return string
*/
public function transform($tags)
@@ -48,35 +44,34 @@ class TagArrayToStringTransformer implements DataTransformerInterface
/**
* Transforms a string to an array of tags.
*
* @param string|null $stringOfTags
* @see \Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer::reverseTransform()
*
* @param string|null $stringOfTags
* @return Tag[]
* @throws TransformationFailedException if object (issue) is not found
* @throws TransformationFailedException
*/
public function reverseTransform($stringOfTags)
{
// check for empty tag list
if (empty($stringOfTags)) {
if ('' === $stringOfTags || null === $stringOfTags) {
return [];
}
$names = array_filter(array_unique(array_map('trim', explode(',', $stringOfTags))));
// Get the current tags and find the new ones that should be created
// get the current tags and find the new ones that should be created
$tags = $this->tagRepository->findBy(['name' => $names]);
// works, because of the implicit case: (string) $tag
$newNames = array_diff($names, $tags);
foreach ($newNames as $name) {
$tag = new Tag();
$tag->setName($name);
$tags[] = $tag;
// There's no need to persist these new tags because Doctrine does that automatically
// thanks to the cascade={"persist"} option in the App\Entity\Timesheet::$tags property.
// new tags persist automatically thanks to the cascade={"persist"}
}
// Return an array of tags to transform them back into a Doctrine Collection.
// See Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer::reverseTransform()
return $tags;
}
}

View File

@@ -28,6 +28,9 @@ final class InvoiceDocumentRepository
}
}
/**
* @CloudRequired
*/
public function addDirectory(string $directory)
{
$this->documentDirs[] = $directory;
@@ -35,6 +38,9 @@ final class InvoiceDocumentRepository
return $this;
}
/**
* @CloudRequired
*/
public function removeDirectory(string $directory)
{
if (($key = array_search($directory, $this->documentDirs)) !== false) {
@@ -45,7 +51,7 @@ final class InvoiceDocumentRepository
}
/**
* @deprecated since 1.10 - will be removed with 2.0 - use getCustomInvoiceDirectory() instead
* @deprecated since 1.10 - will be removed with 2.0 - use getUploadDirectory() instead
*/
public function getCustomInvoiceDirectory(): string
{

View File

@@ -17,10 +17,6 @@ use Twig\TwigFunction;
class EncoreExtension extends AbstractExtension implements ServiceSubscriberInterface
{
/**
* @var EntrypointLookupInterface
*/
private $encoreService;
/**
* @var string
*/

View File

@@ -113,6 +113,9 @@ class AvatarService
return $this->directory;
}
/**
* @CloudRequired
*/
public function setStorageDirectory(string $directory)
{
$this->directory = realpath($directory);

View File

@@ -29,6 +29,9 @@ final class FileHelper
$this->filesystem = new Filesystem();
}
/**
* @CloudRequired
*/
public function setDataDirectory(string $directory)
{
$this->dataDir = $directory;

View File

@@ -19,7 +19,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A voter to check permissions on Activities.
*/
class ActivityVoter extends Voter
final class ActivityVoter extends Voter
{
/**
* support rules based on the given activity

View File

@@ -19,7 +19,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A voter to check authorization on Customers.
*/
class CustomerVoter extends Voter
final class CustomerVoter extends Voter
{
/**
* supported attributes/rules based on the given customer

View File

@@ -19,7 +19,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A voter to check permissions on Projects.
*/
class ProjectVoter extends Voter
final class ProjectVoter extends Voter
{
/**
* support rules based on the given project

View File

@@ -18,7 +18,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A voter to check the free-configurable permission from "kimai.permissions".
*/
class RolePermissionVoter extends Voter
final class RolePermissionVoter extends Voter
{
private $permissionManager;

View File

@@ -15,7 +15,7 @@ use App\Security\RolePermissionManager;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class TeamVoter extends Voter
final class TeamVoter extends Voter
{
/**
* support rules based on the given $subject (here: Team)

View File

@@ -19,7 +19,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A voter to check permissions on Timesheets.
*/
class TimesheetVoter extends Voter
final class TimesheetVoter extends Voter
{
public const VIEW = 'view';
public const START = 'start';

View File

@@ -17,7 +17,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A voter to check permissions on user profiles.
*/
class UserVoter extends Voter
final class UserVoter extends Voter
{
private const ALLOWED_ATTRIBUTES = [
'view',