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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
31
src/Event/TimesheetDuplicatePostEvent.php
Normal file
31
src/Event/TimesheetDuplicatePostEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
31
src/Event/TimesheetDuplicatePreEvent.php
Normal file
31
src/Event/TimesheetDuplicatePreEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -17,10 +17,6 @@ use Twig\TwigFunction;
|
||||
|
||||
class EncoreExtension extends AbstractExtension implements ServiceSubscriberInterface
|
||||
{
|
||||
/**
|
||||
* @var EntrypointLookupInterface
|
||||
*/
|
||||
private $encoreService;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
|
||||
@@ -113,6 +113,9 @@ class AvatarService
|
||||
return $this->directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* @CloudRequired
|
||||
*/
|
||||
public function setStorageDirectory(string $directory)
|
||||
{
|
||||
$this->directory = realpath($directory);
|
||||
|
||||
@@ -29,6 +29,9 @@ final class FileHelper
|
||||
$this->filesystem = new Filesystem();
|
||||
}
|
||||
|
||||
/**
|
||||
* @CloudRequired
|
||||
*/
|
||||
public function setDataDirectory(string $directory)
|
||||
{
|
||||
$this->dataDir = $directory;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user