collaborators
This commit is contained in:
@@ -29,19 +29,18 @@
|
||||
|
||||
namespace Espo\Classes\DefaultLayouts;
|
||||
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\User;
|
||||
use stdClass;
|
||||
|
||||
class DefaultSidePanelType
|
||||
{
|
||||
private $metadata;
|
||||
|
||||
public function __construct(Metadata $metadata)
|
||||
{
|
||||
$this->metadata = $metadata;
|
||||
}
|
||||
public function __construct(private Metadata $metadata)
|
||||
{}
|
||||
|
||||
/**
|
||||
* @return \stdClass[]
|
||||
* @return stdClass[]
|
||||
*/
|
||||
public function get(string $scope): array
|
||||
{
|
||||
@@ -64,6 +63,13 @@ class DefaultSidePanelType
|
||||
$list[] = (object) ['name' => 'teams'];
|
||||
}
|
||||
|
||||
if (
|
||||
$this->metadata->get("entityDefs.$scope.fields.collaborators.type") === FieldType::LINK_MULTIPLE &&
|
||||
$this->metadata->get("entityDefs.$scope.links.collaborators.entity") === User::ENTITY_TYPE
|
||||
) {
|
||||
$list[] = (object) ['name' => 'collaborators'];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,9 +113,9 @@ class Acl
|
||||
* whether a scope level is set to 'enabled'.
|
||||
*
|
||||
* @param string|Entity $subject An entity type or entity.
|
||||
* @param string|null $action Action to check. Constants are available in the `Table` class.
|
||||
*
|
||||
* @param Table::ACTION_*|null $action Action to check. Constants are available in the `Table` class.
|
||||
* @throws NotImplemented
|
||||
* @noinspection PhpDocSignatureInspection
|
||||
*/
|
||||
public function check($subject, ?string $action = null): bool
|
||||
{
|
||||
@@ -126,7 +126,8 @@ class Acl
|
||||
* The same as `check` but does not throw NotImplemented exception.
|
||||
*
|
||||
* @param string|Entity $subject An entity type or entity.
|
||||
* @param string|null $action Action to check. Constants are available in the `Table` class.
|
||||
* @param Table::ACTION_*|null $action Action to check. Constants are available in the `Table` class.
|
||||
* @noinspection PhpDocSignatureInspection
|
||||
*/
|
||||
public function tryCheck($subject, ?string $action = null): bool
|
||||
{
|
||||
@@ -148,7 +149,8 @@ class Acl
|
||||
* Check access to a specific entity.
|
||||
*
|
||||
* @param Entity $entity An entity to check.
|
||||
* @param string $action Action to check. Constants are available in the `Table` class.
|
||||
* @param Table::ACTION_* $action Action to check. Constants are available in the `Table` class.
|
||||
* @noinspection PhpDocSignatureInspection
|
||||
*/
|
||||
public function checkEntity(Entity $entity, string $action = Table::ACTION_READ): bool
|
||||
{
|
||||
@@ -212,6 +214,18 @@ class Acl
|
||||
return $this->aclManager->checkOwnershipTeam($this->user, $entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an entity is shared with a user.
|
||||
*
|
||||
* @param Table::ACTION_* $action
|
||||
* @since 8.5.0
|
||||
* @noinspection PhpDocSignatureInspection
|
||||
*/
|
||||
public function checkOwnershipShared(Entity $entity, string $action): bool
|
||||
{
|
||||
return $this->aclManager->checkOwnershipShared($this->user, $entity, $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attributes forbidden for a user.
|
||||
*
|
||||
|
||||
@@ -74,6 +74,12 @@ class ScopeChecker
|
||||
}
|
||||
}
|
||||
|
||||
if ($level === Table::LEVEL_OWN || $level === Table::LEVEL_TEAM) {
|
||||
if ($checkerData->isShared()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($level === Table::LEVEL_TEAM) {
|
||||
if ($checkerData->inTeam()) {
|
||||
return true;
|
||||
|
||||
@@ -38,7 +38,8 @@ class ScopeCheckerData
|
||||
{
|
||||
public function __construct(
|
||||
private Closure $isOwnChecker,
|
||||
private Closure $inTeamChecker
|
||||
private Closure $inTeamChecker,
|
||||
private Closure $isSharedChecker,
|
||||
) {}
|
||||
|
||||
public function isOwn(): bool
|
||||
@@ -51,6 +52,11 @@ class ScopeCheckerData
|
||||
return ($this->inTeamChecker)();
|
||||
}
|
||||
|
||||
public function isShared(): bool
|
||||
{
|
||||
return ($this->isSharedChecker)();
|
||||
}
|
||||
|
||||
public static function createBuilder(): ScopeCheckerDataBuilder
|
||||
{
|
||||
return new ScopeCheckerDataBuilder();
|
||||
|
||||
@@ -38,16 +38,13 @@ class ScopeCheckerDataBuilder
|
||||
{
|
||||
private Closure $isOwnChecker;
|
||||
private Closure $inTeamChecker;
|
||||
private Closure $isSharedChecker;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->isOwnChecker = function (): bool {
|
||||
return false;
|
||||
};
|
||||
|
||||
$this->inTeamChecker = function (): bool {
|
||||
return false;
|
||||
};
|
||||
$this->isOwnChecker = fn(): bool => false;
|
||||
$this->inTeamChecker = fn(): bool => false;
|
||||
$this->isSharedChecker = fn(): bool => false;
|
||||
}
|
||||
|
||||
public function setIsOwn(bool $value): self
|
||||
@@ -84,6 +81,19 @@ class ScopeCheckerDataBuilder
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setIsShared(bool $value): self
|
||||
{
|
||||
if ($value) {
|
||||
$this->isSharedChecker = fn(): bool => true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->isSharedChecker = fn(): bool => false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure(): bool $checker
|
||||
*/
|
||||
@@ -104,8 +114,18 @@ class ScopeCheckerDataBuilder
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure(): bool $checker
|
||||
*/
|
||||
public function setIsSharedChecker(Closure $checker): self
|
||||
{
|
||||
$this->isSharedChecker = $checker;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function build(): ScopeCheckerData
|
||||
{
|
||||
return new ScopeCheckerData($this->isOwnChecker, $this->inTeamChecker);
|
||||
return new ScopeCheckerData($this->isOwnChecker, $this->inTeamChecker, $this->isSharedChecker);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,19 +68,22 @@ class DefaultAccessChecker implements
|
||||
private ScopeChecker $scopeChecker
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param Table::ACTION_* $action
|
||||
* @noinspection PhpDocSignatureInspection
|
||||
*/
|
||||
private function checkEntity(User $user, Entity $entity, ScopeData $data, string $action): bool
|
||||
{
|
||||
$checkerData = ScopeCheckerData
|
||||
::createBuilder()
|
||||
->setIsOwnChecker(
|
||||
function () use ($user, $entity): bool {
|
||||
return $this->aclManager->checkOwnershipOwn($user, $entity);
|
||||
}
|
||||
fn(): bool => $this->aclManager->checkOwnershipOwn($user, $entity)
|
||||
)
|
||||
->setInTeamChecker(
|
||||
function () use ($user, $entity): bool {
|
||||
return $this->aclManager->checkOwnershipTeam($user, $entity);
|
||||
}
|
||||
fn(): bool => $this->aclManager->checkOwnershipTeam($user, $entity)
|
||||
)
|
||||
->setIsSharedChecker(
|
||||
fn(): bool => $this->aclManager->checkOwnershipShared($user, $entity, $action)
|
||||
)
|
||||
->build();
|
||||
|
||||
@@ -93,6 +96,7 @@ class DefaultAccessChecker implements
|
||||
::createBuilder()
|
||||
->setIsOwn(true)
|
||||
->setInTeam(true)
|
||||
->setIsShared(true)
|
||||
->build();
|
||||
|
||||
return $this->scopeChecker->check($data, $action, $checkerData);
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
namespace Espo\Core\Acl;
|
||||
|
||||
use Espo\Core\ORM\Entity as CoreEntity;
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Repositories\User as UserRepository;
|
||||
use Espo\ORM\Defs;
|
||||
use Espo\ORM\Entity;
|
||||
@@ -45,12 +47,13 @@ class DefaultAssignmentChecker implements AssignmentChecker
|
||||
protected const FIELD_ASSIGNED_USERS = 'assignedUsers';
|
||||
protected const FIELD_TEAMS = 'teams';
|
||||
protected const ATTR_ASSIGNED_USER_ID = 'assignedUserId';
|
||||
protected const ATTR_ASSIGNED_USERS_IDS = 'assignedUsersIds';
|
||||
private const FIELD_COLLABORATORS = 'collaborators';
|
||||
|
||||
public function __construct(
|
||||
private AclManager $aclManager,
|
||||
private EntityManager $entityManager,
|
||||
private Defs $ormDefs
|
||||
private Defs $ormDefs,
|
||||
private Metadata $metadata,
|
||||
) {}
|
||||
|
||||
public function check(User $user, Entity $entity): bool
|
||||
@@ -69,18 +72,37 @@ class DefaultAssignmentChecker implements AssignmentChecker
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->hasCollaboratorsField($entity->getEntityType())) {
|
||||
if (!$this->isPermittedUsers($user, $entity, self::FIELD_COLLABORATORS)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function hasCollaboratorsField(string $entityType): bool
|
||||
{
|
||||
if (!$this->metadata->get("scopes.$entityType.collaborators")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$entityDefs = $this->ormDefs->getEntity($entityType);
|
||||
|
||||
return
|
||||
$entityDefs->tryGetField(self::FIELD_COLLABORATORS)?->getType() === FieldType::LINK_MULTIPLE &&
|
||||
$entityDefs->tryGetRelation(self::FIELD_COLLABORATORS)?->tryGetForeignEntityType() === User::ENTITY_TYPE;
|
||||
}
|
||||
|
||||
private function hasAssignedUsersField(string $entityType): bool
|
||||
{
|
||||
$entityDefs = $this->ormDefs->getEntity($entityType);
|
||||
|
||||
return
|
||||
$entityDefs->hasField(self::FIELD_ASSIGNED_USERS) &&
|
||||
$entityDefs->getField(self::FIELD_ASSIGNED_USERS)->getType() === 'linkMultiple' &&
|
||||
$entityDefs->getField(self::FIELD_ASSIGNED_USERS)->getType() === FieldType::LINK_MULTIPLE &&
|
||||
$entityDefs->hasRelation(self::FIELD_ASSIGNED_USERS) &&
|
||||
$entityDefs->getRelation(self::FIELD_ASSIGNED_USERS)->getForeignEntityType() === 'User';
|
||||
$entityDefs->getRelation(self::FIELD_ASSIGNED_USERS)->getForeignEntityType() === User::ENTITY_TYPE;
|
||||
}
|
||||
|
||||
protected function isPermittedAssignedUser(User $user, Entity $entity): bool
|
||||
@@ -237,18 +259,28 @@ class DefaultAssignmentChecker implements AssignmentChecker
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Left for backward compatibility.
|
||||
*/
|
||||
protected function isPermittedAssignedUsers(User $user, Entity $entity): bool
|
||||
{
|
||||
return $this->isPermittedUsers($user, $entity, self::FIELD_ASSIGNED_USERS);
|
||||
}
|
||||
|
||||
private function isPermittedUsers(User $user, Entity $entity, string $field): bool
|
||||
{
|
||||
if (!$entity instanceof CoreEntity) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS)) {
|
||||
$idsAttr = $field . 'Ids';
|
||||
|
||||
if (!$entity->hasLinkMultipleField($field)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($user->isPortal()) {
|
||||
if (!$entity->isAttributeChanged(self::ATTR_ASSIGNED_USERS_IDS)) {
|
||||
if (!$entity->isAttributeChanged($idsAttr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -261,6 +293,10 @@ class DefaultAssignmentChecker implements AssignmentChecker
|
||||
$assignmentPermission === Table::LEVEL_YES ||
|
||||
!in_array($assignmentPermission, [Table::LEVEL_TEAM, Table::LEVEL_NO])
|
||||
) {
|
||||
if (!$this->hasOnlyInternalUsers($entity, $field)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -268,22 +304,22 @@ class DefaultAssignmentChecker implements AssignmentChecker
|
||||
|
||||
if (!$entity->isNew()) {
|
||||
// Might be on purpose.
|
||||
$entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS);
|
||||
$entity->getLinkMultipleIdList($field);
|
||||
|
||||
if ($entity->isAttributeChanged(self::ATTR_ASSIGNED_USERS_IDS)) {
|
||||
if ($entity->isAttributeChanged($idsAttr)) {
|
||||
$toProcess = true;
|
||||
}
|
||||
} else {
|
||||
$toProcess = true;
|
||||
}
|
||||
|
||||
$userIdList = $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS);
|
||||
$userIdList = $entity->getLinkMultipleIdList($field);
|
||||
|
||||
if (!$toProcess) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (empty($userIdList)) {
|
||||
if ($userIdList === []) {
|
||||
if ($assignmentPermission === Table::LEVEL_NO && !$user->isApi()) {
|
||||
return false;
|
||||
}
|
||||
@@ -292,22 +328,23 @@ class DefaultAssignmentChecker implements AssignmentChecker
|
||||
}
|
||||
|
||||
if ($assignmentPermission === Table::LEVEL_NO) {
|
||||
return $this->isPermittedAssignedUsersLevelNo($user, $entity);
|
||||
return $this->isPermittedUsersLevelNo($user, $entity, $field);
|
||||
}
|
||||
|
||||
if ($assignmentPermission === Table::LEVEL_TEAM) {
|
||||
return $this->isPermittedAssignedUsersLevelTeam($user, $entity);
|
||||
return $this->isPermittedUsersLevelTeam($user, $entity, $field);
|
||||
}
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isPermittedAssignedUsersLevelNo(User $user, CoreEntity $entity): bool
|
||||
private function isPermittedUsersLevelNo(User $user, CoreEntity $entity, string $field): bool
|
||||
{
|
||||
$userIdList = $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS);
|
||||
$idsAttr = $field . 'Ids';
|
||||
|
||||
$fetchedAssignedUserIdList = $entity->getFetched(self::ATTR_ASSIGNED_USERS_IDS);
|
||||
$userIdList = $entity->getLinkMultipleIdList($field);
|
||||
$fetchedAssignedUserIdList = $entity->getFetched($idsAttr);
|
||||
|
||||
foreach ($userIdList as $userId) {
|
||||
if (!$entity->isNew() && in_array($userId, $fetchedAssignedUserIdList)) {
|
||||
@@ -322,12 +359,12 @@ class DefaultAssignmentChecker implements AssignmentChecker
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isPermittedAssignedUsersLevelTeam(User $user, CoreEntity $entity): bool
|
||||
private function isPermittedUsersLevelTeam(User $user, CoreEntity $entity, string $field): bool
|
||||
{
|
||||
$userIdList = $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS);
|
||||
|
||||
$fetchedAssignedUserIdList = $entity->getFetched(self::ATTR_ASSIGNED_USERS_IDS);
|
||||
$idsAttr = $field . 'Ids';
|
||||
|
||||
$userIdList = $entity->getLinkMultipleIdList($field);
|
||||
$fetchedAssignedUserIdList = $entity->getFetched($idsAttr);
|
||||
$teamIdList = $user->getLinkMultipleIdList(self::FIELD_TEAMS);
|
||||
|
||||
foreach ($userIdList as $userId) {
|
||||
@@ -344,4 +381,20 @@ class DefaultAssignmentChecker implements AssignmentChecker
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function hasOnlyInternalUsers(CoreEntity $entity, string $field): bool
|
||||
{
|
||||
$count = $this->entityManager
|
||||
->getRDBRepositoryByClass(User::class)
|
||||
->where([
|
||||
'type!=' => [
|
||||
User::TYPE_REGULAR,
|
||||
User::TYPE_ADMIN,
|
||||
],
|
||||
'id' => $entity->getLinkMultipleIdList($field),
|
||||
])
|
||||
->count();
|
||||
|
||||
return $count === 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,14 +38,16 @@ use Espo\Entities\User;
|
||||
*
|
||||
* @implements OwnershipOwnChecker<CoreEntity>
|
||||
* @implements OwnershipTeamChecker<CoreEntity>
|
||||
* @implements OwnershipSharedChecker<CoreEntity>
|
||||
*/
|
||||
class DefaultOwnershipChecker implements OwnershipOwnChecker, OwnershipTeamChecker
|
||||
class DefaultOwnershipChecker implements OwnershipOwnChecker, OwnershipTeamChecker, OwnershipSharedChecker
|
||||
{
|
||||
private const ATTR_CREATED_BY_ID = 'createdById';
|
||||
private const ATTR_ASSIGNED_USER_ID = 'assignedUserId';
|
||||
private const ATTR_ASSIGNED_TEAMS_IDS = 'teamsIds';
|
||||
private const FIELD_TEAMS = 'teams';
|
||||
private const FIELD_ASSIGNED_USERS = 'assignedUsers';
|
||||
private const FIELD_COLLABORATORS = 'collaborators';
|
||||
|
||||
public function checkOwn(User $user, Entity $entity): bool
|
||||
{
|
||||
@@ -109,4 +111,24 @@ class DefaultOwnershipChecker implements OwnershipOwnChecker, OwnershipTeamCheck
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function checkShared(User $user, Entity $entity, string $action): bool
|
||||
{
|
||||
if (!$entity instanceof CoreEntity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($action !== Table::ACTION_READ && $action !== Table::ACTION_STREAM) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!$entity->hasRelation(self::FIELD_COLLABORATORS) ||
|
||||
!$entity->hasLinkMultipleField(self::FIELD_COLLABORATORS)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($user->getId(), $entity->getLinkMultipleIdList(self::FIELD_COLLABORATORS));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Acl;
|
||||
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\Entities\User;
|
||||
|
||||
/**
|
||||
* @template TEntity of Entity
|
||||
*/
|
||||
interface OwnershipSharedChecker extends OwnershipChecker
|
||||
{
|
||||
/**
|
||||
* Check whether an entity is shared with a user.
|
||||
*
|
||||
* @param TEntity $entity
|
||||
* @param Table::ACTION_* $action
|
||||
* @noinspection PhpDocSignatureInspection
|
||||
*/
|
||||
public function checkShared(User $user, Entity $entity, string $action): bool;
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
namespace Espo\Core;
|
||||
|
||||
use Espo\Core\Acl\OwnershipSharedChecker;
|
||||
use Espo\Core\Acl\Permission;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
@@ -246,9 +247,9 @@ class AclManager
|
||||
*
|
||||
* @param User $user A user to check for.
|
||||
* @param string|Entity $subject An entity type or entity.
|
||||
* @param string|null $action Action to check. Constants are available in the `Table` class.
|
||||
*
|
||||
* @param Table::ACTION_*|null $action $action Action to check. Constants are available in the `Table` class.
|
||||
* @throws NotImplemented
|
||||
* @noinspection PhpDocSignatureInspection
|
||||
*/
|
||||
public function check(User $user, $subject, ?string $action = null): bool
|
||||
{
|
||||
@@ -273,7 +274,8 @@ class AclManager
|
||||
*
|
||||
* @param User $user A user to check for.
|
||||
* @param string|Entity $subject An entity type or entity.
|
||||
* @param string|null $action Action to check. Constants are available in the `Table` class.
|
||||
* @param Table::ACTION_*|null $action Action to check. Constants are available in the `Table` class.
|
||||
* @noinspection PhpDocSignatureInspection
|
||||
*/
|
||||
public function tryCheck(User $user, $subject, ?string $action = null): bool
|
||||
{
|
||||
@@ -289,9 +291,9 @@ class AclManager
|
||||
*
|
||||
* @param User $user A user to check for.
|
||||
* @param Entity $entity An entity to check.
|
||||
* @param string $action Action to check. Constants are available in the `Table` class.
|
||||
*
|
||||
* @param Table::ACTION_* $action Action to check. Constants are available in the `Table` class.
|
||||
* @throws NotImplemented
|
||||
* @noinspection PhpDocSignatureInspection
|
||||
*/
|
||||
public function checkEntity(User $user, Entity $entity, string $action = Table::ACTION_READ): bool
|
||||
{
|
||||
@@ -405,6 +407,24 @@ class AclManager
|
||||
return $checker->checkTeam($user, $entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an entity is shared with a user.
|
||||
*
|
||||
* @param Table::ACTION_* $action
|
||||
* @since 8.5.0
|
||||
* @noinspection PhpDocSignatureInspection
|
||||
*/
|
||||
public function checkOwnershipShared(User $user, Entity $entity, string $action): bool
|
||||
{
|
||||
$checker = $this->getOwnershipChecker($entity->getEntityType());
|
||||
|
||||
if (!$checker instanceof OwnershipSharedChecker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $checker->checkShared($user, $entity, $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check access to scope. If $action is omitted, it will check whether a scope level is set to 'enabled'.
|
||||
*
|
||||
|
||||
@@ -37,6 +37,7 @@ use Espo\Core\Console\Command\Params;
|
||||
use Espo\Core\Console\IO;
|
||||
use Espo\Core\Container;
|
||||
use Espo\Core\Portal\Application as PortalApplication;
|
||||
use Espo\Core\Acl\Table;
|
||||
|
||||
/**
|
||||
* Checks access for websocket topic subscription. Prints `true` if access allowed.
|
||||
@@ -55,6 +56,8 @@ class AclCheck implements Command
|
||||
$userId = $options['userId'] ?? null;
|
||||
$scope = $options['scope'] ?? null;
|
||||
$id = $options['id'] ?? null;
|
||||
|
||||
/** @var Table::ACTION_*|null $action */
|
||||
$action = $options['action'] ?? null;
|
||||
|
||||
if (!$userId || !$scope || !$id) {
|
||||
@@ -102,6 +105,10 @@ class AclCheck implements Command
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Table::ACTION_*|null $action
|
||||
* @noinspection PhpDocSignatureInspection
|
||||
*/
|
||||
private function check(
|
||||
User $user,
|
||||
string $scope,
|
||||
|
||||
@@ -57,6 +57,8 @@ class CheckEntityType implements Func
|
||||
$userId = $arguments[0];
|
||||
$entityType = $arguments[1];
|
||||
$id = $arguments[2];
|
||||
|
||||
/** @var Table::ACTION_*|null $action */
|
||||
$action = $arguments[3] ?? Table::ACTION_READ;
|
||||
|
||||
if (!is_string($userId)) {
|
||||
|
||||
@@ -232,13 +232,22 @@ class AclManager extends InternalAclManager
|
||||
return parent::checkOwnershipOwn($user, $entity);
|
||||
}
|
||||
|
||||
public function checkOwnershipShared(User $user, Entity $entity, string $action): bool
|
||||
{
|
||||
if ($this->checkUserIsNotPortal($user)) {
|
||||
return $this->internalAclManager->checkOwnershipShared($user, $entity, $action);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function checkOwnershipTeam(User $user, Entity $entity): bool
|
||||
{
|
||||
if ($this->checkUserIsNotPortal($user)) {
|
||||
return $this->internalAclManager->checkOwnershipTeam($user, $entity);
|
||||
}
|
||||
|
||||
return parent::checkOwnershipOwn($user, $entity);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,9 @@ use Espo\Core\Select\AccessControl\Filter;
|
||||
use Espo\Core\Select\Helpers\RelationQueryHelper;
|
||||
use Espo\Core\Select\Helpers\FieldHelper;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\Query\Part\Where\OrGroup;
|
||||
use Espo\ORM\Query\Part\WhereClause;
|
||||
use Espo\ORM\Query\Part\WhereItem;
|
||||
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
|
||||
|
||||
class OnlyOwn implements Filter
|
||||
@@ -46,22 +49,52 @@ class OnlyOwn implements Filter
|
||||
|
||||
public function apply(QueryBuilder $queryBuilder): void
|
||||
{
|
||||
if ($this->fieldHelper->hasAssignedUsersField()) {
|
||||
$queryBuilder->where(
|
||||
$this->relationQueryHelper->prepareAssignedUsersWhere($this->entityType, $this->user->getId())
|
||||
);
|
||||
$ownItem = $this->getOwnWhereItem();
|
||||
|
||||
if ($this->fieldHelper->hasCollaboratorsField()) {
|
||||
$this->applyCollaborators($queryBuilder, $ownItem);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$ownItem) {
|
||||
$queryBuilder->where(['id' => null]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$queryBuilder->where($ownItem);
|
||||
}
|
||||
|
||||
private function applyCollaborators(QueryBuilder $queryBuilder, ?WhereItem $ownItem): void
|
||||
{
|
||||
$sharedItem = $this->relationQueryHelper->prepareCollaboratorsWhere($this->entityType, $this->user->getId());
|
||||
|
||||
$orBuilder = OrGroup::createBuilder();
|
||||
|
||||
if ($ownItem) {
|
||||
$orBuilder->add($ownItem);
|
||||
}
|
||||
|
||||
$orBuilder->add($sharedItem);
|
||||
|
||||
$queryBuilder->where($orBuilder->build());
|
||||
}
|
||||
|
||||
private function getOwnWhereItem(): ?WhereItem
|
||||
{
|
||||
if ($this->fieldHelper->hasAssignedUsersField()) {
|
||||
return $this->relationQueryHelper->prepareAssignedUsersWhere($this->entityType, $this->user->getId());
|
||||
}
|
||||
|
||||
if ($this->fieldHelper->hasAssignedUserField()) {
|
||||
$queryBuilder->where(['assignedUserId' => $this->user->getId()]);
|
||||
|
||||
return;
|
||||
return WhereClause::fromRaw(['assignedUserId' => $this->user->getId()]);
|
||||
}
|
||||
|
||||
if ($this->fieldHelper->hasCreatedByField()) {
|
||||
$queryBuilder->where(['createdById' => $this->user->getId()]);
|
||||
return WhereClause::fromRaw(['createdById' => $this->user->getId()]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,23 @@ class OnlyTeam implements Filter
|
||||
$orGroup['createdById'] = $this->user->getId();
|
||||
}
|
||||
|
||||
if ($this->fieldHelper->hasCollaboratorsField()) {
|
||||
$relationDefs = $this->defs
|
||||
->getEntity($this->entityType)
|
||||
->getRelation('collaborators');
|
||||
|
||||
$middleEntityType = ucfirst($relationDefs->getRelationshipName());
|
||||
$key1 = $relationDefs->getMidKey();
|
||||
$key2 = $relationDefs->getForeignMidKey();
|
||||
|
||||
$subQueryBuilder->leftJoin($middleEntityType, 'collaboratorsMiddle', [
|
||||
"collaboratorsMiddle.$key1:" => 'id',
|
||||
'collaboratorsMiddle.deleted' => false,
|
||||
]);
|
||||
|
||||
$orGroup["collaboratorsMiddle.$key2"] = $this->user->getId();
|
||||
}
|
||||
|
||||
$subQuery = $subQueryBuilder
|
||||
->where(['OR' => $orGroup])
|
||||
->build();
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Select\Bool\Filters;
|
||||
|
||||
use Espo\Core\Select\Bool\Filter;
|
||||
use Espo\Core\Select\Helpers\FieldHelper;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\Defs;
|
||||
use Espo\ORM\Query\Part\Condition as Cond;
|
||||
use Espo\ORM\Query\Part\Where\OrGroupBuilder;
|
||||
use Espo\ORM\Query\SelectBuilder as QueryBuilder;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class Shared implements Filter
|
||||
{
|
||||
public const NAME = 'shared';
|
||||
|
||||
public function __construct(
|
||||
private string $entityType,
|
||||
private User $user,
|
||||
private FieldHelper $fieldHelper,
|
||||
private Defs $defs
|
||||
) {}
|
||||
|
||||
public function apply(QueryBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void
|
||||
{
|
||||
if (!$this->fieldHelper->hasCollaboratorsField()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relationDefs = $this->defs
|
||||
->getEntity($this->entityType)
|
||||
->getRelation('collaborators');
|
||||
|
||||
$middleEntityType = ucfirst($relationDefs->getRelationshipName());
|
||||
$key1 = $relationDefs->getMidKey();
|
||||
$key2 = $relationDefs->getForeignMidKey();
|
||||
|
||||
$subQuery = QueryBuilder::create()
|
||||
->select('id')
|
||||
->from($this->entityType)
|
||||
->leftJoin($middleEntityType, 'collaboratorsMiddle', [
|
||||
"collaboratorsMiddle.$key1:" => 'id',
|
||||
'collaboratorsMiddle.deleted' => false,
|
||||
])
|
||||
->where(["collaboratorsMiddle.$key2" => $this->user->getId()])
|
||||
->build();
|
||||
|
||||
$orGroupBuilder->add(
|
||||
Cond::in(
|
||||
Cond::column('id'),
|
||||
$subQuery
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
namespace Espo\Core\Select\Helpers;
|
||||
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\Team;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\Crm\Entities\Account;
|
||||
@@ -55,9 +56,13 @@ class FieldHelper
|
||||
private const LINK_ASSIGNED_USERS = 'assignedUsers';
|
||||
private const LINK_ASSIGNED_USER = 'assignedUser';
|
||||
private const LINK_CREATED_BY = 'createdBy';
|
||||
private const LINK_COLLABORATORS = 'collaborators';
|
||||
|
||||
public function __construct(private string $entityType, private EntityManager $entityManager)
|
||||
{}
|
||||
public function __construct(
|
||||
private string $entityType,
|
||||
private EntityManager $entityManager,
|
||||
private Metadata $metadata,
|
||||
) {}
|
||||
|
||||
private function getSeed(): Entity
|
||||
{
|
||||
@@ -77,6 +82,20 @@ class FieldHelper
|
||||
return false;
|
||||
}
|
||||
|
||||
public function hasCollaboratorsField(): bool
|
||||
{
|
||||
if (
|
||||
$this->metadata->get("scopes.$this->entityType.collaborators") &&
|
||||
$this->getSeed()->hasRelation(self::LINK_COLLABORATORS) &&
|
||||
$this->getSeed()->hasAttribute(self::LINK_COLLABORATORS . 'Ids') &&
|
||||
$this->getRelationEntityType(self::LINK_COLLABORATORS) === User::ENTITY_TYPE
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function hasAssignedUserField(): bool
|
||||
{
|
||||
if (
|
||||
|
||||
@@ -47,9 +47,34 @@ class RelationQueryHelper
|
||||
|
||||
public function prepareAssignedUsersWhere(string $entityType, string $userId): WhereItem
|
||||
{
|
||||
return $this->prepareRelatedUsersWhere(
|
||||
$entityType,
|
||||
$userId,
|
||||
'assignedUsers',
|
||||
User::RELATIONSHIP_ENTITY_USER
|
||||
);
|
||||
}
|
||||
|
||||
public function prepareCollaboratorsWhere(string $entityType, string $userId): WhereItem
|
||||
{
|
||||
return $this->prepareRelatedUsersWhere(
|
||||
$entityType,
|
||||
$userId,
|
||||
'collaborators',
|
||||
User::RELATIONSHIP_ENTITY_COLLABORATOR
|
||||
);
|
||||
}
|
||||
|
||||
private function prepareRelatedUsersWhere(
|
||||
string $entityType,
|
||||
string $userId,
|
||||
string $field,
|
||||
string $relationship
|
||||
): WhereItem {
|
||||
|
||||
$relationDefs = $this->defs
|
||||
->getEntity($entityType)
|
||||
->getRelation('assignedUsers');
|
||||
->getRelation($field);
|
||||
|
||||
$middleEntityType = ucfirst($relationDefs->getRelationshipName());
|
||||
$key1 = $relationDefs->getMidKey();
|
||||
@@ -60,7 +85,7 @@ class RelationQueryHelper
|
||||
'm.deleted' => false,
|
||||
];
|
||||
|
||||
if ($middleEntityType === User::RELATIONSHIP_ENTITY_USER) {
|
||||
if ($middleEntityType === $relationship) {
|
||||
$joinWhere['m.entityType'] = $entityType;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Utils\Database\Orm\LinkConverters;
|
||||
|
||||
use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs;
|
||||
use Espo\Core\Utils\Database\Orm\Defs\EntityDefs;
|
||||
use Espo\Core\Utils\Database\Orm\Defs\RelationDefs;
|
||||
use Espo\Core\Utils\Database\Orm\LinkConverter;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\Defs\RelationDefs as LinkDefs;
|
||||
use Espo\ORM\Type\AttributeType;
|
||||
use Espo\ORM\Type\RelationType;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class EntityCollaborator implements LinkConverter
|
||||
{
|
||||
private const ENTITY_TYPE_LENGTH = 100;
|
||||
|
||||
public function convert(LinkDefs $linkDefs, string $entityType): EntityDefs
|
||||
{
|
||||
$name = $linkDefs->getName();
|
||||
$relationshipName = $linkDefs->getRelationshipName();
|
||||
|
||||
return EntityDefs::create()
|
||||
->withRelation(
|
||||
RelationDefs::create($name)
|
||||
->withType(RelationType::MANY_MANY)
|
||||
->withForeignEntityType(User::ENTITY_TYPE)
|
||||
->withRelationshipName($relationshipName)
|
||||
->withMidKeys('entityId', 'userId')
|
||||
->withConditions(['entityType' => $entityType])
|
||||
->withAdditionalColumn(
|
||||
AttributeDefs::create('entityType')
|
||||
->withType(AttributeType::VARCHAR)
|
||||
->withLength(self::ENTITY_TYPE_LENGTH)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ class User extends Person
|
||||
public const TYPE_SUPER_ADMIN = 'super-admin';
|
||||
|
||||
public const RELATIONSHIP_ENTITY_USER = 'EntityUser';
|
||||
public const RELATIONSHIP_ENTITY_COLLABORATOR = 'EntityCollaborator';
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Hooks\Common;
|
||||
|
||||
use Espo\Core\Field\Link;
|
||||
use Espo\Core\Field\LinkMultiple;
|
||||
use Espo\Core\Field\LinkMultipleItem;
|
||||
use Espo\Core\Hook\Hook\BeforeSave;
|
||||
use Espo\Core\ORM\Entity as CoreEntity;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\Repository\Option\SaveOptions;
|
||||
|
||||
/**
|
||||
* @implements BeforeSave<Entity>
|
||||
*/
|
||||
class Collaborators implements BeforeSave
|
||||
{
|
||||
public static int $order = 7;
|
||||
|
||||
private const FIELD_COLLABORATORS = 'collaborators';
|
||||
private const FIELD_ASSIGNED_USERS = 'assignedUsers';
|
||||
private const FIELD_ASSIGNED_USER = 'assignedUser';
|
||||
|
||||
public function __construct(
|
||||
private Metadata $metadata,
|
||||
) {}
|
||||
|
||||
public function beforeSave(Entity $entity, SaveOptions $options): void
|
||||
{
|
||||
if (!$entity instanceof CoreEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->hasCollaborators($entity)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS)) {
|
||||
$this->processAssignedUsers($entity);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->processAssignedUser($entity);
|
||||
}
|
||||
|
||||
private function hasCollaborators(CoreEntity $entity): bool
|
||||
{
|
||||
if (!$this->metadata->get("scopes.{$entity->getEntityType()}.collaborators")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$entity->hasLinkMultipleField(self::FIELD_COLLABORATORS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function processAssignedUsers(CoreEntity $entity): void
|
||||
{
|
||||
if (!$entity->has(self::FIELD_COLLABORATORS . 'Ids')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$assignedUsers = $entity->getValueObject(self::FIELD_ASSIGNED_USERS);
|
||||
$collaborators = $entity->getValueObject(self::FIELD_COLLABORATORS);
|
||||
|
||||
if (
|
||||
!$assignedUsers instanceof LinkMultiple ||
|
||||
!$collaborators instanceof LinkMultiple
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$countBefore = $collaborators->getCount();
|
||||
|
||||
foreach ($assignedUsers->getList() as $assignedUser) {
|
||||
$collaborators = $collaborators->withAdded($assignedUser);
|
||||
}
|
||||
|
||||
if ($countBefore === $collaborators->getCount()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity->setValueObject(self::FIELD_COLLABORATORS, $collaborators);
|
||||
}
|
||||
|
||||
private function processAssignedUser(CoreEntity $entity): void
|
||||
{
|
||||
$idAttr = self::FIELD_ASSIGNED_USER . 'Id';
|
||||
|
||||
if (!$entity->hasAttribute($idAttr) || !$entity->isAttributeChanged($idAttr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$assignedUser = $entity->getValueObject(self::FIELD_ASSIGNED_USER);
|
||||
|
||||
if (!$assignedUser instanceof Link) {
|
||||
return;
|
||||
}
|
||||
|
||||
$collaborators = $entity->getValueObject(self::FIELD_COLLABORATORS);
|
||||
|
||||
if (!$collaborators instanceof LinkMultiple) {
|
||||
return;
|
||||
}
|
||||
|
||||
$collaborators = $collaborators
|
||||
->withAdded(LinkMultipleItem::create($assignedUser->getId(), $assignedUser->getName()));
|
||||
|
||||
$entity->setValueObject(self::FIELD_COLLABORATORS, $collaborators);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
[
|
||||
"assignedUser",
|
||||
"teams",
|
||||
"createdAt",
|
||||
"createdBy",
|
||||
"modifiedAt",
|
||||
"collaborators",
|
||||
"status",
|
||||
"priority",
|
||||
"account",
|
||||
"contact",
|
||||
"number",
|
||||
"createdAt",
|
||||
"createdBy",
|
||||
"modifiedAt",
|
||||
"modifiedAt",
|
||||
"status",
|
||||
"priority",
|
||||
"description"
|
||||
]
|
||||
]
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
[
|
||||
"assignedUser",
|
||||
"teams",
|
||||
"createdAt",
|
||||
"createdBy",
|
||||
"modifiedAt",
|
||||
"collaborators",
|
||||
"status",
|
||||
"priority",
|
||||
"parent",
|
||||
"account",
|
||||
"dateCompleted",
|
||||
"dateStart",
|
||||
"dateEnd",
|
||||
"status",
|
||||
"parent",
|
||||
"priority"
|
||||
"dateCompleted",
|
||||
"createdAt",
|
||||
"createdBy",
|
||||
"modifiedAt"
|
||||
]
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
"view": "views/admin/entity-manager/fields/duplicate-check-field-list"
|
||||
}
|
||||
},
|
||||
"collaborators": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"assignedUsers": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
@@ -39,6 +46,13 @@
|
||||
"view": "views/admin/entity-manager/fields/duplicate-check-field-list"
|
||||
}
|
||||
},
|
||||
"collaborators": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"assignedUsers": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
@@ -63,6 +77,13 @@
|
||||
"view": "views/admin/entity-manager/fields/duplicate-check-field-list"
|
||||
}
|
||||
},
|
||||
"collaborators": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"assignedUsers": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
@@ -72,6 +93,13 @@
|
||||
}
|
||||
},
|
||||
"Opportunity": {
|
||||
"collaborators": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"assignedUsers": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
@@ -81,6 +109,13 @@
|
||||
}
|
||||
},
|
||||
"Document": {
|
||||
"collaborators": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"assignedUsers": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
@@ -90,6 +125,13 @@
|
||||
}
|
||||
},
|
||||
"Case": {
|
||||
"collaborators": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"assignedUsers": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
@@ -99,6 +141,13 @@
|
||||
}
|
||||
},
|
||||
"KnowledgeBaseArticle": {
|
||||
"collaborators": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"assignedUsers": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
@@ -202,6 +251,13 @@
|
||||
"view": "crm:views/admin/entity-manager/fields/status-list"
|
||||
}
|
||||
},
|
||||
"collaborators": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"assignedUsers": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
@@ -210,6 +266,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TargetList": {
|
||||
"collaborators": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@Event": {
|
||||
"activityStatusList": {
|
||||
"location": "scopes",
|
||||
@@ -247,6 +312,13 @@
|
||||
"view": "crm:views/admin/entity-manager/fields/status-list"
|
||||
}
|
||||
},
|
||||
"collaborators": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"assignedUsers": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"links": {
|
||||
"inboundEmail": {
|
||||
"readOnly": true
|
||||
},
|
||||
"collaborators": {
|
||||
"readOnly": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,10 @@
|
||||
"email": {
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"collaborators": {
|
||||
"readOnly": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"module": "Module",
|
||||
"version": "Version",
|
||||
"primaryFilters": "Primary Filters",
|
||||
"assignedUsers": "Multiple Assigned Users"
|
||||
"assignedUsers": "Multiple Assigned Users",
|
||||
"collaborators": "Collaborators"
|
||||
},
|
||||
"options": {
|
||||
"type": {
|
||||
@@ -95,6 +96,7 @@
|
||||
"beforeSaveApiScript": "A script called on create and update API requests before an entity is saved. Use for custom validation and duplicate checking."
|
||||
},
|
||||
"tooltips": {
|
||||
"collaborators": "The ability to share records with specific users.",
|
||||
"assignedUsers": "The ability to assign multiple users to a record.\n\nNote that after enabling the parameter, existing assigned users won't be transferred to the new *Assigned Users* field.",
|
||||
"duplicateCheckFieldList": "Which fields to check when performing checking for duplicates.",
|
||||
"updateDuplicateCheck": "Perform checking for duplicates when updating a record.",
|
||||
|
||||
@@ -428,7 +428,8 @@
|
||||
"boolFilters": {
|
||||
"onlyMy": "Only My",
|
||||
"onlyMyTeam": "My Team",
|
||||
"followed": "Followed"
|
||||
"followed": "Followed",
|
||||
"shared": "Shared"
|
||||
},
|
||||
"presetFilters": {
|
||||
"followed": "Followed",
|
||||
@@ -457,6 +458,7 @@
|
||||
"salutationName": "Salutation",
|
||||
"assignedUser": "Assigned User",
|
||||
"assignedUsers": "Assigned Users",
|
||||
"collaborators": "Collaborators",
|
||||
"emailAddress": "Email",
|
||||
"emailAddressData": "Email Address Data",
|
||||
"emailAddressIsOptedOut": "Email Address is Opted-Out",
|
||||
@@ -493,6 +495,7 @@
|
||||
"links": {
|
||||
"assignedUser": "Assigned User",
|
||||
"assignedUsers": "Assigned Users",
|
||||
"collaborators": "Collaborators",
|
||||
"createdBy": "Created By",
|
||||
"modifiedBy": "Modified By",
|
||||
"team": "Team",
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
"read": "yes",
|
||||
"edit": "no"
|
||||
},
|
||||
"collaborators": false,
|
||||
"teams": false
|
||||
},
|
||||
"scopeFieldLevel": {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\DeleteHasChildrenLinks"
|
||||
],
|
||||
"updateHookClassNameList": [
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\AssignedUsersUpdateHook"
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\AssignedUsersUpdateHook",
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\CollaboratorsUpdateHook"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -38,6 +38,13 @@
|
||||
"view": "views/admin/entity-manager/fields/duplicate-check-field-list"
|
||||
}
|
||||
},
|
||||
"collaborators": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"assignedUsers": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
@@ -62,6 +69,13 @@
|
||||
"view": "views/admin/entity-manager/fields/duplicate-check-field-list"
|
||||
}
|
||||
},
|
||||
"collaborators": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"assignedUsers": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
@@ -86,6 +100,13 @@
|
||||
"view": "views/admin/entity-manager/fields/duplicate-check-field-list"
|
||||
}
|
||||
},
|
||||
"collaborators": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"assignedUsers": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
@@ -110,6 +131,13 @@
|
||||
"view": "views/admin/entity-manager/fields/duplicate-check-field-list"
|
||||
}
|
||||
},
|
||||
"collaborators": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"assignedUsers": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"entityUser": {
|
||||
"converterClassName": "Espo\\Core\\Utils\\Database\\Orm\\LinkConverters\\EntityUser"
|
||||
},
|
||||
"entityCollaborator": {
|
||||
"converterClassName": "Espo\\Core\\Utils\\Database\\Orm\\LinkConverters\\EntityCollaborator"
|
||||
},
|
||||
"smsPhoneNumber": {
|
||||
"converterClassName": "Espo\\Core\\Utils\\Database\\Orm\\LinkConverters\\SmsPhoneNumber"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\EntityManager\Hook\Hooks;
|
||||
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\Type\RelationType;
|
||||
use Espo\Tools\EntityManager\Hook\UpdateHook;
|
||||
use Espo\Tools\EntityManager\Params;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class CollaboratorsUpdateHook implements UpdateHook
|
||||
{
|
||||
private const PARAM = 'collaborators';
|
||||
private const FIELD = 'collaborators';
|
||||
private const RELATION_NAME = 'entityCollaborator';
|
||||
|
||||
private const DEFAULT_MAX_COUNT = 30;
|
||||
|
||||
public function __construct(
|
||||
private Metadata $metadata,
|
||||
private Log $log,
|
||||
) {}
|
||||
|
||||
public function process(Params $params, Params $previousParams): void
|
||||
{
|
||||
if ($params->get(self::PARAM) && !$previousParams->get(self::PARAM)) {
|
||||
$this->add($params->getName());
|
||||
} else if (!$params->get(self::PARAM) && $previousParams->get(self::PARAM)) {
|
||||
$this->remove($params->getName());
|
||||
}
|
||||
}
|
||||
|
||||
private function add(string $entityType): void
|
||||
{
|
||||
if ($this->metadata->get("entityDefs.$entityType.links." . self::FIELD . ".isCustom")) {
|
||||
$this->log->warning("Cannot enable collaborators for $entityType as the link already exists.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->metadata->set('entityDefs', $entityType, [
|
||||
'fields' => [
|
||||
self::FIELD => [
|
||||
'type' => FieldType::LINK_MULTIPLE,
|
||||
'view' => 'views/fields/collaborators',
|
||||
'maxCount' => self::DEFAULT_MAX_COUNT,
|
||||
'fieldManagerParamList' => [
|
||||
'readOnly',
|
||||
'readOnlyAfterCreate',
|
||||
'audited',
|
||||
'autocompleteOnEmpty',
|
||||
'maxCount',
|
||||
'inlineEditDisabled',
|
||||
'tooltipText'
|
||||
]
|
||||
],
|
||||
],
|
||||
'links' => [
|
||||
self::FIELD => [
|
||||
'type' => RelationType::HAS_MANY,
|
||||
'entity' => User::ENTITY_TYPE,
|
||||
'relationName' => self::RELATION_NAME,
|
||||
'layoutRelationshipsDisabled' => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->metadata->set('entityAcl', $entityType, [
|
||||
'links' => [
|
||||
self::FIELD => [
|
||||
'readOnly' => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->metadata->save();
|
||||
}
|
||||
|
||||
private function remove(string $entityType): void
|
||||
{
|
||||
$field = self::FIELD;
|
||||
|
||||
if (
|
||||
$this->metadata->get("entityDefs.$entityType.links.$field.isCustom") &&
|
||||
$this->metadata->get("entityDefs.$entityType.links.$field.relationName") !== self::RELATION_NAME
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->metadata->delete('entityAcl', $entityType, [
|
||||
'links.' . self::FIELD,
|
||||
]);
|
||||
|
||||
$this->metadata->save();
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ class NameUtil
|
||||
'teams',
|
||||
'assignedUser',
|
||||
'assignedUsers',
|
||||
'collaborators',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,6 +66,7 @@ class FieldManager
|
||||
'teams',
|
||||
'assignedUser',
|
||||
'assignedUsers',
|
||||
'collaborators',
|
||||
];
|
||||
|
||||
/** @var string[] */
|
||||
|
||||
@@ -524,7 +524,8 @@ class HookProcessor
|
||||
// @todo Introduce a metadata parameter.
|
||||
$entity->isAttributeChanged('assignedUserId') ||
|
||||
$entity->isAttributeChanged('teamsIds') ||
|
||||
$entity->isAttributeChanged('assignedUsersIds')
|
||||
$entity->isAttributeChanged('assignedUsersIds') ||
|
||||
$entity->isAttributeChanged('collaboratorsIds')
|
||||
)
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -107,9 +107,7 @@ class Helper
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
$this->aclManager->checkReadOnlyOwn($user, $scope)
|
||||
) {
|
||||
if ($this->aclManager->checkReadOnlyOwn($user, $scope)) {
|
||||
$list[] = $scope;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ class Service
|
||||
* Notes having `related` or `superParent` are subjects to access control
|
||||
* through `users` and `teams` fields.
|
||||
*
|
||||
* When users or teams of `related` or `parent` record are changed
|
||||
* When users or teams of `related` or `parent` record are changed,
|
||||
* the note record will be changed too.
|
||||
*/
|
||||
private function processNoteTeamsUsers(Note $note, Entity $entity): void
|
||||
@@ -384,26 +384,37 @@ class Service
|
||||
}
|
||||
|
||||
$note->setAclIsProcessed();
|
||||
|
||||
$note->setTeamsIds([]);
|
||||
$note->setUsersIds([]);
|
||||
|
||||
if ($entity->hasLinkMultipleField('teams')) {
|
||||
$teamIdList = $entity->getLinkMultipleIdList('teams');
|
||||
|
||||
$note->setTeamsIds($teamIdList);
|
||||
$note->setTeamsIds($entity->getLinkMultipleIdList('teams'));
|
||||
}
|
||||
|
||||
$userIds = array_merge(
|
||||
$this->getAssignedUserIds($entity),
|
||||
$this->getCollaboratorIds($entity)
|
||||
);
|
||||
|
||||
$userIds = array_values(array_unique($userIds));
|
||||
|
||||
$note->setUsersIds($userIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getAssignedUserIds(CoreEntity $entity): array
|
||||
{
|
||||
$ownerUserField = $this->aclManager->getReadOwnerUserField($entity->getEntityType());
|
||||
|
||||
if (!$ownerUserField) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
$defs = $this->entityManager->getDefs()->getEntity($entity->getEntityType());
|
||||
|
||||
if (!$defs->hasField($ownerUserField)) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
$fieldDefs = $defs->getField($ownerUserField);
|
||||
@@ -413,26 +424,42 @@ class Service
|
||||
} else if ($fieldDefs->getType() === FieldType::LINK) {
|
||||
$ownerUserIdAttribute = $ownerUserField . 'Id';
|
||||
} else {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!$entity->has($ownerUserIdAttribute)) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($fieldDefs->getType() === FieldType::LINK_MULTIPLE) {
|
||||
$userIdList = $entity->getLinkMultipleIdList($ownerUserField);
|
||||
} else {
|
||||
$userId = $entity->get($ownerUserIdAttribute);
|
||||
|
||||
if (!$userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userIdList = [$userId];
|
||||
return $entity->getLinkMultipleIdList($ownerUserField);
|
||||
}
|
||||
|
||||
$note->setUsersIds($userIdList);
|
||||
$userId = $entity->get($ownerUserIdAttribute);
|
||||
|
||||
if ($userId) {
|
||||
return [$userId];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getCollaboratorIds(CoreEntity $entity): array
|
||||
{
|
||||
if (!$this->metadata->get("scopes.{$entity->getEntityType()}.collaborators")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$field = 'collaborators';
|
||||
|
||||
if (!$entity->hasLinkMultipleField($field)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $entity->getLinkMultipleIdList($field);
|
||||
}
|
||||
|
||||
public function noteEmailReceived(Entity $entity, Email $email, bool $isInitial = false): void
|
||||
|
||||
@@ -157,7 +157,7 @@ class AclManager {
|
||||
*
|
||||
* @param {string} scope A scope.
|
||||
* @param {module:acl-manager~action} action An action.
|
||||
* @returns {'yes'|'all'|'team'|'no'|null}
|
||||
* @returns {'yes'|'all'|'team'|'own'|'no'|null}
|
||||
*/
|
||||
getLevel(scope, action) {
|
||||
if (!(scope in this.data.table)) {
|
||||
@@ -301,6 +301,17 @@ class AclManager {
|
||||
return this.getImplementation(model.entityType).checkInTeam(model);
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
/**
|
||||
* Check if a record is shared with the user.
|
||||
*
|
||||
* @param {module:model} model A model.
|
||||
* @returns {boolean|null} True if shared, null if not clear.
|
||||
*/
|
||||
checkIsShared(model) {
|
||||
return this.getImplementation(model.entityType).checkIsShared(model);
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
/**
|
||||
* Check an assignment permission to a user.
|
||||
|
||||
+66
-19
@@ -56,7 +56,17 @@ class Acl {
|
||||
|
||||
this.aclAllowDeleteCreated = params.aclAllowDeleteCreated;
|
||||
this.teamsFieldIsForbidden = params.teamsFieldIsForbidden;
|
||||
this.forbiddenFieldList = params.forbiddenFieldList;
|
||||
|
||||
/**
|
||||
* @type {string[]}
|
||||
*/
|
||||
this.forbiddenFieldList = params.forbiddenFieldList || [];
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.collaboratorsFieldIsForbidden = this.forbiddenFieldList.includes('collaborators');
|
||||
|
||||
/**
|
||||
* @type {import('acl-manager').default}
|
||||
@@ -81,7 +91,7 @@ class Acl {
|
||||
* @param {string|boolean|Object.<string, string>} data Access data.
|
||||
* @param {module:acl-manager~action|null} [action=null] An action.
|
||||
* @param {boolean} [precise=false] To return `null` if `inTeam == null`.
|
||||
* @param {Object|null} [entityAccessData=null] Entity access data. `inTeam`, `isOwner`.
|
||||
* @param {Record.<string, boolean|null>|null} [entityAccessData=null] Entity access data. `inTeam`, `isOwner`.
|
||||
* @returns {boolean|null} True if access allowed.
|
||||
*/
|
||||
checkScope(data, action, precise, entityAccessData) {
|
||||
@@ -89,6 +99,7 @@ class Acl {
|
||||
|
||||
const inTeam = entityAccessData.inTeam;
|
||||
const isOwner = entityAccessData.isOwner;
|
||||
const isShared = entityAccessData.isShared;
|
||||
|
||||
if (this.getUser().isAdmin()) {
|
||||
if (data === false) {
|
||||
@@ -138,7 +149,7 @@ class Acl {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof isOwner === 'undefined') {
|
||||
if (isOwner === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -148,31 +159,32 @@ class Acl {
|
||||
}
|
||||
}
|
||||
|
||||
let result = false;
|
||||
if (isShared) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value === 'team') {
|
||||
result = inTeam;
|
||||
|
||||
if (inTeam === null) {
|
||||
if (precise) {
|
||||
result = null;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (inTeam) {
|
||||
if (inTeam) {
|
||||
if (value === 'team') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isOwner === null) {
|
||||
if (precise) {
|
||||
let result = false;
|
||||
|
||||
if (value === 'team') {
|
||||
if (inTeam === null && precise) {
|
||||
result = null;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isOwner === null && precise) {
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (isShared === null) {
|
||||
result = null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -191,9 +203,16 @@ class Acl {
|
||||
return true;
|
||||
}
|
||||
|
||||
let isShared = false;
|
||||
|
||||
if (action === 'read' || action === 'stream') {
|
||||
isShared = this.checkIsShared(model);
|
||||
}
|
||||
|
||||
const entityAccessData = {
|
||||
isOwner: this.checkIsOwner(model),
|
||||
inTeam: this.checkInTeam(model),
|
||||
isShared: isShared,
|
||||
};
|
||||
|
||||
return this.checkScope(data, action, precise, entityAccessData);
|
||||
@@ -304,6 +323,10 @@ class Acl {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!model.hasField('teams')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -320,6 +343,30 @@ class Acl {
|
||||
return inTeam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a record is shared with the user.
|
||||
*
|
||||
* @param {module:model} model A model.
|
||||
* @returns {boolean|null} True if shared. Null if not enough data to determine.
|
||||
*/
|
||||
checkIsShared(model) {
|
||||
if (!model.has('collaboratorsIds')) {
|
||||
if (this.collaboratorsFieldIsForbidden) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!model.hasField('collaborators')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const collaboratorsIds = model.getLinkMultipleIdList('collaborators');
|
||||
|
||||
return collaboratorsIds.includes(this.user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a permission level.
|
||||
*
|
||||
|
||||
@@ -137,6 +137,15 @@ class DefaultsPopulator {
|
||||
defaultHash['teamsNames'][defaultTeamId] = this.user.get('defaultTeamName');
|
||||
}
|
||||
}
|
||||
|
||||
const hasCollaborators = model.hasField('collaborators') &&
|
||||
model.getLinkParam('collaborators', 'entity') === 'User' &&
|
||||
this.metadata.get(`scopes.${model.entityType}.collaborators`);
|
||||
|
||||
if (hasCollaborators) {
|
||||
defaultHash.collaboratorsIds = [this.user.id];
|
||||
defaultHash.collaboratorsNames = {[this.user.id]: this.user.attributes.name};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -250,7 +250,7 @@ class FieldManagerEditView extends View {
|
||||
|
||||
if (
|
||||
item === 'createButton' &&
|
||||
['assignedUser', 'assignedUsers', 'teams'].includes(this.field)
|
||||
['assignedUser', 'assignedUsers', 'teams', 'collaborators'].includes(this.field)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ export default class extends MultiEnumFieldView {
|
||||
this.params.options.push('followed');
|
||||
}
|
||||
|
||||
if (this.getMetadata().get(`scopes.${entityType}.collaborators`)) {
|
||||
this.params.options.push('shared');
|
||||
}
|
||||
|
||||
this.translatedOptions = {};
|
||||
|
||||
this.params.options.forEach(item => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
import LinkMultipleFieldView from 'views/fields/link-multiple';
|
||||
|
||||
export default class CollaboratorsFieldView extends LinkMultipleFieldView {
|
||||
|
||||
init() {
|
||||
this.assignmentPermission = this.getAcl().getPermissionLevel('assignmentPermission');
|
||||
|
||||
if (this.assignmentPermission === 'no') {
|
||||
this.readOnly = true;
|
||||
}
|
||||
|
||||
super.init();
|
||||
}
|
||||
|
||||
getSelectBoolFilterList() {
|
||||
if (this.assignmentPermission === 'team') {
|
||||
return ['onlyMyTeam'];
|
||||
}
|
||||
}
|
||||
|
||||
getSelectPrimaryFilterName() {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
getDetailLinkHtml(id, name) {
|
||||
const html = super.getDetailLinkHtml(id);
|
||||
|
||||
const avatarHtml = this.isDetailMode() ?
|
||||
this.getHelper().getAvatarHtml(id, 'small', 18, 'avatar-link') : '';
|
||||
|
||||
if (!avatarHtml) {
|
||||
return html;
|
||||
}
|
||||
|
||||
return `${avatarHtml} ${html}`;
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
getOnEmptyAutocomplete() {
|
||||
if (this.params.autocompleteOnEmpty) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.ids && this.ids.includes(this.getUser().id)) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,10 @@ class SearchView extends View {
|
||||
this.boolFilterList.push('followed');
|
||||
}
|
||||
|
||||
if (this.getMetadata().get(`scopes.${this.entityType}.collaborators`) && !this.getUser().isPortal()) {
|
||||
this.boolFilterList.push('shared');
|
||||
}
|
||||
|
||||
this.loadSearchData();
|
||||
|
||||
if (this.hasAdvancedFilter()) {
|
||||
|
||||
@@ -39,6 +39,7 @@ class RoleRecordTableView extends View {
|
||||
/** @type {'detail'|'string'} */
|
||||
mode = 'detail'
|
||||
lowestLevelByDefault = true
|
||||
collaborators = true
|
||||
|
||||
actionList = ['create', 'read', 'edit', 'delete', 'stream']
|
||||
accessList = ['not-set', 'enabled', 'disabled']
|
||||
@@ -211,6 +212,14 @@ class RoleRecordTableView extends View {
|
||||
}
|
||||
}
|
||||
|
||||
if (level && !levelList.includes(level)) {
|
||||
levelList.push(level);
|
||||
|
||||
levelList.sort((a, b) => {
|
||||
return this.levelList.findIndex(it => it === a) - this.levelList.findIndex(it => it === b);
|
||||
});
|
||||
}
|
||||
|
||||
list.push({
|
||||
level: level,
|
||||
name: scope + '-' + action,
|
||||
@@ -231,17 +240,28 @@ class RoleRecordTableView extends View {
|
||||
return aclDataList;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} scope
|
||||
* @param {'create'|'read'|'edit'|'delete'|'stream'} action
|
||||
* @return {string[]}
|
||||
*/
|
||||
getLevelList(scope, action) {
|
||||
if (this.booleanActionList.includes(action)) {
|
||||
return this.booleanLevelList;
|
||||
}
|
||||
|
||||
const specifiedLevelList =
|
||||
this.getMetadata().get(`scopes.${scope}.${this.type}ActionLevelListMap.${action}`) ||
|
||||
this.getMetadata().get(`scopes.${scope}.${this.type}LevelList`);
|
||||
|
||||
if (specifiedLevelList) {
|
||||
return specifiedLevelList;
|
||||
}
|
||||
|
||||
const type = this.aclTypeMap[scope];
|
||||
|
||||
return this.getMetadata().get(['scopes', scope, this.type + 'ActionLevelListMap', action]) ||
|
||||
this.getMetadata().get(['scopes', scope, this.type + 'LevelList']) ||
|
||||
this.levelListMap[type] ||
|
||||
[];
|
||||
return this.levelListMap[type] || [];
|
||||
}
|
||||
|
||||
setup() {
|
||||
|
||||
@@ -47,10 +47,10 @@
|
||||
"enum": ["yes", "no"]
|
||||
},
|
||||
"levelsAll": {
|
||||
"enum": ["yes", "all", "team", "own", "no"]
|
||||
"enum": ["yes", "all", "team", "shared", "own", "no"]
|
||||
},
|
||||
"levels": {
|
||||
"enum": ["all", "team", "own", "no"]
|
||||
"enum": ["all", "team", "shared", "own", "no"]
|
||||
},
|
||||
"group": {
|
||||
"type": "object",
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
"description": "Enables stars.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"collaborators": {
|
||||
"description": "Enables collaborators.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"importable": {
|
||||
"description": "Whether the entity is available in the Import tool.",
|
||||
"type": "boolean"
|
||||
|
||||
@@ -29,17 +29,22 @@
|
||||
|
||||
namespace tests\integration\Espo\User;
|
||||
|
||||
use Espo\Core\Acl\Table;
|
||||
use Espo\Core\AclManager;
|
||||
use Espo\Core\Api\ControllerActionProcessor;
|
||||
use Espo\Core\Api\ResponseWrapper;
|
||||
use Espo\Core\DataManager;
|
||||
use Espo\Core\Field\Date;
|
||||
use Espo\Core\Record\CreateParams;
|
||||
use Espo\Core\Record\ServiceContainer;
|
||||
use Espo\Core\Record\UpdateParams;
|
||||
use Espo\Core\Select\SearchParams;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Core\Select\Where\Item as WhereItem;
|
||||
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
|
||||
use Espo\Entities\Team;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\Crm\Entities\Account;
|
||||
use Espo\Modules\Crm\Entities\CaseObj;
|
||||
@@ -47,6 +52,8 @@ use Espo\Modules\Crm\Entities\Contact;
|
||||
use Espo\Modules\Crm\Entities\Lead;
|
||||
use Espo\Modules\Crm\Entities\Meeting;
|
||||
use Espo\Modules\Crm\Entities\Opportunity;
|
||||
use Espo\Modules\Crm\Entities\Task;
|
||||
use Espo\Tools\EntityManager\EntityManager as EntityManagerTool;
|
||||
use Exception;
|
||||
|
||||
class AclTest extends \tests\integration\Core\BaseTestCase
|
||||
@@ -705,4 +712,156 @@ class AclTest extends \tests\integration\Core\BaseTestCase
|
||||
'assignedUserId' => $userId,
|
||||
], CreateParams::create());
|
||||
}
|
||||
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
public function testShared(): void
|
||||
{
|
||||
$entityManagerTool = $this->getInjectableFactory()->create(EntityManagerTool::class);
|
||||
$dataManager = $this->getContainer()->getByClass(DataManager::class);
|
||||
|
||||
/** @noinspection PhpArrayKeyDoesNotMatchArrayShapeInspection */
|
||||
$entityManagerTool->update(Task::ENTITY_TYPE, ['collaborators' => true]);
|
||||
$dataManager->rebuild();
|
||||
|
||||
$user1 = $this->createUser('test-1', [
|
||||
'data' => [
|
||||
Task::ENTITY_TYPE => [
|
||||
Table::ACTION_READ => Table::LEVEL_OWN,
|
||||
Table::ACTION_EDIT => Table::LEVEL_OWN,
|
||||
Table::ACTION_STREAM => Table::LEVEL_OWN,
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$user2 = $this->createUser('test-2', [
|
||||
'data' => [
|
||||
Task::ENTITY_TYPE => [
|
||||
Table::ACTION_READ => Table::LEVEL_OWN,
|
||||
Table::ACTION_EDIT => Table::LEVEL_OWN,
|
||||
Table::ACTION_STREAM => Table::LEVEL_NO,
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$user3 = $this->createUser('test-3', [
|
||||
'data' => [
|
||||
Task::ENTITY_TYPE => [
|
||||
Table::ACTION_READ => Table::LEVEL_TEAM,
|
||||
Table::ACTION_EDIT => Table::LEVEL_TEAM,
|
||||
Table::ACTION_STREAM => Table::LEVEL_TEAM,
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$user4 = $this->createUser('test-4', [
|
||||
'data' => [
|
||||
Task::ENTITY_TYPE => [
|
||||
Table::ACTION_READ => Table::LEVEL_NO,
|
||||
Table::ACTION_EDIT => Table::LEVEL_NO,
|
||||
Table::ACTION_STREAM => Table::LEVEL_NO,
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$aclManager = $this->getContainer()->getByClass(AclManager::class);
|
||||
|
||||
$team = $em->createEntity(Team::ENTITY_TYPE);
|
||||
|
||||
$em->getRelation($user3, 'teams')->relate($team);
|
||||
|
||||
$em->refreshEntity($user1);
|
||||
$em->refreshEntity($user2);
|
||||
$em->refreshEntity($user3);
|
||||
$em->refreshEntity($user4);
|
||||
|
||||
$entity1 = $em->createEntity(Task::ENTITY_TYPE, [
|
||||
'collaboratorsIds' => [$user1->getId(), $user3->getId()]
|
||||
]);
|
||||
|
||||
$entity2 = $em->createEntity(Task::ENTITY_TYPE, [
|
||||
'teamsIds' => [$team->getId()]
|
||||
]);
|
||||
|
||||
$entity3 = $em->createEntity(Task::ENTITY_TYPE, [
|
||||
'collaboratorsIds' => [$user3->getId()]
|
||||
]);
|
||||
|
||||
$entity4 = $em->createEntity(Task::ENTITY_TYPE, [
|
||||
'collaboratorsIds' => [$user2->getId(), $user4->getId()]
|
||||
]);
|
||||
|
||||
$this->assertTrue($aclManager->checkEntityRead($user1, $entity1));
|
||||
$this->assertFalse($aclManager->checkEntityEdit($user1, $entity1));
|
||||
$this->assertTrue($aclManager->checkEntityStream($user1, $entity1));
|
||||
|
||||
$this->assertFalse($aclManager->checkEntityRead($user2, $entity1));
|
||||
$this->assertFalse($aclManager->checkEntityEdit($user2, $entity1));
|
||||
$this->assertFalse($aclManager->checkEntityStream($user2, $entity1));
|
||||
|
||||
$this->assertTrue($aclManager->checkEntityRead($user3, $entity1));
|
||||
$this->assertFalse($aclManager->checkEntityEdit($user3, $entity1));
|
||||
$this->assertTrue($aclManager->checkEntityStream($user3, $entity1));
|
||||
|
||||
$this->assertTrue($aclManager->checkEntityRead($user2, $entity4));
|
||||
$this->assertFalse($aclManager->checkEntityStream($user2, $entity4));
|
||||
|
||||
$this->assertFalse($aclManager->checkEntityRead($user4, $entity4));
|
||||
$this->assertFalse($aclManager->checkEntityStream($user4, $entity4));
|
||||
|
||||
$selectBuilderFactory = $this->getInjectableFactory()->create(SelectBuilderFactory::class);
|
||||
|
||||
// user1
|
||||
|
||||
$query = $selectBuilderFactory
|
||||
->create()
|
||||
->forUser($user1)
|
||||
->from(Task::ENTITY_TYPE)
|
||||
->withAccessControlFilter()
|
||||
->build();
|
||||
|
||||
$entity1Found = $em->getRDBRepositoryByClass(Task::class)
|
||||
->clone($query)
|
||||
->where(['id' => $entity1->getId()])
|
||||
->findOne();
|
||||
|
||||
$this->assertNotNull($entity1Found);
|
||||
|
||||
$entity2Found = $em->getRDBRepositoryByClass(Task::class)
|
||||
->clone($query)
|
||||
->where(['id' => $entity2->getId()])
|
||||
->findOne();
|
||||
|
||||
$this->assertNull($entity2Found);
|
||||
|
||||
// user3
|
||||
|
||||
$query = $selectBuilderFactory
|
||||
->create()
|
||||
->forUser($user3)
|
||||
->from(Task::ENTITY_TYPE)
|
||||
->withAccessControlFilter()
|
||||
->build();
|
||||
|
||||
$entity1Found = $em->getRDBRepositoryByClass(Task::class)
|
||||
->clone($query)
|
||||
->where(['id' => $entity1->getId()])
|
||||
->findOne();
|
||||
|
||||
$this->assertNotNull($entity1Found);
|
||||
|
||||
$entity2Found = $em->getRDBRepositoryByClass(Task::class)
|
||||
->clone($query)
|
||||
->where(['id' => $entity2->getId()])
|
||||
->findOne();
|
||||
|
||||
$this->assertNotNull($entity2Found);
|
||||
|
||||
$entity3Found = $em->getRDBRepositoryByClass(Task::class)
|
||||
->clone($query)
|
||||
->where(['id' => $entity3->getId()])
|
||||
->findOne();
|
||||
|
||||
$this->assertNotNull($entity3Found);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,10 @@
|
||||
|
||||
namespace tests\unit\Espo\Core\Acl;
|
||||
|
||||
use Espo\Core\{
|
||||
Acl\AccessChecker\ScopeCheckerData,
|
||||
};
|
||||
use Espo\Core\Acl\AccessChecker\ScopeCheckerData;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ScopeCheckerDataTest extends \PHPUnit\Framework\TestCase
|
||||
class ScopeCheckerDataTest extends TestCase
|
||||
{
|
||||
protected function setUp() : void
|
||||
{
|
||||
@@ -45,8 +44,9 @@ class ScopeCheckerDataTest extends \PHPUnit\Framework\TestCase
|
||||
::createBuilder()
|
||||
->build();
|
||||
|
||||
$this->assertEquals(false, $checkerData->isOwn());
|
||||
$this->assertEquals(false, $checkerData->inTeam());
|
||||
$this->assertFalse($checkerData->isOwn());
|
||||
$this->assertFalse($checkerData->inTeam());
|
||||
$this->assertFalse($checkerData->isShared());
|
||||
}
|
||||
|
||||
public function testCheckerData1()
|
||||
@@ -55,10 +55,12 @@ class ScopeCheckerDataTest extends \PHPUnit\Framework\TestCase
|
||||
::createBuilder()
|
||||
->setIsOwn(true)
|
||||
->setInTeam(true)
|
||||
->setIsShared(true)
|
||||
->build();
|
||||
|
||||
$this->assertEquals(true, $checkerData->isOwn());
|
||||
$this->assertEquals(true, $checkerData->inTeam());
|
||||
$this->assertTrue($checkerData->isOwn());
|
||||
$this->assertTrue($checkerData->inTeam());
|
||||
$this->assertTrue($checkerData->isShared());
|
||||
}
|
||||
|
||||
public function testCheckerData2()
|
||||
@@ -71,10 +73,16 @@ class ScopeCheckerDataTest extends \PHPUnit\Framework\TestCase
|
||||
return true;
|
||||
}
|
||||
)
|
||||
->setIsSharedChecker(
|
||||
function (): bool {
|
||||
return true;
|
||||
}
|
||||
)
|
||||
->build();
|
||||
|
||||
$this->assertEquals(false, $checkerData->isOwn());
|
||||
$this->assertEquals(true, $checkerData->inTeam());
|
||||
$this->assertFalse($checkerData->isOwn());
|
||||
$this->assertTrue($checkerData->inTeam());
|
||||
$this->assertTrue($checkerData->isShared());
|
||||
}
|
||||
|
||||
public function testCheckerData3()
|
||||
@@ -91,9 +99,15 @@ class ScopeCheckerDataTest extends \PHPUnit\Framework\TestCase
|
||||
return false;
|
||||
}
|
||||
)
|
||||
->setIsSharedChecker(
|
||||
function (): bool {
|
||||
return false;
|
||||
}
|
||||
)
|
||||
->build();
|
||||
|
||||
$this->assertEquals(true, $checkerData->isOwn());
|
||||
$this->assertEquals(false, $checkerData->inTeam());
|
||||
$this->assertTrue($checkerData->isOwn());
|
||||
$this->assertFalse($checkerData->inTeam());
|
||||
$this->assertFalse($checkerData->isShared());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user