User roles and permission management via Admin UI (#1231)
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,7 +5,9 @@
|
|||||||
/bin/*
|
/bin/*
|
||||||
!bin/console
|
!bin/console
|
||||||
/config/packages/local.yaml
|
/config/packages/local.yaml
|
||||||
|
/config/packages/*-local.yaml
|
||||||
/config/packages/*/local.yaml
|
/config/packages/*/local.yaml
|
||||||
|
/config/packages/*/*-local.yaml
|
||||||
|
|
||||||
public/avatars/*.png
|
public/avatars/*.png
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ you can upgrade your Kimai installation to the latest stable release.
|
|||||||
Check below if there are more version specific steps required, which need to be executed after the normal update process.
|
Check below if there are more version specific steps required, which need to be executed after the normal update process.
|
||||||
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
|
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
|
||||||
|
|
||||||
|
## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6)
|
||||||
|
|
||||||
|
- New functionality to manage permissions via Admin UI were added. Please move your permission settings from [local.yaml to your database](https://www.kimai.org/documentation/permissions.html)
|
||||||
|
- New database tables were created, don't forget to [run the migrations](https://www.kimai.org/documentation/updates.html)
|
||||||
|
|
||||||
|
### Developer
|
||||||
|
|
||||||
|
Please add default permissions to your [plugin](https://www.kimai.org/documentation/plugins.html).
|
||||||
|
|
||||||
## [1.5](https://github.com/kevinpapst/kimai2/releases/tag/1.5)
|
## [1.5](https://github.com/kevinpapst/kimai2/releases/tag/1.5)
|
||||||
|
|
||||||
[Update as usual](https://www.kimai.org/documentation/updates.html)
|
[Update as usual](https://www.kimai.org/documentation/updates.html)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import KimaiAutocomplete from "./plugins/KimaiAutocomplete";
|
|||||||
import KimaiFormSelect from "./plugins/KimaiFormSelect";
|
import KimaiFormSelect from "./plugins/KimaiFormSelect";
|
||||||
import KimaiForm from "./plugins/KimaiForm";
|
import KimaiForm from "./plugins/KimaiForm";
|
||||||
import KimaiDatePicker from "./plugins/KimaiDatePicker";
|
import KimaiDatePicker from "./plugins/KimaiDatePicker";
|
||||||
|
import KimaiConfirmationLink from "./plugins/KimaiConfirmationLink";
|
||||||
|
|
||||||
export default class KimaiLoader {
|
export default class KimaiLoader {
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ export default class KimaiLoader {
|
|||||||
kimai.registerPlugin(new KimaiAPI());
|
kimai.registerPlugin(new KimaiAPI());
|
||||||
kimai.registerPlugin(new KimaiAlert());
|
kimai.registerPlugin(new KimaiAlert());
|
||||||
kimai.registerPlugin(new KimaiFormSelect('.selectpicker'));
|
kimai.registerPlugin(new KimaiFormSelect('.selectpicker'));
|
||||||
|
kimai.registerPlugin(new KimaiConfirmationLink('confirmation-link'));
|
||||||
kimai.registerPlugin(new KimaiActiveRecordsDuration('[data-since]'));
|
kimai.registerPlugin(new KimaiActiveRecordsDuration('[data-since]'));
|
||||||
kimai.registerPlugin(new KimaiDatatableColumnView('data-column-visibility'));
|
kimai.registerPlugin(new KimaiDatatableColumnView('data-column-visibility'));
|
||||||
kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepickerenable="on"]'));
|
kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepickerenable="on"]'));
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* file that was distributed with this source code.
|
* file that was distributed with this source code.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import KimaiClickHandlerReducedInTableRow from "./KimaiClickHandlerReducedInTableRow";
|
import KimaiPlugin from "../KimaiPlugin";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Needs to be initialized with a class name.
|
* Needs to be initialized with a class name.
|
||||||
@@ -18,7 +18,7 @@ import KimaiClickHandlerReducedInTableRow from "./KimaiClickHandlerReducedInTabl
|
|||||||
*
|
*
|
||||||
* @param selector
|
* @param selector
|
||||||
*/
|
*/
|
||||||
export default class KimaiAPILink extends KimaiClickHandlerReducedInTableRow {
|
export default class KimaiAPILink extends KimaiPlugin {
|
||||||
|
|
||||||
constructor(selector) {
|
constructor(selector) {
|
||||||
super();
|
super();
|
||||||
|
|||||||
55
assets/js/plugins/KimaiConfirmationLink.js
Normal file
55
assets/js/plugins/KimaiConfirmationLink.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import KimaiPlugin from "../KimaiPlugin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Needs to be initialized with a class name.
|
||||||
|
*
|
||||||
|
* Allows to assign the given selector to any element, which then is used as click-handler
|
||||||
|
* calling an API method and trigger the event from data-event attribute afterwards.
|
||||||
|
*
|
||||||
|
* @param selector
|
||||||
|
*/
|
||||||
|
export default class KimaiConfirmationLink extends KimaiPlugin {
|
||||||
|
|
||||||
|
constructor(selector) {
|
||||||
|
super();
|
||||||
|
this.selector = selector;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const self = this;
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
let target = event.target;
|
||||||
|
while (target !== null && !target.matches('body')) {
|
||||||
|
if (target.classList.contains(self.selector)) {
|
||||||
|
const attributes = target.dataset;
|
||||||
|
|
||||||
|
let url = attributes['href'];
|
||||||
|
if (!url) {
|
||||||
|
url = target.getAttribute('href');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attributes.question !== undefined) {
|
||||||
|
self.getContainer().getPlugin('alert').question(attributes.question, function(value) {
|
||||||
|
if (value) {
|
||||||
|
document.location = url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
target = target.parentNode;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -175,7 +175,7 @@ kimai:
|
|||||||
user_duration:
|
user_duration:
|
||||||
title: stats.yourWorkingHours
|
title: stats.yourWorkingHours
|
||||||
order: 10
|
order: 10
|
||||||
permission: ROLE_USER
|
permission: view_own_timesheet
|
||||||
type: '\App\Widget\Type\CompoundChart'
|
type: '\App\Widget\Type\CompoundChart'
|
||||||
widgets: [DailyWorkingTimeChart, userDurationToday, userDurationWeek, userDurationMonth, userDurationYear]
|
widgets: [DailyWorkingTimeChart, userDurationToday, userDurationWeek, userDurationMonth, userDurationYear]
|
||||||
user_rates:
|
user_rates:
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ security:
|
|||||||
allow_if_all_abstain: false
|
allow_if_all_abstain: false
|
||||||
|
|
||||||
role_hierarchy:
|
role_hierarchy:
|
||||||
|
ROLE_USER: ~
|
||||||
ROLE_TEAMLEAD: ROLE_USER
|
ROLE_TEAMLEAD: ROLE_USER
|
||||||
ROLE_ADMIN: ROLE_TEAMLEAD
|
ROLE_ADMIN: ROLE_TEAMLEAD
|
||||||
ROLE_SUPER_ADMIN: ROLE_ADMIN
|
ROLE_SUPER_ADMIN: ROLE_ADMIN
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
app.test.sidebar_settings:
|
app.test.active_entries:
|
||||||
path: /{_locale}/sidebar/settings
|
path: /{_locale}/layou/active_entries
|
||||||
controller: App\Controller\SidebarController::settingsAction
|
controller: App\Controller\LayoutController::activeEntries
|
||||||
requirements:
|
requirements:
|
||||||
_locale: '%app_locales%'
|
_locale: '%app_locales%'
|
||||||
defaults:
|
defaults:
|
||||||
|
|||||||
@@ -135,7 +135,8 @@ services:
|
|||||||
# ================================================================================
|
# ================================================================================
|
||||||
|
|
||||||
App\Security\RoleService:
|
App\Security\RoleService:
|
||||||
arguments: ['%security.role_hierarchy.roles%']
|
arguments:
|
||||||
|
$roles: '%security.role_hierarchy.roles%'
|
||||||
|
|
||||||
App\Security\RolePermissionManager:
|
App\Security\RolePermissionManager:
|
||||||
arguments:
|
arguments:
|
||||||
@@ -198,6 +199,16 @@ services:
|
|||||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||||
arguments: ['App\Entity\Configuration']
|
arguments: ['App\Entity\Configuration']
|
||||||
|
|
||||||
|
App\Repository\RoleRepository:
|
||||||
|
class: Doctrine\ORM\EntityRepository
|
||||||
|
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||||
|
arguments: ['App\Entity\Role']
|
||||||
|
|
||||||
|
App\Repository\RolePermissionRepository:
|
||||||
|
class: Doctrine\ORM\EntityRepository
|
||||||
|
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||||
|
arguments: ['App\Entity\RolePermission']
|
||||||
|
|
||||||
App\Repository\InvoiceDocumentRepository:
|
App\Repository\InvoiceDocumentRepository:
|
||||||
class: App\Repository\InvoiceDocumentRepository
|
class: App\Repository\InvoiceDocumentRepository
|
||||||
arguments: ['%kimai.invoice.documents%']
|
arguments: ['%kimai.invoice.documents%']
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
|||||||
"build/runtime.4ee6be68.js",
|
"build/runtime.4ee6be68.js",
|
||||||
"build/0.a87622f3.js",
|
"build/0.a87622f3.js",
|
||||||
"build/1.c1bee41f.js",
|
"build/1.c1bee41f.js",
|
||||||
"build/app.52968b5f.js"
|
"build/app.f4954bf5.js"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
"build/app.4bdd4f2d.css"
|
"build/app.4bdd4f2d.css"
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"build/runtime.4ee6be68.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
|
"build/runtime.4ee6be68.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
|
||||||
"build/0.a87622f3.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok",
|
"build/0.a87622f3.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok",
|
||||||
"build/1.c1bee41f.js": "sha384-7UVWcP6Hefp2k/CrtGSITKXx4dSZqtvpAiU8WX7dClETkzMewrUjoCRtVXQ5j3KI",
|
"build/1.c1bee41f.js": "sha384-7UVWcP6Hefp2k/CrtGSITKXx4dSZqtvpAiU8WX7dClETkzMewrUjoCRtVXQ5j3KI",
|
||||||
"build/app.52968b5f.js": "sha384-yWBI0DIyKu0YrNSDgjIfdCEAEyw+d7qiiEVnY/AfDWljVigYuBlrHnBYsYdMccNT",
|
"build/app.f4954bf5.js": "sha384-7g3VJTabvDpytWUXASpINBO3z2Rsaum700b4qzbf30TryOoFJeMfzsOC/jxMCxXj",
|
||||||
"build/app.4bdd4f2d.css": "sha384-XpqGJV9ny/Fk0uJHisAZrAZfWfkzDvm5RJQOaa9ozf6nJstSXyEHziVAza9fIifm",
|
"build/app.4bdd4f2d.css": "sha384-XpqGJV9ny/Fk0uJHisAZrAZfWfkzDvm5RJQOaa9ozf6nJstSXyEHziVAza9fIifm",
|
||||||
"build/2.7be60d8d.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q",
|
"build/2.7be60d8d.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q",
|
||||||
"build/chart.0af3f813.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
|
"build/chart.0af3f813.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"build/1.c1bee41f.js": "build/1.c1bee41f.js",
|
"build/1.c1bee41f.js": "build/1.c1bee41f.js",
|
||||||
"build/2.7be60d8d.js": "build/2.7be60d8d.js",
|
"build/2.7be60d8d.js": "build/2.7be60d8d.js",
|
||||||
"build/app.css": "build/app.4bdd4f2d.css",
|
"build/app.css": "build/app.4bdd4f2d.css",
|
||||||
"build/app.js": "build/app.52968b5f.js",
|
"build/app.js": "build/app.f4954bf5.js",
|
||||||
"build/calendar.css": "build/calendar.36ef2a02.css",
|
"build/calendar.css": "build/calendar.36ef2a02.css",
|
||||||
"build/calendar.js": "build/calendar.6f94d071.js",
|
"build/calendar.js": "build/calendar.6f94d071.js",
|
||||||
"build/chart.js": "build/chart.0af3f813.js",
|
"build/chart.js": "build/chart.0af3f813.js",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ trait StringAccessibleConfigTrait
|
|||||||
*/
|
*/
|
||||||
protected function getConfigurations(ConfigLoaderInterface $repository): array
|
protected function getConfigurations(ConfigLoaderInterface $repository): array
|
||||||
{
|
{
|
||||||
return $repository->getConfiguration($this->getPrefix() . '.');
|
return $repository->getConfiguration($this->getPrefix());
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function prepare()
|
protected function prepare()
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
|||||||
public const DOMAIN_FLASH = 'flashmessages';
|
public const DOMAIN_FLASH = 'flashmessages';
|
||||||
public const DOMAIN_ERROR = 'exceptions';
|
public const DOMAIN_ERROR = 'exceptions';
|
||||||
|
|
||||||
public const ROLE_ADMIN = 'ROLE_ADMIN';
|
/**
|
||||||
|
* @deprecated since 1.6, will be removed with 2.0
|
||||||
|
*/
|
||||||
|
public const ROLE_ADMIN = User::ROLE_ADMIN;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return DataCollectorTranslator
|
* @return DataCollectorTranslator
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use Symfony\Component\Routing\Annotation\Route;
|
|||||||
* Controller used to display calendars.
|
* Controller used to display calendars.
|
||||||
*
|
*
|
||||||
* @Route(path="/calendar")
|
* @Route(path="/calendar")
|
||||||
* @Security("is_granted('ROLE_USER')")
|
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||||
*/
|
*/
|
||||||
class CalendarController extends AbstractController
|
class CalendarController extends AbstractController
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use Symfony\Component\Routing\Annotation\Route;
|
|||||||
* Dashboard controller for the admin area.
|
* Dashboard controller for the admin area.
|
||||||
*
|
*
|
||||||
* @Route(path="/dashboard")
|
* @Route(path="/dashboard")
|
||||||
* @Security("is_granted('ROLE_USER')")
|
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||||
*/
|
*/
|
||||||
class DashboardController extends AbstractController
|
class DashboardController extends AbstractController
|
||||||
{
|
{
|
||||||
@@ -121,9 +121,16 @@ class DashboardController extends AbstractController
|
|||||||
$this->eventDispatcher->dispatch($event);
|
$this->eventDispatcher->dispatch($event);
|
||||||
|
|
||||||
$sections = $event->getSections();
|
$sections = $event->getSections();
|
||||||
|
$clearedSections = [];
|
||||||
|
/** @var WidgetContainerInterface $section */
|
||||||
|
foreach ($sections as $key => $section) {
|
||||||
|
if (!empty($section->getWidgets())) {
|
||||||
|
$clearedSections[] = $section;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uasort(
|
uasort(
|
||||||
$sections,
|
$clearedSections,
|
||||||
function (WidgetContainerInterface $a, WidgetContainerInterface $b) {
|
function (WidgetContainerInterface $a, WidgetContainerInterface $b) {
|
||||||
if ($a->getOrder() == $b->getOrder()) {
|
if ($a->getOrder() == $b->getOrder()) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -134,7 +141,7 @@ class DashboardController extends AbstractController
|
|||||||
);
|
);
|
||||||
|
|
||||||
return $this->render('dashboard/index.html.twig', [
|
return $this->render('dashboard/index.html.twig', [
|
||||||
'widgets' => $sections
|
'widgets' => $clearedSections
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use Symfony\Component\Routing\Annotation\Route;
|
|||||||
* Homepage controller is a redirect controller with user specific logic.
|
* Homepage controller is a redirect controller with user specific logic.
|
||||||
*
|
*
|
||||||
* @Route(path="/homepage")
|
* @Route(path="/homepage")
|
||||||
* @Security("is_granted('ROLE_USER')")
|
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||||
*/
|
*/
|
||||||
class HomepageController extends AbstractController
|
class HomepageController extends AbstractController
|
||||||
{
|
{
|
||||||
|
|||||||
34
src/Controller/LayoutController.php
Normal file
34
src/Controller/LayoutController.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?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\Controller;
|
||||||
|
|
||||||
|
use App\Configuration\TimesheetConfiguration;
|
||||||
|
use App\Repository\TimesheetRepository;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for the (initial) page rendering.
|
||||||
|
*/
|
||||||
|
class LayoutController extends AbstractController
|
||||||
|
{
|
||||||
|
public function activeEntries(TimesheetRepository $repository, TimesheetConfiguration $configuration): Response
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
$activeEntries = $repository->getActiveEntries($user);
|
||||||
|
|
||||||
|
return $this->render(
|
||||||
|
'navbar/active-entries.html.twig',
|
||||||
|
[
|
||||||
|
'entries' => $activeEntries,
|
||||||
|
'soft_limit' => $configuration->getActiveEntriesSoftLimit(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/Controller/PermissionController.php
Normal file
161
src/Controller/PermissionController.php
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<?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\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Role;
|
||||||
|
use App\Entity\RolePermission;
|
||||||
|
use App\Form\RoleType;
|
||||||
|
use App\Repository\RolePermissionRepository;
|
||||||
|
use App\Repository\RoleRepository;
|
||||||
|
use App\Security\RolePermissionManager;
|
||||||
|
use App\Security\RoleService;
|
||||||
|
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller used to manage user roles and role permissions.
|
||||||
|
*
|
||||||
|
* @Route(path="/admin/permissions")
|
||||||
|
* @Security("is_granted('role_permissions')")
|
||||||
|
*/
|
||||||
|
final class PermissionController extends AbstractController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var RoleService
|
||||||
|
*/
|
||||||
|
private $roleService;
|
||||||
|
/**
|
||||||
|
* @var RolePermissionManager
|
||||||
|
*/
|
||||||
|
private $manager;
|
||||||
|
/**
|
||||||
|
* @var RoleRepository
|
||||||
|
*/
|
||||||
|
private $roleRepository;
|
||||||
|
|
||||||
|
public function __construct(RoleService $roleService, RolePermissionManager $manager, RoleRepository $roleRepository)
|
||||||
|
{
|
||||||
|
$this->roleService = $roleService;
|
||||||
|
$this->manager = $manager;
|
||||||
|
$this->roleRepository = $roleRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route(path="", name="admin_user_permissions", methods={"GET", "POST"})
|
||||||
|
* @Security("is_granted('role_permissions')")
|
||||||
|
*/
|
||||||
|
public function permissions()
|
||||||
|
{
|
||||||
|
$all = $this->roleRepository->findAll();
|
||||||
|
$existing = [];
|
||||||
|
|
||||||
|
foreach ($all as $role) {
|
||||||
|
$existing[] = $role->getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = array_map('strtoupper', $existing);
|
||||||
|
|
||||||
|
// automatically import all hard coded (default) roles into the database table
|
||||||
|
foreach ($this->roleService->getAvailableNames() as $roleName) {
|
||||||
|
$roleName = strtoupper($roleName);
|
||||||
|
if (!in_array($roleName, $existing)) {
|
||||||
|
$role = new Role();
|
||||||
|
$role->setName($roleName);
|
||||||
|
$this->roleRepository->saveRole($role);
|
||||||
|
$existing[] = $roleName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('user/permissions.html.twig', [
|
||||||
|
'roles' => $this->roleRepository->findAll(),
|
||||||
|
'permissions' => $this->manager->getPermissions(),
|
||||||
|
'manager' => $this->manager,
|
||||||
|
'system_roles' => $this->roleService->getSystemRoles(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route(path="/roles/create", name="admin_user_roles", methods={"GET", "POST"})
|
||||||
|
* @Security("is_granted('role_permissions')")
|
||||||
|
*/
|
||||||
|
public function createRole(Request $request): Response
|
||||||
|
{
|
||||||
|
$role = new Role();
|
||||||
|
|
||||||
|
$form = $this->createForm(RoleType::class, $role, [
|
||||||
|
'action' => $this->generateUrl('admin_user_roles', []),
|
||||||
|
'method' => 'POST',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
try {
|
||||||
|
$this->roleRepository->saveRole($role);
|
||||||
|
$this->flashSuccess('action.update.success');
|
||||||
|
} catch (\Exception $ex) {
|
||||||
|
$this->flashSuccess('action.update.error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_user_permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('user/edit_role.html.twig', [
|
||||||
|
'form' => $form->createView(),
|
||||||
|
'role' => $role,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route(path="/roles/{id}/delete", name="admin_user_role_delete", methods={"GET", "POST"})
|
||||||
|
* @Security("is_granted('role_permissions')")
|
||||||
|
*/
|
||||||
|
public function deleteRole(Role $role): Response
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->roleRepository->deleteRole($role);
|
||||||
|
$this->flashSuccess('action.delete.success');
|
||||||
|
} catch (\Exception $ex) {
|
||||||
|
$this->flashError('action.delete.error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_user_permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route(path="/roles/{id}/{name}/{value}", name="admin_user_permission_save", methods={"GET"})
|
||||||
|
* @Security("is_granted('role_permissions')")
|
||||||
|
*/
|
||||||
|
public function savePermission(Role $role, string $name, string $value, RolePermissionRepository $rolePermissionRepository): Response
|
||||||
|
{
|
||||||
|
if (!$this->manager->isRegisteredPermission($name)) {
|
||||||
|
throw $this->createNotFoundException('Unknown permission: ' . $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$permission = $rolePermissionRepository->findRolePermission($role, $name);
|
||||||
|
if (null === $permission) {
|
||||||
|
$permission = new RolePermission();
|
||||||
|
$permission->setRole($role);
|
||||||
|
$permission->setPermission($name);
|
||||||
|
}
|
||||||
|
$permission->setAllowed((bool) $value);
|
||||||
|
|
||||||
|
$rolePermissionRepository->saveRolePermission($permission);
|
||||||
|
$this->flashSuccess('action.update.success');
|
||||||
|
} catch (\Exception $ex) {
|
||||||
|
$this->flashError('action.update.error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_user_permissions');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,23 +78,4 @@ class TimesheetController extends TimesheetAbstractController
|
|||||||
{
|
{
|
||||||
return $this->create($request, 'timesheet/edit.html.twig', $projectRepository, $activityRepository, $tagRepository);
|
return $this->create($request, 'timesheet/edit.html.twig', $projectRepository, $activityRepository, $tagRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Used for the initial page rendering.
|
|
||||||
*
|
|
||||||
* @return Response
|
|
||||||
*/
|
|
||||||
public function activeEntriesAction()
|
|
||||||
{
|
|
||||||
$user = $this->getUser();
|
|
||||||
$activeEntries = $this->getRepository()->getActiveEntries($user);
|
|
||||||
|
|
||||||
return $this->render(
|
|
||||||
'navbar/active-entries.html.twig',
|
|
||||||
[
|
|
||||||
'entries' => $activeEntries,
|
|
||||||
'soft_limit' => $this->getSoftLimit(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ use App\Form\UserCreateType;
|
|||||||
use App\Repository\Query\UserQuery;
|
use App\Repository\Query\UserQuery;
|
||||||
use App\Repository\TimesheetRepository;
|
use App\Repository\TimesheetRepository;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
use App\Security\RolePermissionManager;
|
|
||||||
use Pagerfanta\Pagerfanta;
|
use Pagerfanta\Pagerfanta;
|
||||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
|
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
|
||||||
|
|
||||||
@@ -64,12 +65,8 @@ class UserController extends AbstractController
|
|||||||
* @Route(path="/", defaults={"page": 1}, name="admin_user", methods={"GET"})
|
* @Route(path="/", defaults={"page": 1}, name="admin_user", methods={"GET"})
|
||||||
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_user_paginated", methods={"GET"})
|
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_user_paginated", methods={"GET"})
|
||||||
* @Security("is_granted('view_user')")
|
* @Security("is_granted('view_user')")
|
||||||
*
|
|
||||||
* @param int $page
|
|
||||||
* @param Request $request
|
|
||||||
* @return \Symfony\Component\HttpFoundation\Response
|
|
||||||
*/
|
*/
|
||||||
public function indexAction($page, Request $request)
|
public function indexAction($page, Request $request): Response
|
||||||
{
|
{
|
||||||
$query = new UserQuery();
|
$query = new UserQuery();
|
||||||
$query->setPage($page);
|
$query->setPage($page);
|
||||||
@@ -99,11 +96,8 @@ class UserController extends AbstractController
|
|||||||
/**
|
/**
|
||||||
* @Route(path="/create", name="admin_user_create", methods={"GET", "POST"})
|
* @Route(path="/create", name="admin_user_create", methods={"GET", "POST"})
|
||||||
* @Security("is_granted('create_user')")
|
* @Security("is_granted('create_user')")
|
||||||
*
|
|
||||||
* @param Request $request
|
|
||||||
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
|
|
||||||
*/
|
*/
|
||||||
public function createAction(Request $request)
|
public function createAction(Request $request): Response
|
||||||
{
|
{
|
||||||
$user = new User();
|
$user = new User();
|
||||||
$user->setEnabled(true);
|
$user->setEnabled(true);
|
||||||
@@ -144,14 +138,8 @@ class UserController extends AbstractController
|
|||||||
/**
|
/**
|
||||||
* @Route(path="/{id}/delete", name="admin_user_delete", methods={"GET", "POST"})
|
* @Route(path="/{id}/delete", name="admin_user_delete", methods={"GET", "POST"})
|
||||||
* @Security("is_granted('delete', userToDelete)")
|
* @Security("is_granted('delete', userToDelete)")
|
||||||
*
|
|
||||||
* @param User $userToDelete
|
|
||||||
* @param Request $request
|
|
||||||
* @param TimesheetRepository $repository
|
|
||||||
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
|
|
||||||
* @throws \Doctrine\ORM\NonUniqueResultException
|
|
||||||
*/
|
*/
|
||||||
public function deleteAction(User $userToDelete, Request $request, TimesheetRepository $repository)
|
public function deleteAction(User $userToDelete, Request $request, TimesheetRepository $repository): Response
|
||||||
{
|
{
|
||||||
// $userToDelete MUST not be called $user, as $user is always the current user!
|
// $userToDelete MUST not be called $user, as $user is always the current user!
|
||||||
$stats = $repository->getUserStatistics($userToDelete);
|
$stats = $repository->getUserStatistics($userToDelete);
|
||||||
@@ -189,27 +177,7 @@ class UserController extends AbstractController
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function getToolbarForm(UserQuery $query): FormInterface
|
||||||
* @Route(path="/permissions", name="admin_user_permissions", methods={"GET", "POST"})
|
|
||||||
* @Security("is_granted('role_permissions')")
|
|
||||||
*
|
|
||||||
* @param RolePermissionManager $manager
|
|
||||||
* @return \Symfony\Component\HttpFoundation\Response
|
|
||||||
*/
|
|
||||||
public function permissions(RolePermissionManager $manager)
|
|
||||||
{
|
|
||||||
return $this->render('user/permissions.html.twig', [
|
|
||||||
'roles' => $manager->getRoles(),
|
|
||||||
'permissions' => $manager->getPermissions(),
|
|
||||||
'manager' => $manager,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param UserQuery $query
|
|
||||||
* @return \Symfony\Component\Form\FormInterface
|
|
||||||
*/
|
|
||||||
protected function getToolbarForm(UserQuery $query)
|
|
||||||
{
|
{
|
||||||
return $this->createForm(UserToolbarForm::class, $query, [
|
return $this->createForm(UserToolbarForm::class, $query, [
|
||||||
'action' => $this->generateUrl('admin_user', [
|
'action' => $this->generateUrl('admin_user', [
|
||||||
@@ -219,11 +187,7 @@ class UserController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function createEditForm(User $user): FormInterface
|
||||||
* @param User $user
|
|
||||||
* @return \Symfony\Component\Form\FormInterface
|
|
||||||
*/
|
|
||||||
private function createEditForm(User $user)
|
|
||||||
{
|
{
|
||||||
return $this->createForm(UserCreateType::class, $user, [
|
return $this->createForm(UserCreateType::class, $user, [
|
||||||
'action' => $this->generateUrl('admin_user_create'),
|
'action' => $this->generateUrl('admin_user_create'),
|
||||||
|
|||||||
@@ -525,7 +525,6 @@ class Configuration implements ConfigurationInterface
|
|||||||
->requiresAtLeastOneElement()
|
->requiresAtLeastOneElement()
|
||||||
->useAttributeAsKey('key')
|
->useAttributeAsKey('key')
|
||||||
->arrayPrototype()
|
->arrayPrototype()
|
||||||
->useAttributeAsKey('key')
|
|
||||||
->isRequired()
|
->isRequired()
|
||||||
->scalarPrototype()->end()
|
->scalarPrototype()->end()
|
||||||
->defaultValue([])
|
->defaultValue([])
|
||||||
|
|||||||
59
src/Entity/Role.php
Normal file
59
src/Entity/Role.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?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\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Table(name="kimai2_roles",
|
||||||
|
* uniqueConstraints={
|
||||||
|
* @ORM\UniqueConstraint(name="roles_name", columns={"name"})
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* @ORM\Entity(repositoryClass="App\Repository\RoleRepository")
|
||||||
|
* @UniqueEntity("name")
|
||||||
|
*/
|
||||||
|
class Role
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*
|
||||||
|
* @ORM\Column(name="id", type="integer")
|
||||||
|
* @ORM\Id
|
||||||
|
* @ORM\GeneratedValue(strategy="IDENTITY")
|
||||||
|
*/
|
||||||
|
private $id;
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*
|
||||||
|
* @ORM\Column(name="name", type="string", length=50, nullable=false)
|
||||||
|
* @Assert\Length(min=5, max=50)
|
||||||
|
*/
|
||||||
|
private $name;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): Role
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/Entity/RolePermission.php
Normal file
101
src/Entity/RolePermission.php
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?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\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Table(name="kimai2_roles_permissions",
|
||||||
|
* uniqueConstraints={
|
||||||
|
* @ORM\UniqueConstraint(name="role_permission", columns={"role_id","permission"})
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* @ORM\Entity(repositoryClass="App\Repository\RolePermissionRepository")
|
||||||
|
* @UniqueEntity({"role", "permission"})
|
||||||
|
*/
|
||||||
|
class RolePermission
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*
|
||||||
|
* @ORM\Column(name="id", type="integer")
|
||||||
|
* @ORM\Id
|
||||||
|
* @ORM\GeneratedValue(strategy="IDENTITY")
|
||||||
|
*/
|
||||||
|
private $id;
|
||||||
|
/**
|
||||||
|
* @var Role
|
||||||
|
*
|
||||||
|
* @ORM\ManyToOne(targetEntity="App\Entity\Role")
|
||||||
|
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
|
||||||
|
* @Assert\NotNull()
|
||||||
|
*/
|
||||||
|
private $role;
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*
|
||||||
|
* @ORM\Column(name="permission", type="string", length=50, nullable=false)
|
||||||
|
* @Assert\Length(max=50)
|
||||||
|
*/
|
||||||
|
private $permission;
|
||||||
|
/**
|
||||||
|
* @var bool
|
||||||
|
*
|
||||||
|
* @ORM\Column(name="allowed", type="boolean", nullable=false, options={"default": false})
|
||||||
|
* @Assert\NotNull()
|
||||||
|
*/
|
||||||
|
private $allowed = false;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRole(): ?Role
|
||||||
|
{
|
||||||
|
return $this->role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRole(Role $role): RolePermission
|
||||||
|
{
|
||||||
|
$this->role = $role;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPermission(): ?string
|
||||||
|
{
|
||||||
|
return $this->permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPermission(string $permission): RolePermission
|
||||||
|
{
|
||||||
|
$this->permission = $permission;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias for isValue()
|
||||||
|
*/
|
||||||
|
public function isAllowed(): bool
|
||||||
|
{
|
||||||
|
return $this->allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAllowed(bool $allowed): RolePermission
|
||||||
|
{
|
||||||
|
$this->allowed = $allowed;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,10 +82,6 @@ class DashboardSubscriber implements EventSubscriberInterface
|
|||||||
*/
|
*/
|
||||||
public function onDashboardEvent(DashboardEvent $event)
|
public function onDashboardEvent(DashboardEvent $event)
|
||||||
{
|
{
|
||||||
if (!$this->security->isGranted(User::ROLE_ADMIN)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$section = new CompoundRow();
|
$section = new CompoundRow();
|
||||||
$section->setTitle('ROLE_ADMIN');
|
$section->setTitle('ROLE_ADMIN');
|
||||||
$section->setOrder(100);
|
$section->setOrder(100);
|
||||||
|
|||||||
79
src/Form/RoleType.php
Normal file
79
src/Form/RoleType.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?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\Form;
|
||||||
|
|
||||||
|
use App\Entity\Role;
|
||||||
|
use FOS\RestBundle\Validator\Constraints\Regex;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\CallbackTransformer;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form used to edit roles.
|
||||||
|
*/
|
||||||
|
class RoleType extends AbstractType
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('name', TextType::class, [
|
||||||
|
'label' => 'label.name',
|
||||||
|
'help' => 'Allowed character: A-Z and _',
|
||||||
|
'constraints' => [
|
||||||
|
new Regex(['pattern' => '/^[a-zA-Z_]{5,}$/'])
|
||||||
|
],
|
||||||
|
'attr' => [
|
||||||
|
'maxlength' => 50
|
||||||
|
]
|
||||||
|
])
|
||||||
|
;
|
||||||
|
|
||||||
|
// help the user to figure out the allowed name
|
||||||
|
$builder->get('name')->addViewTransformer(
|
||||||
|
new CallbackTransformer(
|
||||||
|
function ($roleName) {
|
||||||
|
if (is_string($roleName)) {
|
||||||
|
$roleName = str_replace(' ', '_', $roleName);
|
||||||
|
$roleName = str_replace('-', '_', $roleName);
|
||||||
|
$roleName = strtoupper($roleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $roleName;
|
||||||
|
},
|
||||||
|
function ($roleName) {
|
||||||
|
return $roleName;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function configureOptions(OptionsResolver $resolver)
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => Role::class,
|
||||||
|
'csrf_protection' => true,
|
||||||
|
'csrf_field_name' => '_token',
|
||||||
|
'csrf_token_id' => 'edit_role',
|
||||||
|
'attr' => [
|
||||||
|
'data-form-event' => 'kimai.userRoleUpdate',
|
||||||
|
'data-msg-success' => 'action.update.success',
|
||||||
|
'data-msg-error' => 'action.update.error',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ class UserRoleType extends AbstractType
|
|||||||
$resolver->setDefault('choices', function (Options $options) {
|
$resolver->setDefault('choices', function (Options $options) {
|
||||||
$roles = [];
|
$roles = [];
|
||||||
foreach ($this->roles->getAvailableNames() as $name) {
|
foreach ($this->roles->getAvailableNames() as $name) {
|
||||||
$roles[$name] = $name;
|
$roles[$name] = strtoupper($name);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($options['include_default'] !== true && isset($roles[User::DEFAULT_ROLE])) {
|
if ($options['include_default'] !== true && isset($roles[User::DEFAULT_ROLE])) {
|
||||||
|
|||||||
67
src/Migrations/Version20191108151534.php
Normal file
67
src/Migrations/Version20191108151534.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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 DoctrineMigrations;
|
||||||
|
|
||||||
|
use App\Doctrine\AbstractMigration;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the user roles and role permissions table
|
||||||
|
*
|
||||||
|
* @version 1.6
|
||||||
|
*/
|
||||||
|
final class Version20191108151534 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Adds the user roles and role permissions table';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isSupportingForeignKeys(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isTransactional(): bool
|
||||||
|
{
|
||||||
|
if ($this->isPlatformSqlite()) {
|
||||||
|
// does fail if we use transactions, as tables are re-created and foreign keys would fail
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$roles = $schema->createTable('kimai2_roles');
|
||||||
|
$roles->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
|
||||||
|
$roles->addColumn('name', 'string', ['notnull' => true, 'length' => 50]);
|
||||||
|
$roles->setPrimaryKey(['id']);
|
||||||
|
$roles->addUniqueIndex(['name'], 'roles_name');
|
||||||
|
|
||||||
|
$rolePermissions = $schema->createTable('kimai2_roles_permissions');
|
||||||
|
$rolePermissions->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
|
||||||
|
$rolePermissions->addColumn('role_id', 'integer', ['length' => 11, 'notnull' => true]);
|
||||||
|
$rolePermissions->addColumn('permission', 'string', ['notnull' => true, 'length' => 50]);
|
||||||
|
$rolePermissions->addColumn('allowed', 'boolean', ['notnull' => true, 'default' => false]);
|
||||||
|
$rolePermissions->setPrimaryKey(['id']);
|
||||||
|
$rolePermissions->addUniqueIndex(['role_id', 'permission'], 'role_permission');
|
||||||
|
$rolePermissions->addForeignKeyConstraint('kimai2_roles', ['role_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_D263A3B8D60322AC');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$schema->dropTable('kimai2_roles_permissions');
|
||||||
|
$schema->dropTable('kimai2_roles');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,27 +14,53 @@ use App\Entity\Configuration;
|
|||||||
use App\Form\Model\SystemConfiguration;
|
use App\Form\Model\SystemConfiguration;
|
||||||
use Doctrine\ORM\EntityRepository;
|
use Doctrine\ORM\EntityRepository;
|
||||||
use Doctrine\ORM\ORMException;
|
use Doctrine\ORM\ORMException;
|
||||||
use Doctrine\ORM\Query;
|
|
||||||
|
|
||||||
class ConfigurationRepository extends EntityRepository implements ConfigLoaderInterface
|
class ConfigurationRepository extends EntityRepository implements ConfigLoaderInterface
|
||||||
{
|
{
|
||||||
|
private static $cacheByPrefix = null;
|
||||||
|
private static $cacheAll = [];
|
||||||
|
|
||||||
|
private function clearCache()
|
||||||
|
{
|
||||||
|
static::$cacheByPrefix = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function prefillCache()
|
||||||
|
{
|
||||||
|
if (null !== static::$cacheByPrefix) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Configuration[] $configs */
|
||||||
|
$configs = $this->findAll();
|
||||||
|
static::$cacheByPrefix = [];
|
||||||
|
foreach ($configs as $config) {
|
||||||
|
$key = substr($config->getName(), 0, strpos($config->getName(), '.'));
|
||||||
|
if (!array_key_exists($key, static::$cacheByPrefix)) {
|
||||||
|
static::$cacheByPrefix[$key] = [];
|
||||||
|
}
|
||||||
|
static::$cacheByPrefix[$key][] = $config;
|
||||||
|
static::$cacheAll[] = $config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $prefix
|
* @param string $prefix
|
||||||
* @return Configuration[]
|
* @return Configuration[]
|
||||||
*/
|
*/
|
||||||
public function getConfiguration(?string $prefix = null): array
|
public function getConfiguration(?string $prefix = null): array
|
||||||
{
|
{
|
||||||
|
$this->prefillCache();
|
||||||
|
|
||||||
if (null === $prefix) {
|
if (null === $prefix) {
|
||||||
return $this->findAll();
|
return static::$cacheAll;
|
||||||
}
|
}
|
||||||
|
|
||||||
$qb = $this->createQueryBuilder('c');
|
if (!array_key_exists($prefix, static::$cacheByPrefix)) {
|
||||||
$qb
|
return [];
|
||||||
->select('c')
|
}
|
||||||
->where($qb->expr()->like('c.name', ':prefix'))
|
|
||||||
->setParameter(':prefix', $prefix . '%');
|
|
||||||
|
|
||||||
return $qb->getQuery()->getResult(Query::HYDRATE_OBJECT);
|
return static::$cacheByPrefix[$prefix];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveSystemConfiguration(SystemConfiguration $model)
|
public function saveSystemConfiguration(SystemConfiguration $model)
|
||||||
@@ -68,5 +94,7 @@ class ConfigurationRepository extends EntityRepository implements ConfigLoaderIn
|
|||||||
$em->rollback();
|
$em->rollback();
|
||||||
throw $ex;
|
throw $ex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->clearCache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/Repository/RolePermissionRepository.php
Normal file
40
src/Repository/RolePermissionRepository.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?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\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Role;
|
||||||
|
use App\Entity\RolePermission;
|
||||||
|
use Doctrine\ORM\AbstractQuery;
|
||||||
|
use Doctrine\ORM\EntityRepository;
|
||||||
|
|
||||||
|
class RolePermissionRepository extends EntityRepository
|
||||||
|
{
|
||||||
|
public function saveRolePermission(RolePermission $permission)
|
||||||
|
{
|
||||||
|
$entityManager = $this->getEntityManager();
|
||||||
|
$entityManager->persist($permission);
|
||||||
|
$entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findRolePermission(Role $role, string $permission)
|
||||||
|
{
|
||||||
|
return $this->findOneBy(['role' => $role, 'permission' => $permission]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllAsArray()
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('rp');
|
||||||
|
|
||||||
|
$qb->select('r.name as role,rp.permission,rp.allowed')
|
||||||
|
->leftJoin('rp.role', 'r');
|
||||||
|
|
||||||
|
return $qb->getQuery()->execute([], AbstractQuery::HYDRATE_ARRAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/Repository/RoleRepository.php
Normal file
47
src/Repository/RoleRepository.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?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\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Role;
|
||||||
|
use Doctrine\ORM\EntityRepository;
|
||||||
|
use Doctrine\ORM\ORMException;
|
||||||
|
|
||||||
|
class RoleRepository extends EntityRepository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return Role[]
|
||||||
|
*/
|
||||||
|
public function findAll()
|
||||||
|
{
|
||||||
|
return parent::findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveRole(Role $role)
|
||||||
|
{
|
||||||
|
$entityManager = $this->getEntityManager();
|
||||||
|
$entityManager->persist($role);
|
||||||
|
$entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteRole(Role $role)
|
||||||
|
{
|
||||||
|
$em = $this->getEntityManager();
|
||||||
|
$em->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$em->remove($role);
|
||||||
|
$em->flush();
|
||||||
|
$em->commit();
|
||||||
|
} catch (ORMException $ex) {
|
||||||
|
$em->rollback();
|
||||||
|
throw $ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,32 +9,61 @@
|
|||||||
|
|
||||||
namespace App\Security;
|
namespace App\Security;
|
||||||
|
|
||||||
class RolePermissionManager
|
use App\Entity\User;
|
||||||
|
use App\Repository\RolePermissionRepository;
|
||||||
|
|
||||||
|
final class RolePermissionManager
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var array
|
* @var array
|
||||||
*/
|
*/
|
||||||
protected $permissions = [];
|
private $permissions = [];
|
||||||
/**
|
/**
|
||||||
* @var string[]
|
* @var string[]
|
||||||
*/
|
*/
|
||||||
protected $knownPermissions = [];
|
private $knownPermissions = [];
|
||||||
/**
|
|
||||||
* @var RoleService
|
|
||||||
*/
|
|
||||||
private $roles;
|
|
||||||
|
|
||||||
public function __construct(RoleService $roles, array $permissions)
|
public function __construct(RolePermissionRepository $repository, array $permissions)
|
||||||
{
|
{
|
||||||
$this->roles = $roles;
|
|
||||||
$this->permissions = $permissions;
|
$this->permissions = $permissions;
|
||||||
|
|
||||||
foreach ($permissions as $role => $perms) {
|
foreach ($permissions as $role => $perms) {
|
||||||
$this->knownPermissions = array_merge($this->knownPermissions, $perms);
|
$this->knownPermissions = array_merge($this->knownPermissions, $perms);
|
||||||
}
|
}
|
||||||
$this->knownPermissions = array_unique($this->knownPermissions);
|
$this->knownPermissions = array_unique($this->knownPermissions);
|
||||||
|
|
||||||
|
$all = $repository->getAllAsArray();
|
||||||
|
foreach ($all as $item) {
|
||||||
|
$perm = $item['permission'];
|
||||||
|
$role = strtoupper($item['role']);
|
||||||
|
$isAllowed = $item['allowed'];
|
||||||
|
|
||||||
|
// see permissions.html.twig for this special case
|
||||||
|
if ($role === User::ROLE_SUPER_ADMIN && in_array($perm, ['role_permissions', 'view_user'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isAllowed) {
|
||||||
|
if (array_key_exists($role, $this->permissions)) {
|
||||||
|
if (($key = array_search($perm, $this->permissions[$role])) !== false) {
|
||||||
|
unset($this->permissions[$role][$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!array_key_exists($role, $this->permissions)) {
|
||||||
|
$this->permissions[$role] = [];
|
||||||
|
}
|
||||||
|
$this->permissions[$role][] = $perm;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only permissions which were registered through the Symfony configuration stack will be acknowledged here.
|
||||||
|
*
|
||||||
|
* @param string $permission
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
public function isRegisteredPermission(string $permission): bool
|
public function isRegisteredPermission(string $permission): bool
|
||||||
{
|
{
|
||||||
return in_array($permission, $this->knownPermissions);
|
return in_array($permission, $this->knownPermissions);
|
||||||
@@ -42,6 +71,8 @@ class RolePermissionManager
|
|||||||
|
|
||||||
public function hasPermission(string $role, string $permission): bool
|
public function hasPermission(string $role, string $permission): bool
|
||||||
{
|
{
|
||||||
|
$role = strtoupper($role);
|
||||||
|
|
||||||
if (!isset($this->permissions[$role])) {
|
if (!isset($this->permissions[$role])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -49,11 +80,11 @@ class RolePermissionManager
|
|||||||
return in_array($permission, $this->permissions[$role]);
|
return in_array($permission, $this->permissions[$role]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRoles(): array
|
/**
|
||||||
{
|
* Only permissions which were registered through the Symfony configuration stack will be returned here.
|
||||||
return $this->roles->getAvailableNames();
|
*
|
||||||
}
|
* @return array
|
||||||
|
*/
|
||||||
public function getPermissions(): array
|
public function getPermissions(): array
|
||||||
{
|
{
|
||||||
return $this->knownPermissions;
|
return $this->knownPermissions;
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
|
|
||||||
namespace App\Security;
|
namespace App\Security;
|
||||||
|
|
||||||
|
use App\Entity\Role;
|
||||||
|
use App\Repository\RoleRepository;
|
||||||
|
|
||||||
final class RoleService
|
final class RoleService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -19,13 +22,18 @@ final class RoleService
|
|||||||
* @var string[]
|
* @var string[]
|
||||||
*/
|
*/
|
||||||
private $roleNames = [];
|
private $roleNames = [];
|
||||||
|
/**
|
||||||
|
* @var RoleRepository
|
||||||
|
*/
|
||||||
|
private $repository;
|
||||||
|
|
||||||
public function __construct(array $roles)
|
public function __construct(RoleRepository $repository, array $roles)
|
||||||
{
|
{
|
||||||
|
$this->repository = $repository;
|
||||||
$this->roles = $roles;
|
$this->roles = $roles;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getAvailableNames(): array
|
private function cacheNames()
|
||||||
{
|
{
|
||||||
if (empty($this->roleNames)) {
|
if (empty($this->roleNames)) {
|
||||||
$roles = [];
|
$roles = [];
|
||||||
@@ -38,9 +46,24 @@ final class RoleService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var Role $item */
|
||||||
|
foreach ($this->repository->findAll() as $item) {
|
||||||
|
$roles[] = $item->getName();
|
||||||
|
}
|
||||||
|
|
||||||
$this->roleNames = array_values(array_unique($roles));
|
$this->roleNames = array_values(array_unique($roles));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAvailableNames(): array
|
||||||
|
{
|
||||||
|
$this->cacheNames();
|
||||||
|
|
||||||
return $this->roleNames;
|
return $this->roleNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getSystemRoles(): array
|
||||||
|
{
|
||||||
|
return $this->roles;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ final class IconExtension extends AbstractExtension
|
|||||||
'profile-stats' => 'far fa-chart-bar',
|
'profile-stats' => 'far fa-chart-bar',
|
||||||
'project' => 'fas fa-briefcase',
|
'project' => 'fas fa-briefcase',
|
||||||
'repeat' => 'fas fa-redo-alt',
|
'repeat' => 'fas fa-redo-alt',
|
||||||
|
'roles' => 'fas fa-user-shield',
|
||||||
'search' => 'fas fa-search',
|
'search' => 'fas fa-search',
|
||||||
'settings' => 'fas fa-cog',
|
'settings' => 'fas fa-cog',
|
||||||
'shop' => 'fas fa-shopping-cart',
|
'shop' => 'fas fa-shopping-cart',
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ class RoleValidator extends ConstraintValidator
|
|||||||
$roles = [$roles];
|
$roles = [$roles];
|
||||||
}
|
}
|
||||||
|
|
||||||
$allowedRoles = $this->service->getAvailableNames();
|
// the fos user entity uppercases the roles by default
|
||||||
|
$allowedRoles = array_map('strtoupper', $this->service->getAvailableNames());
|
||||||
|
|
||||||
foreach ($roles as $role) {
|
foreach ($roles as $role) {
|
||||||
if (!is_string($role) || !in_array($role, $allowedRoles)) {
|
if (!is_string($role) || !in_array($role, $allowedRoles)) {
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ abstract class AbstractVoter extends Voter
|
|||||||
*/
|
*/
|
||||||
protected $roleManager;
|
protected $roleManager;
|
||||||
|
|
||||||
/**
|
|
||||||
* @param AclDecisionManager $decisionManager
|
|
||||||
* @param RolePermissionManager $roleManager
|
|
||||||
*/
|
|
||||||
public function __construct(AclDecisionManager $decisionManager, RolePermissionManager $roleManager)
|
public function __construct(AclDecisionManager $decisionManager, RolePermissionManager $roleManager)
|
||||||
{
|
{
|
||||||
$this->decisionManager = $decisionManager;
|
$this->decisionManager = $decisionManager;
|
||||||
|
|||||||
@@ -30,13 +30,7 @@ class RolePermissionVoter extends AbstractVoter
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// and which is not neither a user role like USER_ADMIN
|
return $this->isRegisteredPermission($attribute);
|
||||||
// nor an implicit role like IS_REMEMBERED / IS_FULLY_AUTHENTICATED
|
|
||||||
if (strpos($attribute, 'ROLE_') === false && strpos($attribute, 'IS_') === false) {
|
|
||||||
return $this->isRegisteredPermission($attribute);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,12 +47,6 @@ class RolePermissionVoter extends AbstractVoter
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($user->getRoles() as $role) {
|
return $this->hasRolePermission($user, $attribute);
|
||||||
if ($this->hasPermission($role, $attribute)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,15 +82,8 @@ class UserVoter extends AbstractVoter
|
|||||||
|
|
||||||
return $this->hasRolePermission($user, 'delete_user');
|
return $this->hasRolePermission($user, 'delete_user');
|
||||||
|
|
||||||
// used in templates and ProfileController
|
|
||||||
case self::VIEW:
|
case self::VIEW:
|
||||||
case self::EDIT:
|
case self::EDIT:
|
||||||
// always allow the user to edit these own settings
|
|
||||||
if ($subject->getId() === $user->getId()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// no break on purpose
|
|
||||||
|
|
||||||
case self::PREFERENCES:
|
case self::PREFERENCES:
|
||||||
case self::PASSWORD:
|
case self::PASSWORD:
|
||||||
case self::API_TOKEN:
|
case self::API_TOKEN:
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% block navbar_extensions %}{% endblock %}
|
{% block navbar_extensions %}{% endblock %}
|
||||||
{{ render(controller('App\\Controller\\TimesheetController::activeEntriesAction')) }}
|
{{ render(controller('App\\Controller\\LayoutController::activeEntries')) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,12 @@
|
|||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
{% for row in widgets %}
|
{% if widgets is empty %}
|
||||||
{{ render_widget(row) }}
|
{{ widgets.callout('warning', 'error.no_entries_found') }}
|
||||||
{% endfor %}
|
{% else %}
|
||||||
|
{% for row in widgets %}
|
||||||
|
{{ render_widget(row) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -39,7 +39,13 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro datatable_header(tableName, columns, query, options) %}
|
{% macro datatable_header(tableName, columns, query, options) %}
|
||||||
{% set orderBy = options.orderBy|default(query.orderBy) %}
|
{% if query is not null %}
|
||||||
|
{% set orderBy = options.orderBy|default(query.orderBy) %}
|
||||||
|
{% set order = query.order|lower %}
|
||||||
|
{% else %}
|
||||||
|
{% set orderBy = false %}
|
||||||
|
{% set order = false %}
|
||||||
|
{% endif %}
|
||||||
{% set striped = options.striped|default(true) %}
|
{% set striped = options.striped|default(true) %}
|
||||||
{% set reloadEvent = options.reload|default('') %}
|
{% set reloadEvent = options.reload|default('') %}
|
||||||
{% set translationDomain = options.translationDomain|default('messages') %}
|
{% set translationDomain = options.translationDomain|default('messages') %}
|
||||||
@@ -63,12 +69,16 @@
|
|||||||
{% set headerOptions = {'class': headerOptions} %}
|
{% set headerOptions = {'class': headerOptions} %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not headerOptions.orderBy is defined %}
|
{% if not headerOptions.orderBy is defined %}
|
||||||
{% set headerOptions = headerOptions|merge({'orderBy': title}) %}
|
{% if orderBy is same as(false) %}
|
||||||
|
{% set headerOptions = headerOptions|merge({'orderBy': false}) %}
|
||||||
|
{% else %}
|
||||||
|
{% set headerOptions = headerOptions|merge({'orderBy': title}) %}
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% set headerClass = macro.data_table_column_class(tableName, columns, title) %}
|
{% set headerClass = macro.data_table_column_class(tableName, columns, title) %}
|
||||||
{% if title != 'actions' and not headerOptions.orderBy is same as(false) %}
|
{% if title != 'actions' and not headerOptions.orderBy is same as(false) %}
|
||||||
{% if orderBy == headerOptions.orderBy %}
|
{% if orderBy == headerOptions.orderBy %}
|
||||||
{% set headerClass = headerClass ~ ' sortable sorting_' ~ (query.order|lower) %}
|
{% set headerClass = headerClass ~ ' sortable sorting_' ~ (order) %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set headerClass = headerClass ~ ' sortable sorting' %}
|
{% set headerClass = headerClass ~ ' sortable sorting' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -79,7 +89,15 @@
|
|||||||
{% elseif title is not empty and title != 'actions' %}
|
{% elseif title is not empty and title != 'actions' %}
|
||||||
{% set headerTitle = (translationPrefix ~ title)|trans({}, translationDomain) %}
|
{% set headerTitle = (translationPrefix ~ title)|trans({}, translationDomain) %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<th data-field="{{ title }}" {% if not headerOptions.orderBy is same as(false) %}data-order="{{ headerOptions.orderBy }}" {% endif %}class="{{ headerClass }}">{{ headerTitle }}</th>
|
<th data-field="{{ title }}" {% if not headerOptions.orderBy is same as(false) %}data-order="{{ headerOptions.orderBy }}" {% endif %}class="{{ headerClass }}">
|
||||||
|
{% if headerOptions.html_before is defined %}
|
||||||
|
{{ headerOptions.html_before|raw }}
|
||||||
|
{% endif %}
|
||||||
|
{{ headerTitle }}
|
||||||
|
{% if headerOptions.html_after is defined %}
|
||||||
|
{{ headerOptions.html_after|raw }}
|
||||||
|
{% endif %}
|
||||||
|
</th>
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
{% set actions = actions|merge({'back': path('admin_user')}) %}
|
{% set actions = actions|merge({'back': path('admin_user')}) %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if view != 'permissions' and is_granted('role_permissions') %}
|
{% if is_granted('role_permissions') %}
|
||||||
{% set actions = actions|merge({'permissions': path('admin_user_permissions')}) %}
|
{% set actions = actions|merge({'permissions': path('admin_user_permissions')}) %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -33,7 +33,18 @@
|
|||||||
{% import "macros/widgets.html.twig" as widgets %}
|
{% import "macros/widgets.html.twig" as widgets %}
|
||||||
{% set actions = {} %}
|
{% set actions = {} %}
|
||||||
|
|
||||||
{% set actions = actions|merge({'back': path('admin_user')}) %}
|
{% if is_granted('view_user') %}
|
||||||
|
{% set actions = actions|merge({'back': path('admin_user')}) %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if view != 'index' and is_granted('role_permissions') %}
|
||||||
|
{% set actions = actions|merge({'permissions': path('admin_user_permissions')}) %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if view != 'role' and is_granted('role_permissions') %}
|
||||||
|
{% set actions = actions|merge({'roles': {'url': path('admin_user_roles'), 'class': 'modal-ajax-form'}}) %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% set actions = actions|merge({'help': {'url': 'permissions.html'|docu_link, 'target': '_blank'}}) %}
|
{% set actions = actions|merge({'help': {'url': 'permissions.html'|docu_link, 'target': '_blank'}}) %}
|
||||||
|
|
||||||
{% set event = trigger('actions.user_permissions', {'actions': actions, 'view': view}) %}
|
{% set event = trigger('actions.user_permissions', {'actions': actions, 'view': view}) %}
|
||||||
|
|||||||
14
templates/user/edit_role.html.twig
Normal file
14
templates/user/edit_role.html.twig
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
|
||||||
|
{% import "macros/widgets.html.twig" as widgets %}
|
||||||
|
{% import "user/actions.html.twig" as actions %}
|
||||||
|
|
||||||
|
{% block page_title %}{{ 'user_role.title'|trans }}{% endblock %}
|
||||||
|
{% block page_actions %}{{ actions.user_permissions('role') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
{{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', {
|
||||||
|
'title': (role.id ? 'action.edit'|trans : 'create'|trans) ~ ': ' ~ 'user_role.title'|trans,
|
||||||
|
'form': form,
|
||||||
|
'back': path('admin_user_permissions')
|
||||||
|
}) }}
|
||||||
|
{% endblock %}
|
||||||
@@ -7,28 +7,40 @@
|
|||||||
{% set columns = {
|
{% set columns = {
|
||||||
'label.name': 'alwaysVisible',
|
'label.name': 'alwaysVisible',
|
||||||
} %}
|
} %}
|
||||||
|
{% set canEditPedrmissions = is_granted('role_permissions') %}
|
||||||
{% for role in roles %}
|
{% for role in roles %}
|
||||||
|
{% set options = {'class': 'alwaysVisible text-center'} %}
|
||||||
|
{% if canEditPedrmissions and role.name not in system_roles|keys %}
|
||||||
|
{% set widget %}
|
||||||
|
<a href="{{ path('admin_user_role_delete', {'id': role.id}) }}" class="confirmation-link" data-question="confirm.delete" data-msg-error="action.delete.error" data-msg-success="action.delete.success">{{ widgets.icon('trash') }}</a>
|
||||||
|
{% endset %}
|
||||||
|
{% set options = options|merge({'html_after': widget}) %}
|
||||||
|
{% endif %}
|
||||||
{% set columns = columns|merge({
|
{% set columns = columns|merge({
|
||||||
(role): 'alwaysVisible text-center',
|
(role.name|trans): options,
|
||||||
}) %}
|
}) %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% set tableName = 'user_admin_permissions' %}
|
{% set tableName = 'user_admin_permissions' %}
|
||||||
|
|
||||||
{% block page_title %}{{ 'admin_user.title'|trans }}{% endblock %}
|
{% block page_title %}{{ 'user_permissions.title'|trans }}{% endblock %}
|
||||||
{% block page_actions %}{{ actions.user_permissions('index') }}{% endblock %}
|
{% block page_actions %}{{ actions.user_permissions('index') }}{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
{{ tables.datatable_header(tableName, columns, null, {'translationPrefix': ''}) }}
|
||||||
{{ tables.data_table_header_options(tableName, columns, {'translationPrefix': ''}) }}
|
|
||||||
|
|
||||||
{% for permission in permissions|sort %}
|
{% for permission in permissions|sort %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ permission }}</td>
|
<td>{{ permission }}</td>
|
||||||
{% for role in roles %}
|
{% for role in roles %}
|
||||||
|
{% set value = manager.permission(role.name, permission) %}
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
{{ widgets.label_boolean(manager.permission(role, permission)) }}
|
{# see RolePermissionManager for this special case #}
|
||||||
|
{% if (permission != 'role_permissions' and permission != 'view_user') or role.name != 'ROLE_SUPER_ADMIN' %}
|
||||||
|
<a href="{{ path('admin_user_permission_save', {'id': role.id, 'name': permission, 'value': (value ? '0' : '1')}) }}">{{ widgets.label_boolean(value) }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ widgets.label_boolean(value) }}
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -37,3 +49,12 @@
|
|||||||
{{ tables.data_table_footer(permissions) }}
|
{{ tables.data_table_footer(permissions) }}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javascripts %}
|
||||||
|
{{ parent() }}
|
||||||
|
<script type="text/javascript">
|
||||||
|
document.addEventListener('kimai.userRoleUpdate', function() {
|
||||||
|
document.location.reload();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use Symfony\Component\HttpKernel\HttpKernelBrowser;
|
|||||||
/**
|
/**
|
||||||
* @group integration
|
* @group integration
|
||||||
*/
|
*/
|
||||||
class LayoutTest extends ControllerBaseTest
|
class LayoutControllerTest extends ControllerBaseTest
|
||||||
{
|
{
|
||||||
public function testNavigationMenus()
|
public function testNavigationMenus()
|
||||||
{
|
{
|
||||||
@@ -72,4 +72,26 @@ class LayoutTest extends ControllerBaseTest
|
|||||||
$this->assertStringContainsString('<a href="/en/calendar/">', $content);
|
$this->assertStringContainsString('<a href="/en/calendar/">', $content);
|
||||||
$this->assertStringContainsString('<span>Calendar</span>', $content);
|
$this->assertStringContainsString('<span>Calendar</span>', $content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testActiveEntries()
|
||||||
|
{
|
||||||
|
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||||
|
|
||||||
|
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
|
||||||
|
$user = $this->getUserByRole($em, User::ROLE_USER);
|
||||||
|
|
||||||
|
$this->request($client, '/layou/active_entries');
|
||||||
|
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||||
|
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
|
||||||
|
self::assertStringContainsString('<li class="dropdown messages-menu" style="display:none">', $content);
|
||||||
|
self::assertStringContainsString('<ul class="dropdown-menu"', $content);
|
||||||
|
self::assertStringContainsString('data-api="', $content);
|
||||||
|
self::assertStringContainsString('data-href="', $content);
|
||||||
|
self::assertStringContainsString('data-icon=', $content);
|
||||||
|
self::assertStringContainsString('data-format=', $content);
|
||||||
|
self::assertStringContainsString('<ul class="menu">', $content);
|
||||||
|
self::assertStringContainsString('<li class="messages-menu-empty" style="">', $content);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
158
tests/Controller/PermissionControllerTest.php
Normal file
158
tests/Controller/PermissionControllerTest.php
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<?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\Tests\Controller;
|
||||||
|
|
||||||
|
use App\Entity\RolePermission;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @group integration
|
||||||
|
*/
|
||||||
|
class PermissionControllerTest extends ControllerBaseTest
|
||||||
|
{
|
||||||
|
public function testPermissionsIsSecure()
|
||||||
|
{
|
||||||
|
$this->assertUrlIsSecured('/admin/permissions');
|
||||||
|
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/admin/permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPermissions()
|
||||||
|
{
|
||||||
|
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||||
|
$this->assertAccessIsGranted($client, '/admin/permissions');
|
||||||
|
$this->assertHasDataTable($client);
|
||||||
|
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 83);
|
||||||
|
$this->assertPageActions($client, [
|
||||||
|
'back' => $this->createUrl('/admin/user/'),
|
||||||
|
'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),
|
||||||
|
'help' => 'https://www.kimai.org/documentation/permissions.html'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
// the english translation instead of the real system user role names
|
||||||
|
self::assertStringContainsString('<th data-field="User" class="alwaysVisible text-center">', $content);
|
||||||
|
self::assertStringContainsString('<th data-field="Teamlead" class="alwaysVisible text-center">', $content);
|
||||||
|
self::assertStringContainsString('<th data-field="Administrator" class="alwaysVisible text-center">', $content);
|
||||||
|
self::assertStringContainsString('<th data-field="System-Admin" class="alwaysVisible text-center">', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRoleIsSecured()
|
||||||
|
{
|
||||||
|
$this->assertUrlIsSecured('/admin/permissions/roles/create');
|
||||||
|
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/admin/permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRole()
|
||||||
|
{
|
||||||
|
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||||
|
$this->assertAccessIsGranted($client, '/admin/permissions/roles/create');
|
||||||
|
$form = $client->getCrawler()->filter('form[name=role]')->form();
|
||||||
|
$client->submit($form, [
|
||||||
|
'role' => [
|
||||||
|
'name' => 'TEST_ROLE',
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
$this->assertIsRedirect($client, $this->createUrl('/admin/permissions'));
|
||||||
|
$client->followRedirect();
|
||||||
|
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
// the english translation instead of the real system user role names
|
||||||
|
self::assertStringContainsString('<th data-field="User" class="alwaysVisible text-center">', $content);
|
||||||
|
self::assertStringContainsString('<th data-field="Teamlead" class="alwaysVisible text-center">', $content);
|
||||||
|
self::assertStringContainsString('<th data-field="Administrator" class="alwaysVisible text-center">', $content);
|
||||||
|
self::assertStringContainsString('<th data-field="System-Admin" class="alwaysVisible text-center">', $content);
|
||||||
|
self::assertStringContainsString('<th data-field="TEST_ROLE" class="alwaysVisible text-center">', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteRoleIsSecured()
|
||||||
|
{
|
||||||
|
$this->assertUrlIsSecured('/admin/permissions/roles/1/delete');
|
||||||
|
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/admin/permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteRole()
|
||||||
|
{
|
||||||
|
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||||
|
$this->assertAccessIsGranted($client, '/admin/permissions/roles/create');
|
||||||
|
$form = $client->getCrawler()->filter('form[name=role]')->form();
|
||||||
|
$client->submit($form, [
|
||||||
|
'role' => [
|
||||||
|
'name' => 'TEST_ROLE',
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
$this->assertIsRedirect($client, $this->createUrl('/admin/permissions'));
|
||||||
|
$client->followRedirect();
|
||||||
|
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
self::assertStringContainsString('<th data-field="TEST_ROLE" class="alwaysVisible text-center">', $content);
|
||||||
|
|
||||||
|
$this->request($client, '/admin/permissions/roles/1/delete');
|
||||||
|
$this->assertIsRedirect($client, $this->createUrl('/admin/permissions'));
|
||||||
|
$client->followRedirect();
|
||||||
|
|
||||||
|
self::assertHasFlashDeleteSuccess($client);
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
self::assertStringNotContainsString('<th data-field="TEST_ROLE" class="alwaysVisible text-center">', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSavePermissionIsSecured()
|
||||||
|
{
|
||||||
|
$this->assertUrlIsSecured('/admin/permissions/roles/1/view_user/1');
|
||||||
|
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/admin/permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSavePermission()
|
||||||
|
{
|
||||||
|
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||||
|
$this->assertAccessIsGranted($client, '/admin/permissions/roles/create');
|
||||||
|
$form = $client->getCrawler()->filter('form[name=role]')->form();
|
||||||
|
$client->submit($form, [
|
||||||
|
'role' => [
|
||||||
|
'name' => 'TEST_ROLE',
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
$this->assertIsRedirect($client, $this->createUrl('/admin/permissions'));
|
||||||
|
|
||||||
|
/** @var EntityManager $em */
|
||||||
|
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
|
||||||
|
$rolePermissions = $em->getRepository(RolePermission::class)->findAll();
|
||||||
|
$this->assertEquals(0, count($rolePermissions));
|
||||||
|
|
||||||
|
// create the permission
|
||||||
|
$this->request($client, '/admin/permissions/roles/1/view_user/1');
|
||||||
|
$this->assertIsRedirect($client, $this->createUrl('/admin/permissions'));
|
||||||
|
$client->followRedirect();
|
||||||
|
|
||||||
|
$rolePermissions = $em->getRepository(RolePermission::class)->findAll();
|
||||||
|
$this->assertEquals(1, count($rolePermissions));
|
||||||
|
$permission = $rolePermissions[0];
|
||||||
|
self::assertInstanceOf(RolePermission::class, $permission);
|
||||||
|
self::assertEquals('view_user', $permission->getPermission());
|
||||||
|
self::assertTrue($permission->isAllowed());
|
||||||
|
self::assertEquals('TEST_ROLE', $permission->getRole()->getName());
|
||||||
|
self::assertEquals(1, $permission->getRole()->getId());
|
||||||
|
|
||||||
|
// flush the cache to prevent wrong results
|
||||||
|
$em->clear(RolePermission::class);
|
||||||
|
|
||||||
|
// update the permission
|
||||||
|
$this->request($client, '/admin/permissions/roles/1/view_user/0');
|
||||||
|
$this->assertIsRedirect($client, $this->createUrl('/admin/permissions'));
|
||||||
|
$client->followRedirect();
|
||||||
|
|
||||||
|
$rolePermissions = $em->getRepository(RolePermission::class)->findAll();
|
||||||
|
$this->assertEquals(1, count($rolePermissions));
|
||||||
|
$permission = $rolePermissions[0];
|
||||||
|
self::assertInstanceOf(RolePermission::class, $permission);
|
||||||
|
self::assertEquals('view_user', $permission->getPermission());
|
||||||
|
self::assertFalse($permission->isAllowed());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,13 @@ class UserControllerTest extends ControllerBaseTest
|
|||||||
$this->assertAccessIsGranted($client, '/admin/user/');
|
$this->assertAccessIsGranted($client, '/admin/user/');
|
||||||
$this->assertHasDataTable($client);
|
$this->assertHasDataTable($client);
|
||||||
$this->assertDataTableRowCount($client, 'datatable_user_admin', 7);
|
$this->assertDataTableRowCount($client, 'datatable_user_admin', 7);
|
||||||
|
$this->assertPageActions($client, [
|
||||||
|
'search search-toggle visible-xs-inline' => '#',
|
||||||
|
'visibility' => '#',
|
||||||
|
'permissions' => $this->createUrl('/admin/permissions'),
|
||||||
|
'create' => $this->createUrl('/admin/user/create'),
|
||||||
|
'help' => 'https://www.kimai.org/documentation/users.html'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIndexActionWithSearchTermQuery()
|
public function testIndexActionWithSearchTermQuery()
|
||||||
@@ -220,18 +227,4 @@ class UserControllerTest extends ControllerBaseTest
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPermissionsIsSecure()
|
|
||||||
{
|
|
||||||
$this->assertUrlIsSecured('/admin/user/permissions');
|
|
||||||
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/admin/user/permissions');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPermissions()
|
|
||||||
{
|
|
||||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
|
||||||
$this->assertAccessIsGranted($client, '/admin/user/permissions');
|
|
||||||
$this->assertHasDataTable($client);
|
|
||||||
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 83);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
44
tests/Entity/RolePermissionTest.php
Normal file
44
tests/Entity/RolePermissionTest.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?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\Tests\Entity;
|
||||||
|
|
||||||
|
use App\Entity\Role;
|
||||||
|
use App\Entity\RolePermission;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @covers \App\Entity\RolePermission
|
||||||
|
*/
|
||||||
|
class RolePermissionTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testDefaultValues()
|
||||||
|
{
|
||||||
|
$sut = new RolePermission();
|
||||||
|
self::assertNull($sut->getId());
|
||||||
|
self::assertNull($sut->getPermission());
|
||||||
|
self::assertNull($sut->getRole());
|
||||||
|
self::assertFalse($sut->isAllowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetterAndGetter()
|
||||||
|
{
|
||||||
|
$sut = new RolePermission();
|
||||||
|
|
||||||
|
self::assertInstanceOf(RolePermission::class, $sut->setPermission('foo'));
|
||||||
|
self::assertEquals('foo', $sut->getPermission());
|
||||||
|
|
||||||
|
$role = (new Role())->setName('sdfsd');
|
||||||
|
self::assertInstanceOf(RolePermission::class, $sut->setRole($role));
|
||||||
|
self::assertSame($role, $sut->getRole());
|
||||||
|
|
||||||
|
self::assertInstanceOf(RolePermission::class, $sut->setAllowed(true));
|
||||||
|
self::assertTrue($sut->isAllowed());
|
||||||
|
}
|
||||||
|
}
|
||||||
34
tests/Entity/RoleTest.php
Normal file
34
tests/Entity/RoleTest.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?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\Tests\Entity;
|
||||||
|
|
||||||
|
use App\Entity\Role;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @covers \App\Entity\Role
|
||||||
|
*/
|
||||||
|
class RoleTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testDefaultValues()
|
||||||
|
{
|
||||||
|
$sut = new Role();
|
||||||
|
self::assertNull($sut->getId());
|
||||||
|
self::assertNull($sut->getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSetterAndGetter()
|
||||||
|
{
|
||||||
|
$sut = new Role();
|
||||||
|
|
||||||
|
self::assertInstanceOf(Role::class, $sut->setName('foo'));
|
||||||
|
self::assertEquals('foo', $sut->getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ use App\Ldap\LdapDriver;
|
|||||||
use App\Ldap\LdapDriverException;
|
use App\Ldap\LdapDriverException;
|
||||||
use App\Ldap\LdapManager;
|
use App\Ldap\LdapManager;
|
||||||
use App\Ldap\LdapUserHydrator;
|
use App\Ldap\LdapUserHydrator;
|
||||||
use App\Security\RoleService;
|
use App\Tests\Mocks\Security\RoleServiceFactory;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,11 +50,13 @@ class LdapManagerTest extends TestCase
|
|||||||
'role' => $roleConfig,
|
'role' => $roleConfig,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$hydrator = new LdapUserHydrator($config, new RoleService([
|
$roles = [
|
||||||
'ROLE_TEAMLEAD' => ['ROLE_USER'],
|
'ROLE_TEAMLEAD' => ['ROLE_USER'],
|
||||||
'ROLE_ADMIN' => ['ROLE_TEAMLEAD'],
|
'ROLE_ADMIN' => ['ROLE_TEAMLEAD'],
|
||||||
'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN']
|
'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN']
|
||||||
]));
|
];
|
||||||
|
|
||||||
|
$hydrator = new LdapUserHydrator($config, (new RoleServiceFactory($this))->create($roles));
|
||||||
|
|
||||||
return new LdapManager($driver, $hydrator, $config);
|
return new LdapManager($driver, $hydrator, $config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace App\Tests\Ldap;
|
|||||||
use App\Configuration\LdapConfiguration;
|
use App\Configuration\LdapConfiguration;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Ldap\LdapUserHydrator;
|
use App\Ldap\LdapUserHydrator;
|
||||||
use App\Security\RoleService;
|
use App\Tests\Mocks\Security\RoleServiceFactory;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +33,7 @@ class LdapUserHydratorTest extends TestCase
|
|||||||
'role' => [],
|
'role' => [],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$sut = new LdapUserHydrator($config, new RoleService([]));
|
$sut = new LdapUserHydrator($config, (new RoleServiceFactory($this))->create([]));
|
||||||
$user = $sut->hydrate(['dn' => 'blub']);
|
$user = $sut->hydrate(['dn' => 'blub']);
|
||||||
self::assertInstanceOf(User::class, $user);
|
self::assertInstanceOf(User::class, $user);
|
||||||
self::assertEmpty($user->getUsername());
|
self::assertEmpty($user->getUsername());
|
||||||
@@ -71,7 +71,7 @@ class LdapUserHydratorTest extends TestCase
|
|||||||
'dn' => 'blub',
|
'dn' => 'blub',
|
||||||
];
|
];
|
||||||
|
|
||||||
$sut = new LdapUserHydrator($config, new RoleService([]));
|
$sut = new LdapUserHydrator($config, (new RoleServiceFactory($this))->create([]));
|
||||||
$user = $sut->hydrate($ldapEntry);
|
$user = $sut->hydrate($ldapEntry);
|
||||||
|
|
||||||
self::assertInstanceOf(User::class, $user);
|
self::assertInstanceOf(User::class, $user);
|
||||||
@@ -113,7 +113,7 @@ class LdapUserHydratorTest extends TestCase
|
|||||||
'dn' => 'blub',
|
'dn' => 'blub',
|
||||||
];
|
];
|
||||||
|
|
||||||
$sut = new LdapUserHydrator($config, new RoleService([]));
|
$sut = new LdapUserHydrator($config, (new RoleServiceFactory($this))->create([]));
|
||||||
$user = new User();
|
$user = new User();
|
||||||
$user->setPassword('foobar');
|
$user->setPassword('foobar');
|
||||||
$sut->hydrateUser($user, $ldapEntry);
|
$sut->hydrateUser($user, $ldapEntry);
|
||||||
@@ -176,11 +176,13 @@ class LdapUserHydratorTest extends TestCase
|
|||||||
'count' => 4
|
'count' => 4
|
||||||
];
|
];
|
||||||
|
|
||||||
$sut = new LdapUserHydrator($config, new RoleService([
|
$roles = [
|
||||||
'ROLE_TEAMLEAD' => ['ROLE_USER'],
|
'ROLE_TEAMLEAD' => ['ROLE_USER'],
|
||||||
'ROLE_ADMIN' => ['ROLE_TEAMLEAD'],
|
'ROLE_ADMIN' => ['ROLE_TEAMLEAD'],
|
||||||
'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN']
|
'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN']
|
||||||
]));
|
];
|
||||||
|
|
||||||
|
$sut = new LdapUserHydrator($config, (new RoleServiceFactory($this))->create($roles));
|
||||||
$user = new User();
|
$user = new User();
|
||||||
$sut->hydrateRoles($user, $ldapGroups);
|
$sut->hydrateRoles($user, $ldapGroups);
|
||||||
self::assertEquals(['ROLE_TEAMLEAD', 'ROLE_ADMIN', 'ROLE_USER'], $user->getRoles());
|
self::assertEquals(['ROLE_TEAMLEAD', 'ROLE_ADMIN', 'ROLE_USER'], $user->getRoles());
|
||||||
|
|||||||
@@ -9,13 +9,20 @@
|
|||||||
|
|
||||||
namespace App\Tests\Mocks\Security;
|
namespace App\Tests\Mocks\Security;
|
||||||
|
|
||||||
|
use App\Entity\Role;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Repository\RoleRepository;
|
||||||
use App\Security\RoleService;
|
use App\Security\RoleService;
|
||||||
use App\Tests\Mocks\AbstractMockFactory;
|
use App\Tests\Mocks\AbstractMockFactory;
|
||||||
|
|
||||||
class RoleServiceFactory extends AbstractMockFactory
|
class RoleServiceFactory extends AbstractMockFactory
|
||||||
{
|
{
|
||||||
public function create($roles = null): RoleService
|
/**
|
||||||
|
* @param string[]|null $roles
|
||||||
|
* @param Role[]|null $repositoryRoles
|
||||||
|
* @return RoleService
|
||||||
|
*/
|
||||||
|
public function create(?array $roles = null, ?array $repositoryRoles = []): RoleService
|
||||||
{
|
{
|
||||||
if (null === $roles) {
|
if (null === $roles) {
|
||||||
$roles = [
|
$roles = [
|
||||||
@@ -26,6 +33,10 @@ class RoleServiceFactory extends AbstractMockFactory
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RoleService($roles);
|
$repository = $this->getMockBuilder(RoleRepository::class)->onlyMethods(['findAll'])->disableOriginalConstructor()->getMock();
|
||||||
|
$repository->method('findAll')->willReturn($repositoryRoles);
|
||||||
|
|
||||||
|
/* @var RoleRepository $repository */
|
||||||
|
return new RoleService($repository, $roles);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
106
tests/Security/RolePermissionManagerTest.php
Normal file
106
tests/Security/RolePermissionManagerTest.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?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\Tests\Security;
|
||||||
|
|
||||||
|
use App\Repository\RolePermissionRepository;
|
||||||
|
use App\Security\RolePermissionManager;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @covers \App\Security\RolePermissionManager
|
||||||
|
*/
|
||||||
|
class RolePermissionManagerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testWithEmptyRepository()
|
||||||
|
{
|
||||||
|
$repository = $this->getMockBuilder(RolePermissionRepository::class)->onlyMethods(['getAllAsArray'])->disableOriginalConstructor()->getMock();
|
||||||
|
$repository->method('getAllAsArray')->willReturn([]);
|
||||||
|
|
||||||
|
/** @var RolePermissionRepository $repository */
|
||||||
|
$sut = new RolePermissionManager($repository, []);
|
||||||
|
self::assertFalse($sut->isRegisteredPermission('foo'));
|
||||||
|
self::assertEquals([], $sut->getPermissions());
|
||||||
|
self::assertFalse($sut->hasPermission('TEST_ROLE', 'foo'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithRepositoryData()
|
||||||
|
{
|
||||||
|
$repository = $this->getMockBuilder(RolePermissionRepository::class)->onlyMethods(['getAllAsArray'])->disableOriginalConstructor()->getMock();
|
||||||
|
$repository->method('getAllAsArray')->willReturn([
|
||||||
|
['permission' => 'foo', 'role' => 'TEST_ROLE', 'allowed' => true],
|
||||||
|
['permission' => 'bar', 'role' => 'USER_ROLE', 'allowed' => true],
|
||||||
|
['permission' => 'foo', 'role' => 'USER_ROLE', 'allowed' => false],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var RolePermissionRepository $repository */
|
||||||
|
$sut = new RolePermissionManager($repository, []);
|
||||||
|
|
||||||
|
// only data injected through the config will be registered as "known"
|
||||||
|
self::assertFalse($sut->isRegisteredPermission('foo'));
|
||||||
|
self::assertFalse($sut->isRegisteredPermission('bar'));
|
||||||
|
self::assertEquals([], $sut->getPermissions());
|
||||||
|
|
||||||
|
self::assertTrue($sut->hasPermission('TEST_ROLE', 'foo'));
|
||||||
|
self::assertFalse($sut->hasPermission('USER_ROLE', 'foo'));
|
||||||
|
self::assertTrue($sut->hasPermission('USER_ROLE', 'bar'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithConfigData()
|
||||||
|
{
|
||||||
|
$repository = $this->getMockBuilder(RolePermissionRepository::class)->onlyMethods(['getAllAsArray'])->disableOriginalConstructor()->getMock();
|
||||||
|
$repository->method('getAllAsArray')->willReturn([]);
|
||||||
|
|
||||||
|
/** @var RolePermissionRepository $repository */
|
||||||
|
$sut = new RolePermissionManager($repository, ['TEST_ROLE' => ['foo'], 'USER_ROLE' => ['bar']]);
|
||||||
|
|
||||||
|
self::assertTrue($sut->isRegisteredPermission('foo'));
|
||||||
|
self::assertTrue($sut->isRegisteredPermission('bar'));
|
||||||
|
self::assertEquals(['foo', 'bar'], $sut->getPermissions());
|
||||||
|
|
||||||
|
self::assertTrue($sut->hasPermission('TEST_ROLE', 'foo'));
|
||||||
|
self::assertFalse($sut->hasPermission('TEST_ROLE', 'bar'));
|
||||||
|
self::assertFalse($sut->hasPermission('USER_ROLE', 'foo'));
|
||||||
|
self::assertTrue($sut->hasPermission('USER_ROLE', 'bar'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithMixedData()
|
||||||
|
{
|
||||||
|
$repository = $this->getMockBuilder(RolePermissionRepository::class)->onlyMethods(['getAllAsArray'])->disableOriginalConstructor()->getMock();
|
||||||
|
$repository->method('getAllAsArray')->willReturn([
|
||||||
|
['permission' => 'foo', 'role' => 'TEST_ROLE', 'allowed' => false],
|
||||||
|
['permission' => 'bar', 'role' => 'USER_ROLE', 'allowed' => true],
|
||||||
|
['permission' => 'foo', 'role' => 'USER_ROLE', 'allowed' => false],
|
||||||
|
['permission' => 'role_permissions', 'role' => 'ROLE_SUPER_ADMIN', 'allowed' => false],
|
||||||
|
['permission' => 'view_user', 'role' => 'ROLE_SUPER_ADMIN', 'allowed' => false],
|
||||||
|
['permission' => 'create_user', 'role' => 'ROLE_SUPER_ADMIN', 'allowed' => false],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var RolePermissionRepository $repository */
|
||||||
|
$sut = new RolePermissionManager($repository, [
|
||||||
|
'ROLE_SUPER_ADMIN' => ['role_permissions', 'view_user', 'create_user'],
|
||||||
|
'TEST_ROLE' => ['foo2', 'foo'],
|
||||||
|
'USER_ROLE' => ['foo', 'bar']
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertTrue($sut->isRegisteredPermission('foo'));
|
||||||
|
self::assertTrue($sut->isRegisteredPermission('bar'));
|
||||||
|
self::assertEquals(['role_permissions', 'view_user', 'create_user', 'foo2', 'foo', 'bar'], array_values($sut->getPermissions()));
|
||||||
|
|
||||||
|
self::assertTrue($sut->hasPermission('TEST_ROLE', 'foo2'));
|
||||||
|
self::assertFalse($sut->hasPermission('TEST_ROLE', 'foo'));
|
||||||
|
self::assertFalse($sut->hasPermission('USER_ROLE', 'foo'));
|
||||||
|
self::assertTrue($sut->hasPermission('USER_ROLE', 'bar'));
|
||||||
|
self::assertFalse($sut->hasPermission('ROLE_SUPER_ADMIN', 'create_user'));
|
||||||
|
|
||||||
|
// the next two are a special case, which might never be falsified by the database
|
||||||
|
self::assertTrue($sut->hasPermission('ROLE_SUPER_ADMIN', 'role_permissions'));
|
||||||
|
self::assertTrue($sut->hasPermission('ROLE_SUPER_ADMIN', 'view_user'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
|
|
||||||
namespace App\Tests\Security;
|
namespace App\Tests\Security;
|
||||||
|
|
||||||
use App\Security\RoleService;
|
use App\Entity\Role;
|
||||||
|
use App\Tests\Mocks\Security\RoleServiceFactory;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,7 +18,7 @@ use PHPUnit\Framework\TestCase;
|
|||||||
*/
|
*/
|
||||||
class RoleServiceTest extends TestCase
|
class RoleServiceTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testGetAvailableNames()
|
public function testWithEmptyRepository()
|
||||||
{
|
{
|
||||||
$real = [
|
$real = [
|
||||||
'ROLE_TEAMLEAD' => [0 => 'ROLE_USER'],
|
'ROLE_TEAMLEAD' => [0 => 'ROLE_USER'],
|
||||||
@@ -25,10 +26,34 @@ class RoleServiceTest extends TestCase
|
|||||||
'ROLE_SUPER_ADMIN' => [0 => 'ROLE_ADMIN']
|
'ROLE_SUPER_ADMIN' => [0 => 'ROLE_ADMIN']
|
||||||
];
|
];
|
||||||
|
|
||||||
$sut = new RoleService($real);
|
$sut = (new RoleServiceFactory($this))->create($real);
|
||||||
|
|
||||||
$expected = ['ROLE_TEAMLEAD', 'ROLE_USER', 'ROLE_ADMIN', 'ROLE_SUPER_ADMIN'];
|
$expected = ['ROLE_TEAMLEAD', 'ROLE_USER', 'ROLE_ADMIN', 'ROLE_SUPER_ADMIN'];
|
||||||
|
|
||||||
self::assertEquals($expected, $sut->getAvailableNames());
|
self::assertEquals($expected, $sut->getAvailableNames());
|
||||||
|
self::assertEquals($real, $sut->getSystemRoles());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithRepositoryData()
|
||||||
|
{
|
||||||
|
$real = [
|
||||||
|
'ROLE_TEAMLEAD' => [0 => 'ROLE_USER'],
|
||||||
|
'ROLE_ADMIN' => [0 => 'ROLE_TEAMLEAD'],
|
||||||
|
'ROLE_SUPER_ADMIN' => [0 => 'ROLE_ADMIN']
|
||||||
|
];
|
||||||
|
|
||||||
|
$repository = [
|
||||||
|
(new Role())->setName('TEST_ROLE'),
|
||||||
|
(new Role())->setName('ROLE_ADMIN'),
|
||||||
|
(new Role())->setName('ROLE_ADMINX'),
|
||||||
|
(new Role())->setName('TEST_ROLE'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$sut = (new RoleServiceFactory($this))->create($real, $repository);
|
||||||
|
|
||||||
|
$expected = ['ROLE_TEAMLEAD', 'ROLE_USER', 'ROLE_ADMIN', 'ROLE_SUPER_ADMIN', 'TEST_ROLE', 'ROLE_ADMINX'];
|
||||||
|
|
||||||
|
self::assertEquals($expected, $sut->getAvailableNames());
|
||||||
|
self::assertEquals($real, $sut->getSystemRoles());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
namespace App\Tests\Voter;
|
namespace App\Tests\Voter;
|
||||||
|
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Repository\RolePermissionRepository;
|
||||||
use App\Security\AclDecisionManager;
|
use App\Security\AclDecisionManager;
|
||||||
use App\Security\RolePermissionManager;
|
use App\Security\RolePermissionManager;
|
||||||
use App\Tests\Mocks\Security\RoleServiceFactory;
|
|
||||||
use App\Voter\AbstractVoter;
|
use App\Voter\AbstractVoter;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -97,9 +97,10 @@ abstract class AbstractVoterTest extends TestCase
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$factory = new RoleServiceFactory($this);
|
$repository = $this->getMockBuilder(RolePermissionRepository::class)->onlyMethods(['getAllAsArray'])->disableOriginalConstructor()->getMock();
|
||||||
$roleService = $factory->create();
|
$repository->method('getAllAsArray')->willReturn([]);
|
||||||
|
|
||||||
return new RolePermissionManager($roleService, $permissions);
|
/* @var RolePermissionRepository $repository */
|
||||||
|
return new RolePermissionManager($repository, $permissions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -616,7 +616,19 @@
|
|||||||
Momentan existieren für den Benutzer %user% insgesamt %records% Zeiteinträge, welche sich auf eine Gesamtdauer von %duration% belaufen.
|
Momentan existieren für den Benutzer %user% insgesamt %records% Zeiteinträge, welche sich auf eine Gesamtdauer von %duration% belaufen.
|
||||||
</target>
|
</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="user_permissions.title">
|
||||||
|
<source>user_permissions.title</source>
|
||||||
|
<target>Benutzer Berechtigungen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="user_role.title">
|
||||||
|
<source>user_role.title</source>
|
||||||
|
<target>Benutzer Rolle</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allowed character: A-Z and _">
|
||||||
|
<source>Allowed character: A-Z and _</source>
|
||||||
|
<target>Erlaubte Zeichen: A-Z und _</target>
|
||||||
|
</trans-unit>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Admin: Plugins
|
Admin: Plugins
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -616,6 +616,18 @@
|
|||||||
Currently the user %user% has %records% time-records which sum up to a total of %duration%.
|
Currently the user %user% has %records% time-records which sum up to a total of %duration%.
|
||||||
</target>
|
</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="user_permissions.title">
|
||||||
|
<source>user_permissions.title</source>
|
||||||
|
<target>User permissions</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="user_role.title">
|
||||||
|
<source>user_role.title</source>
|
||||||
|
<target>User role</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Allowed character: A-Z and _">
|
||||||
|
<source>Allowed character: A-Z and _</source>
|
||||||
|
<target>Allowed character: A-Z and _</target>
|
||||||
|
</trans-unit>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Admin: Plugins
|
Admin: Plugins
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user