mirror of
https://github.com/espocrm/espocrm.git
synced 2026-03-05 05:47:01 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d678307d2d | ||
|
|
74b1c2ba98 | ||
|
|
a8398965b8 | ||
|
|
1c550da572 | ||
|
|
b48b033be8 | ||
|
|
c392fabc14 | ||
|
|
1de9046b3d | ||
|
|
b007bef366 | ||
|
|
33c8e202bc | ||
|
|
bbf77b16bd | ||
|
|
4da48a15a9 | ||
|
|
db504a3ee7 | ||
|
|
5e2155d7e2 | ||
|
|
ee34bf8cba | ||
|
|
563843cac8 | ||
|
|
9463c9ea55 | ||
|
|
39b461a618 | ||
|
|
65d2c1de07 | ||
|
|
adccaa7951 | ||
|
|
f9d0f3bcaf | ||
|
|
3321bf1020 | ||
|
|
d3da6fc8cc | ||
|
|
6c0e3c67a8 | ||
|
|
2cd9b532f0 | ||
|
|
ce1e664d63 | ||
|
|
2f97cf36ea | ||
|
|
d7764af73d | ||
|
|
dc738fdbfe | ||
|
|
44c20522a0 | ||
|
|
3161d0c436 | ||
|
|
6930658c27 | ||
|
|
61e427d621 | ||
|
|
ecad172891 | ||
|
|
a694db623c | ||
|
|
ed7677a4d7 |
@@ -242,6 +242,11 @@ class Binding implements BindingProcessor
|
||||
'Espo\\Core\\Utils\\Config\\ApplicationConfig',
|
||||
'applicationConfig'
|
||||
);
|
||||
|
||||
$binder->bindService(
|
||||
'Espo\\Core\\Session\\Session',
|
||||
'session'
|
||||
);
|
||||
}
|
||||
|
||||
private function bindCore(Binder $binder): void
|
||||
|
||||
@@ -70,13 +70,6 @@ class Settings
|
||||
|
||||
private function getConfigData(): stdClass
|
||||
{
|
||||
$data = $this->service->getConfigData();
|
||||
$metadataData = $this->service->getMetadataConfigData();
|
||||
|
||||
foreach (get_object_vars($metadataData) as $key => $value) {
|
||||
$data->$key = $value;
|
||||
}
|
||||
|
||||
return $data;
|
||||
return $this->service->getConfigData();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +276,21 @@ class Acl
|
||||
return $this->aclManager->checkField($this->user, $scope, $field, $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check access to a link.
|
||||
*
|
||||
* @param string $scope A scope (entity type).
|
||||
* @param string $link A link to check.
|
||||
* @param Table::ACTION_READ|Table::ACTION_EDIT $action An action.
|
||||
* @noinspection PhpDocSignatureInspection
|
||||
*
|
||||
* @since 9.4.0
|
||||
*/
|
||||
public function checkLink(string $scope, string $link, string $action = Table::ACTION_READ): bool
|
||||
{
|
||||
return $this->aclManager->checkLink($this->user, $scope, $link, $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get links forbidden for a user.
|
||||
*
|
||||
|
||||
@@ -209,7 +209,11 @@ class Helper
|
||||
$userIds = $entity->getLinkMultipleIdList($field);
|
||||
|
||||
if ($userIds === []) {
|
||||
if ($assignmentPermission === Table::LEVEL_NO && !$user->isApi()) {
|
||||
if (
|
||||
$assignmentPermission === Table::LEVEL_NO &&
|
||||
!$user->isApi() &&
|
||||
$field !== Field::COLLABORATORS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -187,6 +187,13 @@ class GlobalRestriction
|
||||
$value = $this->metadata->get(['entityDefs', $scope, 'links', $link, $type]);
|
||||
}
|
||||
|
||||
if (
|
||||
$type === self::TYPE_FORBIDDEN &&
|
||||
$this->metadata->get("entityDefs.$scope.links.$link.disabled")
|
||||
) {
|
||||
$value = true;
|
||||
}
|
||||
|
||||
if (!$value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -579,6 +579,21 @@ class AclManager
|
||||
return !in_array($field, $this->getScopeForbiddenFieldList($user, $scope, $action));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check access to a link.
|
||||
*
|
||||
* @param string $scope A scope (entity type).
|
||||
* @param string $link A link to check.
|
||||
* @param Table::ACTION_READ|Table::ACTION_EDIT $action An action.
|
||||
* @noinspection PhpDocSignatureInspection
|
||||
*
|
||||
* @since 9.4.0
|
||||
*/
|
||||
public function checkLink(User $user, string $scope, string $link, string $action = Table::ACTION_READ): bool
|
||||
{
|
||||
return !in_array($link, $this->getScopeForbiddenLinkList($user, $scope, $action));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a user has access to another user over a specific permission.
|
||||
*
|
||||
|
||||
@@ -222,6 +222,11 @@ class ConfigDataProvider
|
||||
return $this->object->get('oidcAuthorizationPrompt') ?? 'consent';
|
||||
}
|
||||
|
||||
public function useAuthorizationPkce(): bool
|
||||
{
|
||||
return (bool) $this->object->get('oidcAuthorizationPkce');
|
||||
}
|
||||
|
||||
public function getAuthorizationMaxAge(): ?int
|
||||
{
|
||||
return $this->config->get('oidcAuthorizationMaxAge');
|
||||
|
||||
@@ -41,6 +41,7 @@ use Espo\Core\Authentication\Jwt\Validator;
|
||||
use Espo\Core\Authentication\Oidc\UserProvider\UserInfo;
|
||||
use Espo\Core\Authentication\Result;
|
||||
use Espo\Core\Authentication\Result\FailReason;
|
||||
use Espo\Core\Session\Session;
|
||||
use Espo\Core\Utils\Json;
|
||||
use Espo\Core\Utils\Log;
|
||||
use JsonException;
|
||||
@@ -57,6 +58,8 @@ class Login implements LoginInterface
|
||||
private const REQUEST_TIMEOUT = 10;
|
||||
private const NONCE_HEADER = 'X-Oidc-Authorization-Nonce';
|
||||
|
||||
public const string SESSION_KEY_CODE_VERIFIER = 'oidcCodeVerifier';
|
||||
|
||||
public function __construct(
|
||||
private Espo $espoLogin,
|
||||
private Log $log,
|
||||
@@ -66,6 +69,7 @@ class Login implements LoginInterface
|
||||
private UserProvider $userProvider,
|
||||
private ApplicationState $applicationState,
|
||||
private UserInfoDataProvider $userInfoDataProvider,
|
||||
private Session $session,
|
||||
) {}
|
||||
|
||||
public function login(Data $data, Request $request): Result
|
||||
@@ -214,6 +218,7 @@ class Login implements LoginInterface
|
||||
string $redirectUri,
|
||||
string $clientSecret
|
||||
): array {
|
||||
|
||||
$params = [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $clientId,
|
||||
@@ -222,6 +227,12 @@ class Login implements LoginInterface
|
||||
'redirect_uri' => $redirectUri,
|
||||
];
|
||||
|
||||
if ($this->configDataProvider->useAuthorizationPkce()) {
|
||||
$codeVerifier = $this->session->get(self::SESSION_KEY_CODE_VERIFIER);
|
||||
|
||||
$params['code_verifier'] = $codeVerifier;
|
||||
}
|
||||
|
||||
$curl = curl_init();
|
||||
|
||||
curl_setopt_array($curl, [
|
||||
|
||||
54
application/Espo/Core/Authentication/Oidc/PkceUtil.php
Normal file
54
application/Espo/Core/Authentication/Oidc/PkceUtil.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* 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\Authentication\Oidc;
|
||||
|
||||
class PkceUtil
|
||||
{
|
||||
private const string CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
private const int CODE_LENGTH = 64;
|
||||
|
||||
public static function generateCodeVerifier(): string
|
||||
{
|
||||
$output = '';
|
||||
|
||||
for ($i = 0; $i < self::CODE_LENGTH; $i++) {
|
||||
$output .= self::CHARACTERS[random_int(0, strlen(self::CHARACTERS) - 1)];
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
public static function hashAndEncodeCodeVerifier(string $codeVerifier): string
|
||||
{
|
||||
$code = hash('sha256', $codeVerifier, true);
|
||||
|
||||
return rtrim(strtr(base64_encode($code), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
@@ -420,8 +420,8 @@ class DefaultImporter implements Importer
|
||||
$subject = '(No Subject)';
|
||||
}
|
||||
|
||||
if (strlen($subject) > self::SUBJECT_MAX_LENGTH) {
|
||||
$subject = substr($subject, 0, self::SUBJECT_MAX_LENGTH);
|
||||
if (mb_strlen($subject) > self::SUBJECT_MAX_LENGTH) {
|
||||
$subject = mb_substr($subject, 0, self::SUBJECT_MAX_LENGTH);
|
||||
}
|
||||
|
||||
return $subject;
|
||||
|
||||
@@ -291,6 +291,10 @@ class LinkCheck
|
||||
{
|
||||
$entityType = $entity->getEntityType();
|
||||
|
||||
if (!$this->acl->checkLink($entityType, $link)) {
|
||||
throw new ForbiddenSilent("Link $link is forbidden.");
|
||||
}
|
||||
|
||||
/** @var AclTable::ACTION_*|null $action */
|
||||
$action = $this->getParam($entityType, $link, 'linkRequiredAccess');
|
||||
|
||||
|
||||
@@ -40,8 +40,57 @@ use Espo\ORM\Query\Part\Order;
|
||||
*/
|
||||
class CategoryTree extends Database
|
||||
{
|
||||
private const ATTR_ORDER = 'order';
|
||||
private const ATTR_PARENT_ID = 'parentId';
|
||||
private const string ATTR_ORDER = 'order';
|
||||
private const string ATTR_PARENT_ID = 'parentId';
|
||||
|
||||
private const string PATH_ATTR_ASCENDOR_ID = 'ascendorId';
|
||||
private const string PATH_ATTR_DESCENDOR_ID = 'descendorId';
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function save(Entity $entity, array $options = []): void
|
||||
{
|
||||
$this->entityManager
|
||||
->getTransactionManager()
|
||||
->run(function () use ($entity, $options) {
|
||||
if (!$entity->isNew() && $entity->isAttributeChanged(self::ATTR_PARENT_ID)) {
|
||||
$this->lockPathEntries($entity);
|
||||
}
|
||||
|
||||
parent::save($entity, $options);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function remove(Entity $entity, array $options = []): void
|
||||
{
|
||||
$this->entityManager
|
||||
->getTransactionManager()
|
||||
->run(function () use ($entity, $options) {
|
||||
$this->lockPathEntries($entity);
|
||||
|
||||
parent::remove($entity, $options);
|
||||
});
|
||||
}
|
||||
|
||||
private function lockPathEntries(Entity $entity): void
|
||||
{
|
||||
$this->entityManager
|
||||
->getRDBRepository($this->getPathEntityType())
|
||||
->sth()
|
||||
->select(Attribute::ID)
|
||||
->where([
|
||||
'OR' => [
|
||||
self::PATH_ATTR_ASCENDOR_ID => $entity->getId(),
|
||||
self::PATH_ATTR_DESCENDOR_ID => $entity->getId(),
|
||||
]
|
||||
])
|
||||
->forUpdate()
|
||||
->find();
|
||||
}
|
||||
|
||||
protected function beforeSave(Entity $entity, array $options = [])
|
||||
{
|
||||
@@ -64,7 +113,7 @@ class CategoryTree extends Database
|
||||
|
||||
$em = $this->entityManager;
|
||||
|
||||
$pathEntityType = $entity->getEntityType() . 'Path';
|
||||
$pathEntityType = $this->getPathEntityType();
|
||||
|
||||
if ($entity->isNew()) {
|
||||
if ($parentId) {
|
||||
@@ -173,7 +222,7 @@ class CategoryTree extends Database
|
||||
{
|
||||
parent::afterRemove($entity, $options);
|
||||
|
||||
$pathEntityType = $entity->getEntityType() . 'Path';
|
||||
$pathEntityType = $this->getPathEntityType();
|
||||
|
||||
$em = $this->entityManager;
|
||||
|
||||
@@ -215,4 +264,9 @@ class CategoryTree extends Database
|
||||
|
||||
$entity->set(self::ATTR_ORDER, $order);
|
||||
}
|
||||
|
||||
private function getPathEntityType(): string
|
||||
{
|
||||
return $this->entityType . 'Path';
|
||||
}
|
||||
}
|
||||
|
||||
84
application/Espo/Core/Session/DefaultSession.php
Normal file
84
application/Espo/Core/Session/DefaultSession.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* 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\Session;
|
||||
|
||||
use const PHP_SESSION_NONE;
|
||||
|
||||
/**
|
||||
* Do not use directly. Require the Session interface instead.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class DefaultSession implements Session
|
||||
{
|
||||
public function __construct(
|
||||
?string $cacheLimiter = null,
|
||||
?int $cacheExpire = null,
|
||||
) {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
if ($cacheLimiter !== null) {
|
||||
session_cache_limiter($cacheLimiter);
|
||||
}
|
||||
|
||||
if ($cacheExpire !== null) {
|
||||
session_cache_expire($cacheExpire);
|
||||
}
|
||||
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
public function get(string $key): mixed
|
||||
{
|
||||
return $_SESSION[$key] ?? null;
|
||||
}
|
||||
|
||||
public function set(string $key, mixed $value): Session
|
||||
{
|
||||
$_SESSION[$key] = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function clear(string $key): void
|
||||
{
|
||||
unset($_SESSION[$key]);
|
||||
}
|
||||
|
||||
public function clearAll(): void
|
||||
{
|
||||
session_unset();
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return array_key_exists($key, $_SESSION);
|
||||
}
|
||||
}
|
||||
48
application/Espo/Core/Session/Session.php
Normal file
48
application/Espo/Core/Session/Session.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* 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\Session;
|
||||
|
||||
/**
|
||||
* A session wrapper.
|
||||
*
|
||||
* @since 9.4.0
|
||||
*/
|
||||
interface Session
|
||||
{
|
||||
public function get(string $key): mixed;
|
||||
|
||||
public function set(string $key, mixed $value): self;
|
||||
|
||||
public function clear(string $key): void;
|
||||
|
||||
public function clearAll(): void;
|
||||
|
||||
public function has(string $key): bool;
|
||||
}
|
||||
@@ -32,4 +32,6 @@ namespace Espo\Core\Templates\Entities;
|
||||
use Espo\Core\Entities\CategoryTreeItem;
|
||||
|
||||
class CategoryTree extends CategoryTreeItem
|
||||
{}
|
||||
{
|
||||
public const string TEMPLATE_TYPE = 'CategoryTree';
|
||||
}
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
"link": "#{entityType}/list",
|
||||
"acl": "read",
|
||||
"style": "default"
|
||||
},
|
||||
{
|
||||
"labelTranslation": "Global.scopeNamesPlural.{subjectEntityType}",
|
||||
"link": "#{subjectEntityType}",
|
||||
"acl": "read",
|
||||
"style": "default",
|
||||
"aclScope": "{subjectEntityType}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -19,6 +26,13 @@
|
||||
"link": "#{entityType}",
|
||||
"acl": "read",
|
||||
"style": "default"
|
||||
},
|
||||
{
|
||||
"labelTranslation": "Global.scopeNamesPlural.{subjectEntityType}",
|
||||
"link": "#{subjectEntityType}",
|
||||
"acl": "read",
|
||||
"style": "default",
|
||||
"aclScope": "{subjectEntityType}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -29,4 +43,4 @@
|
||||
"unlinkDisabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
{
|
||||
"entity": true,
|
||||
"layouts": true,
|
||||
"tab": true,
|
||||
"tab": false,
|
||||
"acl": true,
|
||||
"aclPortal": true,
|
||||
"customizable": true,
|
||||
"aclLevelList": [
|
||||
"all",
|
||||
"team",
|
||||
"no"
|
||||
],
|
||||
"aclPortalLevelList": [
|
||||
"all",
|
||||
"no"
|
||||
],
|
||||
"importable": false,
|
||||
"notifications": false
|
||||
}
|
||||
"notifications": false,
|
||||
"customizable": false,
|
||||
"entityManager": {
|
||||
"fields": false,
|
||||
"formula": false,
|
||||
"relationships": false,
|
||||
"addField": false,
|
||||
"edit": false,
|
||||
"layouts": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"labels": {
|
||||
"Create {entityType}": "Create {entityTypeTranslated}"
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ use Espo\Core\Utils\DataUtil;
|
||||
use Espo\Core\Utils\Metadata\AdditionalBuilder;
|
||||
use Espo\Core\Utils\Metadata\BuilderHelper;
|
||||
use Espo\Core\Utils\Util;
|
||||
use RuntimeException;
|
||||
use stdClass;
|
||||
|
||||
class Fields implements AdditionalBuilder
|
||||
@@ -75,6 +76,10 @@ class Fields implements AdditionalBuilder
|
||||
}
|
||||
|
||||
foreach (get_object_vars($entityDefsItem->fields) as $field => $fieldDefsItem) {
|
||||
if (!is_object($fieldDefsItem)) {
|
||||
throw new RuntimeException("Bad definition for $entityType.$field field.");
|
||||
}
|
||||
|
||||
$additionalFields = $this->builderHelper->getAdditionalFields(
|
||||
field: $field,
|
||||
params: Util::objectToArray($fieldDefsItem),
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
{
|
||||
"name": "teams"
|
||||
},
|
||||
{
|
||||
"name": "collaborators"
|
||||
},
|
||||
{
|
||||
"name": "isInternal"
|
||||
}
|
||||
|
||||
@@ -122,6 +122,21 @@
|
||||
"type": "linkMultiple",
|
||||
"view": "views/fields/teams"
|
||||
},
|
||||
"collaborators": {
|
||||
"type": "linkMultiple",
|
||||
"view": "views/fields/collaborators",
|
||||
"maxCount": 30,
|
||||
"fieldManagerParamList": [
|
||||
"readOnly",
|
||||
"readOnlyAfterCreate",
|
||||
"audited",
|
||||
"autocompleteOnEmpty",
|
||||
"maxCount",
|
||||
"inlineEditDisabled",
|
||||
"tooltipText"
|
||||
],
|
||||
"dynamicLogicVisibleDisabled": true
|
||||
},
|
||||
"attachments": {
|
||||
"type": "attachmentMultiple"
|
||||
}
|
||||
@@ -145,6 +160,12 @@
|
||||
"relationName": "entityTeam",
|
||||
"layoutRelationshipsDisabled": true
|
||||
},
|
||||
"collaborators": {
|
||||
"type": "hasMany",
|
||||
"entity": "User",
|
||||
"relationName": "entityCollaborator",
|
||||
"layoutRelationshipsDisabled": true
|
||||
},
|
||||
"inboundEmail": {
|
||||
"type": "belongsTo",
|
||||
"entity": "InboundEmail"
|
||||
|
||||
@@ -155,6 +155,21 @@
|
||||
"type": "linkMultiple",
|
||||
"view": "views/fields/teams"
|
||||
},
|
||||
"collaborators": {
|
||||
"type": "linkMultiple",
|
||||
"view": "views/fields/collaborators",
|
||||
"maxCount": 30,
|
||||
"fieldManagerParamList": [
|
||||
"readOnly",
|
||||
"readOnlyAfterCreate",
|
||||
"audited",
|
||||
"autocompleteOnEmpty",
|
||||
"maxCount",
|
||||
"inlineEditDisabled",
|
||||
"tooltipText"
|
||||
],
|
||||
"dynamicLogicVisibleDisabled": true
|
||||
},
|
||||
"attachments": {
|
||||
"type": "attachmentMultiple",
|
||||
"sourceList": ["Document"]
|
||||
@@ -180,6 +195,12 @@
|
||||
"relationName": "entityTeam",
|
||||
"layoutRelationshipsDisabled": true
|
||||
},
|
||||
"collaborators": {
|
||||
"type": "hasMany",
|
||||
"entity": "User",
|
||||
"relationName": "entityCollaborator",
|
||||
"layoutRelationshipsDisabled": true
|
||||
},
|
||||
"parent": {
|
||||
"type": "belongsToParent",
|
||||
"foreign": "tasks"
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
"layouts": true,
|
||||
"tab": true,
|
||||
"acl": true,
|
||||
"aclPortal": "recordAllAccountContactOwnNo",
|
||||
"aclPortal": true,
|
||||
"aclPortalLevelList": [
|
||||
"all",
|
||||
"account",
|
||||
"contact",
|
||||
"own",
|
||||
"no"
|
||||
],
|
||||
"module": "Crm",
|
||||
"customizable": true,
|
||||
"stream": true,
|
||||
"importable": true,
|
||||
"notifications": true,
|
||||
"object": true,
|
||||
"statusField": "status"
|
||||
"statusField": "status",
|
||||
"collaborators": true
|
||||
}
|
||||
|
||||
@@ -19,5 +19,6 @@
|
||||
"statusField": "status",
|
||||
"stream": true,
|
||||
"kanbanStatusIgnoreList": ["Canceled", "Deferred"],
|
||||
"statusFieldLocked": true
|
||||
"statusFieldLocked": true,
|
||||
"collaborators": true
|
||||
}
|
||||
|
||||
@@ -107,4 +107,16 @@ class RelationParam
|
||||
* @since 9.2.5
|
||||
*/
|
||||
public const ORDER = 'order';
|
||||
|
||||
/**
|
||||
* @since 9.4.0
|
||||
*/
|
||||
public const READ_ONLY = 'readOnly';
|
||||
|
||||
/**
|
||||
* Disabled.
|
||||
*
|
||||
* @since 9.4.0
|
||||
*/
|
||||
public const DISABLED = 'disabled';
|
||||
}
|
||||
|
||||
@@ -298,6 +298,7 @@ return [
|
||||
'oidcFallback' => true,
|
||||
'oidcScopes' => ['profile', 'email', 'phone'],
|
||||
'oidcAuthorizationPrompt' => 'consent',
|
||||
'oidcAuthorizationPkce' => true,
|
||||
'listViewSettingsDisabled' => false,
|
||||
'cleanupDeletedRecords' => true,
|
||||
'phoneNumberNumericSearch' => true,
|
||||
|
||||
@@ -242,7 +242,8 @@
|
||||
"aclScope": "ACL Scope",
|
||||
"onlyAdmin": "Only for Admin",
|
||||
"notStorable": "Not Storable",
|
||||
"itemsEditable": "Items Editable"
|
||||
"itemsEditable": "Items Editable",
|
||||
"openInNewTab": "Open in new tab"
|
||||
},
|
||||
"strings" : {
|
||||
"rebuildRequired": "Rebuild is required"
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
"assignedUsers": "Multiple Assigned Users",
|
||||
"collaborators": "Collaborators",
|
||||
"aclContactLink": "ACL Contact Link",
|
||||
"aclAccountLink": "ACL Account Link"
|
||||
"aclAccountLink": "ACL Account Link",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"options": {
|
||||
"type": {
|
||||
@@ -98,6 +99,7 @@
|
||||
"beforeSaveApiScript": "A script called on create and update API requests before an entity is saved. Use for custom validation and duplicate checking."
|
||||
},
|
||||
"tooltips": {
|
||||
"categories": "Enable the category tree feature. Records can be placed into categories.",
|
||||
"aclContactLink": "The link with Contact to use when applying access control for portal users.",
|
||||
"aclAccountLink": "The link with Account to use when applying access control for portal users.",
|
||||
"collaborators": "The ability to share records with specific users.",
|
||||
@@ -118,5 +120,11 @@
|
||||
"countDisabled": "Total number won't be displayed on the list view. Can decrease loading time when the DB table is big.",
|
||||
"fullTextSearch": "Running rebuild is required.",
|
||||
"linkParamReadOnly": "A read-only link cannot be edited via the *link* and *unlink* API requests. It won't be possible to relate and unrelate records via the relationship panel. It still possible to edit read-only links via link and link-multiple fields."
|
||||
},
|
||||
"entityNameParts": {
|
||||
"Category": "Category"
|
||||
},
|
||||
"entityNamePartsPlural": {
|
||||
"Category": "Categories"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,7 +521,8 @@
|
||||
"names": "Names",
|
||||
"types": "Types",
|
||||
"targetListIsOptedOut": "Is Opted Out (Target List)",
|
||||
"childList": "Child List"
|
||||
"childList": "Child List",
|
||||
"category": "Category"
|
||||
},
|
||||
"links": {
|
||||
"assignedUser": "Assigned User",
|
||||
|
||||
@@ -166,6 +166,7 @@
|
||||
"oidcAllowAdminUser": "OIDC Allow OIDC login for admin users",
|
||||
"oidcLogoutUrl": "OIDC Logout URL",
|
||||
"oidcAuthorizationPrompt": "OIDC Authorization Prompt",
|
||||
"oidcAuthorizationPkce": "OIDC Use PKCE",
|
||||
"pdfEngine": "PDF Engine",
|
||||
"quickSearchFullTextAppendWildcard": "Append wildcard in quick search",
|
||||
"authIpAddressCheck": "Restrict access by IP address",
|
||||
|
||||
@@ -130,6 +130,9 @@
|
||||
"oidcLogoutUrl": {
|
||||
"level": "admin"
|
||||
},
|
||||
"oidcAuthorizationPkce": {
|
||||
"level": "admin"
|
||||
},
|
||||
"apiCorsAllowedMethodList": {
|
||||
"level": "admin"
|
||||
},
|
||||
|
||||
@@ -91,5 +91,8 @@
|
||||
},
|
||||
"applicationConfig": {
|
||||
"className": "Espo\\Core\\Utils\\Config\\ApplicationConfig"
|
||||
},
|
||||
"session": {
|
||||
"className": "Espo\\Core\\Session\\DefaultSession"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
"deleteHookClassNameList": [
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\PlusDeleteHook",
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\EventDeleteHook",
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\DeleteHasChildrenLinks"
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\DeleteHasChildrenLinks",
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\CategoriesDeleteHook"
|
||||
],
|
||||
"updateHookClassNameList": [
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\AssignedUsersUpdateHook",
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\CollaboratorsUpdateHook",
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\StreamUpdateHook"
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\StreamUpdateHook",
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\CategoriesUpdateHook"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -167,6 +167,13 @@
|
||||
"tooltip": true,
|
||||
"view": "views/admin/entity-manager/fields/acl-account-link"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@BasePlus": {
|
||||
@@ -216,6 +223,13 @@
|
||||
"tooltip": true,
|
||||
"view": "views/admin/entity-manager/fields/acl-account-link"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
[
|
||||
"Base",
|
||||
"BasePlus",
|
||||
"Event",
|
||||
"Person",
|
||||
"Company"
|
||||
]
|
||||
"Base",
|
||||
"BasePlus",
|
||||
"Event",
|
||||
"Person",
|
||||
"Company",
|
||||
"CategoryTree"
|
||||
]
|
||||
|
||||
@@ -18,5 +18,10 @@
|
||||
"Person": {
|
||||
"entityClassName": "Espo\\Core\\Templates\\Entities\\Person",
|
||||
"repositoryClassName": "Espo\\Core\\Templates\\Repositories\\Person"
|
||||
},
|
||||
"CategoryTree": {
|
||||
"entityClassName": "Espo\\Core\\Templates\\Entities\\CategoryTree",
|
||||
"repositoryClassName": "Espo\\Core\\Templates\\Repositories\\CategoryTree",
|
||||
"isNotCreatable": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"DejaVu Sans",
|
||||
"DejaVu Serif",
|
||||
"DejaVu Sans Mono"
|
||||
]
|
||||
],
|
||||
"additionalParams": {
|
||||
"fonts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,9 @@
|
||||
{
|
||||
"name": "oidcAuthorizationPrompt"
|
||||
},
|
||||
false
|
||||
{
|
||||
"name": "oidcAuthorizationPkce"
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
|
||||
@@ -84,6 +84,10 @@
|
||||
"select_account"
|
||||
],
|
||||
"maxLength": 14
|
||||
},
|
||||
"oidcAuthorizationPkce": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -912,6 +912,10 @@
|
||||
"select_account"
|
||||
]
|
||||
},
|
||||
"oidcAuthorizationPkce": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
},
|
||||
"pdfEngine": {
|
||||
"type": "enum",
|
||||
"view": "views/settings/fields/pdf-engine"
|
||||
|
||||
@@ -56,6 +56,15 @@ use stdClass;
|
||||
|
||||
class SettingsService
|
||||
{
|
||||
/**
|
||||
* @var string[]
|
||||
* @todo Do not use when these parameters moved away from the settings.
|
||||
*/
|
||||
private array $ignoreUpdateParamList = [
|
||||
'loginView',
|
||||
'loginData',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private ApplicationState $applicationState,
|
||||
private Config $config,
|
||||
@@ -87,11 +96,22 @@ class SettingsService
|
||||
$this->filterData($data);
|
||||
$this->loadAdditionalParams($data);
|
||||
|
||||
/** @noinspection PhpDeprecationInspection */
|
||||
$metadataData = $this->getMetadataConfigData();
|
||||
|
||||
foreach (get_object_vars($metadataData) as $key => $value) {
|
||||
$data->$key = $value;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata to be used in config.
|
||||
*
|
||||
* @todo Make private in v9.4.0.
|
||||
* @todo Move away from settings. Use some different approach.
|
||||
* @deprecated Since v9.3.2.
|
||||
*/
|
||||
public function getMetadataConfigData(): stdClass
|
||||
{
|
||||
@@ -208,6 +228,7 @@ class SettingsService
|
||||
}
|
||||
|
||||
$ignoreItemList = array_merge(
|
||||
$this->ignoreUpdateParamList,
|
||||
$this->access->getSystemParamList(),
|
||||
$this->access->getReadOnlyParamList(),
|
||||
$this->isRestrictedMode() && !$user->isSuperAdmin() ?
|
||||
|
||||
@@ -29,26 +29,16 @@
|
||||
|
||||
namespace Espo\Tools\EntityManager;
|
||||
|
||||
class CreateParams
|
||||
readonly class CreateParams
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $replaceData
|
||||
*/
|
||||
public function __construct(
|
||||
private bool $forceCreate = false,
|
||||
private array $replaceData = []
|
||||
public bool $forceCreate = false,
|
||||
public array $replaceData = [],
|
||||
public bool $skipCustomPrefix = false,
|
||||
public bool $isNotRemovable = false,
|
||||
public bool $addTab = true,
|
||||
) {}
|
||||
|
||||
public function forceCreate(): bool
|
||||
{
|
||||
return $this->forceCreate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getReplaceData(): array
|
||||
{
|
||||
return $this->replaceData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,11 +103,13 @@ class EntityManager
|
||||
/** @var array<string, mixed> $templateDefs */
|
||||
$templateDefs = $this->metadata->get(['app', 'entityTemplates', $type], []);
|
||||
|
||||
if (!empty($templateDefs['isNotCreatable']) && !$createParams->forceCreate()) {
|
||||
if (!empty($templateDefs['isNotCreatable']) && !$createParams->forceCreate) {
|
||||
throw new Error("Type '$type' is not creatable.");
|
||||
}
|
||||
|
||||
$name = $this->nameUtil->addCustomPrefix($name, true);
|
||||
if (!$createParams->skipCustomPrefix) {
|
||||
$name = $this->nameUtil->addCustomPrefix($name, true);
|
||||
}
|
||||
|
||||
if ($this->nameUtil->nameIsBad($name)) {
|
||||
throw new Error("Entity name should contain only letters and numbers, " .
|
||||
@@ -206,7 +208,7 @@ class EntityManager
|
||||
}
|
||||
|
||||
$languageContents = $this->fileManager->getContents($filePath);
|
||||
$languageContents = $this->replace($languageContents, $name, $createParams->getReplaceData());
|
||||
$languageContents = $this->replace($languageContents, $name, $createParams->replaceData);
|
||||
$languageContents = str_replace('{entityTypeTranslated}', $labelSingular, $languageContents);
|
||||
|
||||
$destinationFilePath = 'custom/Espo/Custom/Resources/i18n/' . $language . '/' . $name . '.json';
|
||||
@@ -217,7 +219,7 @@ class EntityManager
|
||||
$filePath = $templatePath . "/Metadata/$type/scopes.json";
|
||||
|
||||
$scopesDataContents = $this->fileManager->getContents($filePath);
|
||||
$scopesDataContents = $this->replace($scopesDataContents, $name, $createParams->getReplaceData());
|
||||
$scopesDataContents = $this->replace($scopesDataContents, $name, $createParams->replaceData);
|
||||
|
||||
$scopesData = Json::decode($scopesDataContents, true);
|
||||
|
||||
@@ -228,7 +230,7 @@ class EntityManager
|
||||
$scopesData['object'] = true;
|
||||
$scopesData['isCustom'] = true;
|
||||
|
||||
if (!empty($templateDefs['isNotRemovable']) || !empty($params['isNotRemovable'])) {
|
||||
if ($createParams->isNotRemovable) {
|
||||
$scopesData['isNotRemovable'] = true;
|
||||
}
|
||||
|
||||
@@ -241,7 +243,7 @@ class EntityManager
|
||||
$filePath = $templatePath . "/Metadata/$type/entityDefs.json";
|
||||
|
||||
$entityDefsDataContents = $this->fileManager->getContents($filePath);
|
||||
$entityDefsDataContents = $this->replace($entityDefsDataContents, $name, $createParams->getReplaceData());
|
||||
$entityDefsDataContents = $this->replace($entityDefsDataContents, $name, $createParams->replaceData);
|
||||
|
||||
$entityDefsData = Json::decode($entityDefsDataContents, true);
|
||||
|
||||
@@ -250,7 +252,7 @@ class EntityManager
|
||||
$filePath = $templatePath . "/Metadata/$type/clientDefs.json";
|
||||
|
||||
$clientDefsContents = $this->fileManager->getContents($filePath);
|
||||
$clientDefsContents = $this->replace($clientDefsContents, $name, $createParams->getReplaceData());
|
||||
$clientDefsContents = $this->replace($clientDefsContents, $name, $createParams->replaceData);
|
||||
|
||||
$clientDefsData = Json::decode($clientDefsContents, true);
|
||||
|
||||
@@ -287,13 +289,15 @@ class EntityManager
|
||||
|
||||
$this->processCreateHook($entityTypeParams);
|
||||
|
||||
$tabList = $this->config->get('tabList', []);
|
||||
if ($createParams->addTab) {
|
||||
$tabList = $this->config->get('tabList', []);
|
||||
|
||||
if (!in_array($name, $tabList)) {
|
||||
$tabList[] = $name;
|
||||
if (!in_array($name, $tabList)) {
|
||||
$tabList[] = $name;
|
||||
|
||||
$this->configWriter->set('tabList', $tabList);
|
||||
$this->configWriter->save();
|
||||
$this->configWriter->set('tabList', $tabList);
|
||||
$this->configWriter->save();
|
||||
}
|
||||
}
|
||||
|
||||
$this->dataManager->rebuild();
|
||||
@@ -522,7 +526,7 @@ class EntityManager
|
||||
throw new Forbidden;
|
||||
}
|
||||
|
||||
if (!$this->isScopeCustomizable($name)) {
|
||||
if (!$this->isScopeCustomizable($name) && !$deleteParams->forceRemove()) {
|
||||
throw new Error("Entity type $name is not customizable.");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* 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\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Templates\Entities\Base;
|
||||
use Espo\Core\Templates\Entities\BasePlus;
|
||||
use Espo\Tools\EntityManager\Hook\DeleteHook;
|
||||
use Espo\Tools\EntityManager\Params;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class CategoriesDeleteHook implements DeleteHook
|
||||
{
|
||||
private const string PARAM = 'categories';
|
||||
|
||||
public function __construct(
|
||||
private CategoriesUpdateHook $categoriesUpdateHook,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
*/
|
||||
public function process(Params $params): void
|
||||
{
|
||||
if (!in_array($params->getType(), [BasePlus::TEMPLATE_TYPE, Base::TEMPLATE_TYPE])) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$params->get(self::PARAM)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->categoriesUpdateHook->remove($params->getName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* 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\DataManager;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Conflict;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Templates\Entities\Base;
|
||||
use Espo\Core\Templates\Entities\BasePlus;
|
||||
use Espo\Core\Templates\Entities\CategoryTree;
|
||||
use Espo\Core\Utils\Language;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\ORM\Defs\Params\FieldParam;
|
||||
use Espo\ORM\Defs\Params\RelationParam;
|
||||
use Espo\ORM\Type\RelationType;
|
||||
use Espo\Tools\EntityManager\CreateParams;
|
||||
use Espo\Tools\EntityManager\DeleteParams;
|
||||
use Espo\Tools\EntityManager\EntityManager;
|
||||
use Espo\Tools\EntityManager\Hook\UpdateHook;
|
||||
use Espo\Tools\EntityManager\Params;
|
||||
use Espo\Tools\LayoutManager\LayoutCustomizer;
|
||||
use Espo\Tools\LayoutManager\LayoutName;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class CategoriesUpdateHook implements UpdateHook
|
||||
{
|
||||
private const string PARAM = 'categories';
|
||||
private const string FIELD = 'category';
|
||||
|
||||
public function __construct(
|
||||
private InjectableFactory $injectableFactory,
|
||||
private Language $defaultLanguage,
|
||||
private Metadata $metadata,
|
||||
private DataManager $dataManager,
|
||||
private LayoutCustomizer $layoutCustomizer,
|
||||
private Log $log,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
* @throws Conflict
|
||||
*/
|
||||
public function process(Params $params, Params $previousParams): void
|
||||
{
|
||||
if (!in_array($params->getType(), [BasePlus::TEMPLATE_TYPE, Base::TEMPLATE_TYPE])) {
|
||||
return;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws Conflict
|
||||
* @throws Error
|
||||
*/
|
||||
private function add(string $name): void
|
||||
{
|
||||
$entityType = $this->composeEntityType($name);
|
||||
|
||||
if ($this->metadata->get("scopes.$entityType")) {
|
||||
$message = "Could not create category entity type $entityType as the same entity type exists";
|
||||
|
||||
$this->log->warning($message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$createParams = new CreateParams(
|
||||
forceCreate: true,
|
||||
replaceData: [
|
||||
'subjectEntityType' => $name,
|
||||
],
|
||||
skipCustomPrefix: true,
|
||||
isNotRemovable: true,
|
||||
addTab: false,
|
||||
);
|
||||
|
||||
$this->getEntityManagerTool()->create(
|
||||
name: $entityType,
|
||||
type: CategoryTree::TEMPLATE_TYPE,
|
||||
params: [
|
||||
'labelSingular' => $this->defaultLanguage->translateLabel($name, 'scopeNames') . ' ' .
|
||||
$this->defaultLanguage->translateLabel('Category', 'entityNameParts', 'EntityManager'),
|
||||
'labelPlural' => $this->defaultLanguage->translateLabel($name, 'scopeNames') . ' ' .
|
||||
$this->defaultLanguage->translateLabel('Category', 'entityNamePartsPlural', 'EntityManager')
|
||||
],
|
||||
createParams: $createParams,
|
||||
);
|
||||
|
||||
$this->metadata->set('entityDefs', $name, [
|
||||
'fields' => [
|
||||
self::FIELD => [
|
||||
FieldParam::TYPE => FieldType::LINK,
|
||||
'audited' => true,
|
||||
'view' => 'views/fields/link-category-tree'
|
||||
]
|
||||
],
|
||||
'links' => [
|
||||
self::FIELD => [
|
||||
RelationParam::TYPE => RelationType::BELONGS_TO,
|
||||
RelationParam::ENTITY => $entityType,
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
$this->metadata->set('clientDefs', $name, [
|
||||
'views' => [
|
||||
'list' => 'views/list-with-categories',
|
||||
],
|
||||
'modalViews' => [
|
||||
'select' => 'views/modals/select-records-with-categories',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->metadata->save();
|
||||
$this->dataManager->rebuild();
|
||||
|
||||
$this->layoutCustomizer->addDetailField($name, self::FIELD, LayoutName::DETAIL);
|
||||
$this->layoutCustomizer->addDetailField($name, self::FIELD, LayoutName::DETAIL_SMALL);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
*/
|
||||
public function remove(string $name): void
|
||||
{
|
||||
$entityType = $this->composeEntityType($name);
|
||||
|
||||
$deleteParams = new DeleteParams(
|
||||
forceRemove: true,
|
||||
);
|
||||
|
||||
$this->getEntityManagerTool()->delete($entityType, $deleteParams);
|
||||
|
||||
$this->metadata->delete('entityDefs', $name, [
|
||||
'fields.' . self::FIELD,
|
||||
'links.' . self::FIELD,
|
||||
]);
|
||||
|
||||
$this->metadata->delete('clientDefs', $name, [
|
||||
'views.list',
|
||||
'modalViews.select',
|
||||
]);
|
||||
|
||||
$this->metadata->save();
|
||||
$this->dataManager->rebuild();
|
||||
|
||||
$this->layoutCustomizer->removeInDetail($name, self::FIELD, LayoutName::DETAIL);
|
||||
$this->layoutCustomizer->removeInDetail($name, self::FIELD, LayoutName::DETAIL_SMALL);
|
||||
}
|
||||
|
||||
private function getEntityManagerTool(): EntityManager
|
||||
{
|
||||
return $this->injectableFactory->create(EntityManager::class);
|
||||
}
|
||||
|
||||
private function composeEntityType(string $name): string
|
||||
{
|
||||
return $name . 'Category';
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,9 @@ use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\Crm\Entities\CaseObj;
|
||||
use Espo\Modules\Crm\Entities\Task;
|
||||
use Espo\ORM\Defs\Params\FieldParam;
|
||||
use Espo\ORM\Defs\Params\RelationParam;
|
||||
use Espo\ORM\Type\RelationType;
|
||||
use Espo\Tools\EntityManager\Hook\UpdateHook;
|
||||
@@ -52,6 +55,14 @@ class CollaboratorsUpdateHook implements UpdateHook
|
||||
|
||||
private const DEFAULT_MAX_COUNT = 30;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
private array $enabledByDefaultEntityTypeList = [
|
||||
CaseObj::ENTITY_TYPE,
|
||||
Task::ENTITY_TYPE,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private Metadata $metadata,
|
||||
private Log $log,
|
||||
@@ -81,43 +92,13 @@ class CollaboratorsUpdateHook implements UpdateHook
|
||||
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,
|
||||
RelationParam::RELATION_NAME => self::RELATION_NAME,
|
||||
'layoutRelationshipsDisabled' => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->metadata->set('entityAcl', $entityType, [
|
||||
'links' => [
|
||||
self::FIELD => [
|
||||
'readOnly' => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
if ($this->isEnabledByDefault($entityType)) {
|
||||
$this->addEnabledByDefault($entityType);
|
||||
} else {
|
||||
$this->addInternal($entityType);
|
||||
}
|
||||
|
||||
$this->metadata->save();
|
||||
|
||||
$this->dataManager->rebuild([$entityType]);
|
||||
}
|
||||
|
||||
@@ -142,5 +123,79 @@ class CollaboratorsUpdateHook implements UpdateHook
|
||||
]);
|
||||
|
||||
$this->metadata->save();
|
||||
|
||||
// Must be after metadata is saved.
|
||||
if ($this->isEnabledByDefault($entityType)) {
|
||||
$this->metadata->set('entityDefs', $entityType, [
|
||||
'fields' => [
|
||||
self::FIELD => [
|
||||
FieldParam::DISABLED => true,
|
||||
],
|
||||
],
|
||||
'links' => [
|
||||
self::FIELD => [
|
||||
RelationParam::DISABLED => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->metadata->save();
|
||||
}
|
||||
}
|
||||
|
||||
private function addInternal(string $entityType): void
|
||||
{
|
||||
$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,
|
||||
RelationParam::RELATION_NAME => self::RELATION_NAME,
|
||||
'layoutRelationshipsDisabled' => true,
|
||||
RelationParam::READ_ONLY => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->metadata->set('entityAcl', $entityType, [
|
||||
'links' => [
|
||||
self::FIELD => [
|
||||
'readOnly' => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function addEnabledByDefault(string $entityType): void
|
||||
{
|
||||
$this->metadata->delete('entityDefs', $entityType, [
|
||||
'fields.' . self::FIELD,
|
||||
'links.' . self::FIELD,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $entityType
|
||||
* @return bool
|
||||
*/
|
||||
private function isEnabledByDefault(string $entityType): bool
|
||||
{
|
||||
return in_array($entityType, $this->enabledByDefaultEntityTypeList);
|
||||
}
|
||||
}
|
||||
|
||||
189
application/Espo/Tools/LayoutManager/LayoutCustomizer.php
Normal file
189
application/Espo/Tools/LayoutManager/LayoutCustomizer.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* 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\LayoutManager;
|
||||
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Utils\Json;
|
||||
use Espo\Tools\Layout\LayoutProvider;
|
||||
use RuntimeException;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* @since 9.4.0
|
||||
*/
|
||||
class LayoutCustomizer
|
||||
{
|
||||
public function __construct(
|
||||
private LayoutProvider $layoutProvider,
|
||||
private LayoutManager $layoutManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
public function addDetailField(string $entityType, string $field, string $layoutName): void
|
||||
{
|
||||
$layoutData = $this->getDetailLayout($entityType, $layoutName);
|
||||
|
||||
if ($this->hasInDetail($layoutData, $field)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lastPanel = $layoutData[count($layoutData) - 1];
|
||||
|
||||
if (!$lastPanel instanceof stdClass) {
|
||||
throw new RuntimeException("Bad layout panel definition in $entityType.$layoutName.");
|
||||
}
|
||||
|
||||
if (isset($lastPanel->cols) && is_array($lastPanel->cols)) {
|
||||
$cols = $lastPanel->cols;
|
||||
|
||||
$cols[] = [[(object) ['name' => $field]]];
|
||||
|
||||
$lastPanel->cols = $cols;
|
||||
} else {
|
||||
$rows = $lastPanel->rows ?? [];
|
||||
|
||||
$rows[] = [(object) ['name' => $field], false];
|
||||
|
||||
$lastPanel->rows = $rows;
|
||||
}
|
||||
|
||||
$this->layoutManager->set($layoutData, $entityType, $layoutName);
|
||||
$this->layoutManager->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
public function removeInDetail(string $entityType, string $field, string $layoutName): void
|
||||
{
|
||||
$panels = $this->getDetailLayout($entityType, $layoutName);
|
||||
|
||||
$cell = $this->getCellFromDetail($panels, $field);
|
||||
|
||||
if (!$cell) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($panels as $panelItem) {
|
||||
if (isset($panelItem->cols)) {
|
||||
$rowItems =& $panelItem->cols;
|
||||
} else if (isset($panelItem->rows)) {
|
||||
$rowItems =& $panelItem->rows;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_array($rowItems)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rowItems as &$rowItem) {
|
||||
if (!is_array($rowItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rowItem as $i => $cellItem) {
|
||||
if (!$cellItem instanceof stdClass) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($cellItem->name ?? null) === $field) {
|
||||
$rowItem[$i] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->layoutManager->set($panels, $entityType, $layoutName);
|
||||
$this->layoutManager->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $panels
|
||||
*/
|
||||
private function hasInDetail(array $panels, string $field): bool
|
||||
{
|
||||
return $this->getCellFromDetail($panels, $field) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $panels
|
||||
*/
|
||||
private function getCellFromDetail(array $panels, string $field): ?stdClass
|
||||
{
|
||||
foreach ($panels as $panelItem) {
|
||||
$rowItems = $panelItem->cols ?? $panelItem->rows ?? null;
|
||||
|
||||
if (!is_array($rowItems)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rowItems as $rowItem) {
|
||||
if (!is_array($rowItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rowItem as $cellItem) {
|
||||
if (!$cellItem instanceof stdClass) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($cellItem->name ?? null) === $field) {
|
||||
return $cellItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function getDetailLayout(string $entityType, string $layoutName): array
|
||||
{
|
||||
$layoutString = $this->layoutProvider->get($entityType, $layoutName);
|
||||
|
||||
if (!$layoutString) {
|
||||
$layoutString = '[]';
|
||||
}
|
||||
|
||||
$layoutData = Json::decode($layoutString);
|
||||
|
||||
if (!is_array($layoutData)) {
|
||||
throw new RuntimeException("Bad layout $entityType.$layoutName.");
|
||||
}
|
||||
|
||||
return $layoutData;
|
||||
}
|
||||
}
|
||||
39
application/Espo/Tools/LayoutManager/LayoutName.php
Normal file
39
application/Espo/Tools/LayoutManager/LayoutName.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* 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\LayoutManager;
|
||||
|
||||
/**
|
||||
* @since 9.4.0
|
||||
*/
|
||||
class LayoutName
|
||||
{
|
||||
const string DETAIL = 'detail';
|
||||
const string DETAIL_SMALL = 'detailSmall';
|
||||
}
|
||||
@@ -33,9 +33,11 @@ use Espo\Core\Authentication\Jwt\Exceptions\Invalid;
|
||||
use Espo\Core\Authentication\Oidc\ConfigDataProvider;
|
||||
use Espo\Core\Authentication\Oidc\Login as OidcLogin;
|
||||
use Espo\Core\Authentication\Oidc\BackchannelLogout;
|
||||
use Espo\Core\Authentication\Oidc\PkceUtil;
|
||||
use Espo\Core\Authentication\Util\MethodProvider;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Session\Session;
|
||||
use Espo\Core\Utils\Json;
|
||||
|
||||
class Service
|
||||
@@ -43,7 +45,8 @@ class Service
|
||||
public function __construct(
|
||||
private BackchannelLogout $backchannelLogout,
|
||||
private MethodProvider $methodProvider,
|
||||
private ConfigDataProvider $configDataProvider
|
||||
private ConfigDataProvider $configDataProvider,
|
||||
private Session $session,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -55,6 +58,8 @@ class Service
|
||||
* claims: ?string,
|
||||
* prompt: 'none'|'login'|'consent'|'select_account',
|
||||
* maxAge: ?int,
|
||||
* codeChallenge: ?string,
|
||||
* codeChallengeMethod: ?string,
|
||||
* }
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
@@ -70,6 +75,7 @@ class Service
|
||||
$scopes = $this->configDataProvider->getScopes();
|
||||
$groupClaim = $this->configDataProvider->getGroupClaim();
|
||||
$redirectUri = $this->configDataProvider->getRedirectUri();
|
||||
$codeChallenge = $this->configDataProvider->useAuthorizationPkce() ? $this->prepareCodeChallenge() : null;
|
||||
|
||||
if (!$clientId) {
|
||||
throw new Error("No client ID.");
|
||||
@@ -105,6 +111,8 @@ class Service
|
||||
'claims' => $claims,
|
||||
'prompt' => $prompt,
|
||||
'maxAge' => $maxAge,
|
||||
'codeChallenge' => $codeChallenge,
|
||||
'codeChallengeMethod' => $codeChallenge ? 'S256' : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -123,4 +131,13 @@ class Service
|
||||
throw new Forbidden("OIDC logout: Invalid JWT. " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function prepareCodeChallenge(): string
|
||||
{
|
||||
$codeVerifier = PkceUtil::generateCodeVerifier();
|
||||
|
||||
$this->session->set(OidcLogin::SESSION_KEY_CODE_VERIFIER, $codeVerifier);
|
||||
|
||||
return PkceUtil::hashAndEncodeCodeVerifier($codeVerifier);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,10 @@ use Dompdf\Options;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\File\Manager as FileManager;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Core\Utils\Module;
|
||||
use Espo\Tools\Pdf\Params;
|
||||
use Espo\Tools\Pdf\Template;
|
||||
use RuntimeException;
|
||||
|
||||
class DompdfInitializer
|
||||
{
|
||||
@@ -62,6 +64,7 @@ class DompdfInitializer
|
||||
private Config $config,
|
||||
private Metadata $metadata,
|
||||
private FileManager $fileManager,
|
||||
private Module $module,
|
||||
) {}
|
||||
|
||||
public function initialize(Template $template, Params $params): Dompdf
|
||||
@@ -82,6 +85,8 @@ class DompdfInitializer
|
||||
$this->fileManager->mkdir($dir);
|
||||
}
|
||||
|
||||
$this->setupFontOptions($options);
|
||||
|
||||
$pdf = new Dompdf($options);
|
||||
|
||||
$this->mapFonts($pdf, $params->isPdfA(), $dir);
|
||||
@@ -127,6 +132,8 @@ class DompdfInitializer
|
||||
return;
|
||||
}
|
||||
|
||||
$this->setupAdditionalFonts($pdf);
|
||||
|
||||
/** @var string[] $fontList */
|
||||
$fontList = $this->metadata->get('app.pdfEngines.Dompdf.fontFaceList') ?? [];
|
||||
$fontList = array_map(fn ($it) => strtolower($it), $fontList);
|
||||
@@ -139,4 +146,58 @@ class DompdfInitializer
|
||||
$fontMetrics->setFontFamily($key, $fontMetrics->getFamily($value));
|
||||
}
|
||||
}
|
||||
|
||||
private function setupFontOptions(Options $options): void
|
||||
{
|
||||
$dirs = ['application/Espo/Resources/fonts'];
|
||||
|
||||
foreach ($this->module->getOrderedList() as $module) {
|
||||
$dirs[] = $this->module->getModulePath($module) . '/Resources/fonts';
|
||||
}
|
||||
|
||||
$dirs[] = 'custom/Espo/Custom/Resources/fonts';
|
||||
|
||||
$dirs = array_filter($dirs, fn ($dir) => $this->fileManager->isDir($dir));
|
||||
$dirs = array_values($dirs);
|
||||
|
||||
$options->setChroot($dirs);
|
||||
}
|
||||
|
||||
private function setupAdditionalFonts(Dompdf $pdf): void
|
||||
{
|
||||
/** @var array{family?: string, style?: string, weight?: string, source?: string}[] $fonts */
|
||||
$fonts = $this->metadata->get("app.pdfEngines.Dompdf.additionalParams.fonts") ?? [];
|
||||
|
||||
foreach ($fonts as $defs) {
|
||||
$family = $defs['family'] ?? throw new RuntimeException("No font 'family'.");
|
||||
$style = $defs['style'] ?? throw new RuntimeException("No font 'style'.");
|
||||
$weight = $defs['weight'] ?? throw new RuntimeException("No font 'weight'.");
|
||||
$source = $defs['source'] ?? throw new RuntimeException("No font 'source'.");
|
||||
|
||||
$this->registerFont(
|
||||
pdf: $pdf,
|
||||
family: $family,
|
||||
style: $style,
|
||||
weight: $weight,
|
||||
source: $source,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function registerFont(
|
||||
Dompdf $pdf,
|
||||
string $family,
|
||||
string $style,
|
||||
string $weight,
|
||||
string $source,
|
||||
): void {
|
||||
|
||||
$fontMetrics = $pdf->getFontMetrics();
|
||||
|
||||
$fontMetrics->registerFont([
|
||||
'family' => $family,
|
||||
'style' => $style,
|
||||
'weight' => $weight,
|
||||
], $source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
{{#if link}}href="{{link}}"{{else}}role="button"{{/if}}
|
||||
class="{{aClassName}}"
|
||||
{{#if color}}style="border-color: {{color}}"{{/if}}
|
||||
{{#if openInNewTab}} target="_blank" {{/if}}
|
||||
{{#if isGroup}}
|
||||
id="nav-tab-group-{{name}}"
|
||||
data-toggle="dropdown"
|
||||
@@ -69,6 +70,7 @@
|
||||
id="nav-tab-group-{{name}}"
|
||||
data-toggle="dropdown"
|
||||
{{/if}}
|
||||
{{#if openInNewTab}} target="_blank" {{/if}}
|
||||
>
|
||||
<span class="short-label"{{#if color}} style="color: {{color}}"{{/if}}>
|
||||
{{#if iconClass}}
|
||||
@@ -116,6 +118,7 @@
|
||||
id="nav-tab-group-{{name}}"
|
||||
data-toggle="dropdown"
|
||||
{{/if}}
|
||||
{{#if openInNewTab}} target="_blank" {{/if}}
|
||||
>
|
||||
<span class="short-label"{{#if color}} style="color: {{color}}"{{/if}}>
|
||||
{{#if iconClass}}
|
||||
@@ -155,6 +158,7 @@
|
||||
id="nav-tab-group-{{name}}"
|
||||
data-toggle="dropdown"
|
||||
{{/if}}
|
||||
{{#if openInNewTab}} target="_blank" {{/if}}
|
||||
>
|
||||
<span class="short-label"{{#if color}} style="color: {{color}}"{{/if}}>
|
||||
{{#if iconClass}}
|
||||
|
||||
@@ -82,9 +82,11 @@ class OidcLoginHandler extends LoginHandler {
|
||||
* clientId: string,
|
||||
* redirectUri: string,
|
||||
* scopes: string[],
|
||||
* claims: ?string,
|
||||
* claims: string|null,
|
||||
* prompt: 'login'|'consent'|'select_account',
|
||||
* maxAge: ?Number,
|
||||
* maxAge: Number|null,
|
||||
* codeChallenge: string|null,
|
||||
* codeChallengeMethod: string|null
|
||||
* }} data
|
||||
* @param {WindowProxy} proxy
|
||||
* @return {Promise<{code: string, nonce: string}>}
|
||||
@@ -103,6 +105,11 @@ class OidcLoginHandler extends LoginHandler {
|
||||
prompt: data.prompt,
|
||||
};
|
||||
|
||||
if (data.codeChallenge && data.codeChallengeMethod) {
|
||||
params.code_challenge = data.codeChallenge;
|
||||
params.code_challenge_method = data.codeChallengeMethod;
|
||||
}
|
||||
|
||||
if (data.maxAge || data.maxAge === 0) {
|
||||
params.max_age = data.maxAge;
|
||||
}
|
||||
@@ -154,8 +161,7 @@ class OidcLoginHandler extends LoginHandler {
|
||||
|
||||
try {
|
||||
url = proxy.location.href;
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -181,7 +187,13 @@ class OidcLoginHandler extends LoginHandler {
|
||||
|
||||
if (parsedData.error) {
|
||||
fail();
|
||||
Espo.Ui.error(parsedData.errorDescription || this.loginView.translate('Error'), true);
|
||||
|
||||
const message = parsedData.errorDescription || this.loginView.translate('Error') + '\n' +
|
||||
parsedData.error;
|
||||
|
||||
Espo.Ui.error(message, true);
|
||||
|
||||
console.log(parsedData);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
import {inject} from 'di';
|
||||
import Storage from 'storage';
|
||||
import Utils from 'utils';
|
||||
|
||||
class ListSettingsHelper {
|
||||
|
||||
/**
|
||||
@@ -35,17 +39,30 @@ class ListSettingsHelper {
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} entityType
|
||||
* @param {string} type
|
||||
* @param {string} userId
|
||||
* @param {module:storage} storage
|
||||
* @private
|
||||
* @type {Storage}
|
||||
*/
|
||||
constructor(entityType, type, userId, storage) {
|
||||
/** @private */
|
||||
this.storage = storage;
|
||||
@inject(Storage)
|
||||
storage
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {boolean}
|
||||
*/
|
||||
useStorage
|
||||
|
||||
/**
|
||||
* Note: Do not change the signature for the first 3 parameters.
|
||||
* @internal
|
||||
*
|
||||
* @param {string} entityType
|
||||
* @param {string} key A key used for storage.
|
||||
* @param {string} userId
|
||||
* @param {{useStorage?: boolean}} options
|
||||
*/
|
||||
constructor(entityType, key, userId, options = {}) {
|
||||
/** @private */
|
||||
this.layoutColumnsKey = `${type}-${entityType}-${userId}`;
|
||||
this.layoutColumnsKey = `${key}-${entityType}-${userId}`;
|
||||
|
||||
/**
|
||||
* @private
|
||||
@@ -70,6 +87,42 @@ class ListSettingsHelper {
|
||||
* @type {function()[]}
|
||||
*/
|
||||
this.columnWidthChangeFunctions = [];
|
||||
|
||||
this.useStorage = options.useStorage ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} key
|
||||
* @return {*}
|
||||
*/
|
||||
getStored(key) {
|
||||
if (this.useStorage) {
|
||||
return this.storage.get(key, this.layoutColumnsKey);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} key
|
||||
* @param {*} value
|
||||
*/
|
||||
store(key, value) {
|
||||
if (this.useStorage) {
|
||||
this.storage.set(key, this.layoutColumnsKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} key
|
||||
*/
|
||||
clearStored(key) {
|
||||
if (this.useStorage) {
|
||||
this.storage.clear(key, this.layoutColumnsKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +135,8 @@ class ListSettingsHelper {
|
||||
return this.hiddenColumnMapCache;
|
||||
}
|
||||
|
||||
this.hiddenColumnMapCache = this.storage.get('listHiddenColumns', this.layoutColumnsKey) || {};
|
||||
this.hiddenColumnMapCache = /** @type {Object} */
|
||||
this.getStored('listHiddenColumns') ?? {};
|
||||
|
||||
return this.hiddenColumnMapCache;
|
||||
}
|
||||
@@ -121,7 +175,7 @@ class ListSettingsHelper {
|
||||
*/
|
||||
getColumnResize() {
|
||||
if (this.columnResize === undefined) {
|
||||
this.columnResize = this.storage.get('listColumnResize', this.layoutColumnsKey) || false;
|
||||
this.columnResize = this.getStored('listColumnResize') ?? false;
|
||||
}
|
||||
|
||||
return this.columnResize;
|
||||
@@ -135,7 +189,7 @@ class ListSettingsHelper {
|
||||
storeColumnResize(columnResize) {
|
||||
this.columnResize = columnResize;
|
||||
|
||||
this.storage.set('listColumnResize', this.layoutColumnsKey, columnResize);
|
||||
this.store('listColumnResize', columnResize);
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
@@ -145,7 +199,7 @@ class ListSettingsHelper {
|
||||
clearColumnResize() {
|
||||
this.columnResize = undefined;
|
||||
|
||||
this.storage.clear('listColumnResize', this.layoutColumnsKey);
|
||||
this.clearStored('listColumnResize');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,9 +208,9 @@ class ListSettingsHelper {
|
||||
* @param {Object.<string, boolean>} map
|
||||
*/
|
||||
storeHiddenColumnMap(map) {
|
||||
this.hiddenColumnMapCache = undefined;
|
||||
this.hiddenColumnMapCache = Utils.cloneDeep(map);
|
||||
|
||||
this.storage.set('listHiddenColumns', this.layoutColumnsKey, map);
|
||||
this.store('listHiddenColumns', map);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,7 +219,7 @@ class ListSettingsHelper {
|
||||
clearHiddenColumnMap() {
|
||||
this.hiddenColumnMapCache = undefined;
|
||||
|
||||
this.storage.clear('listHiddenColumns', this.layoutColumnsKey);
|
||||
this.clearStored('listHiddenColumns');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,7 +232,8 @@ class ListSettingsHelper {
|
||||
return this.columnWidthMapCache;
|
||||
}
|
||||
|
||||
this.columnWidthMapCache = this.storage.get('listColumnsWidths', this.layoutColumnsKey) || {};
|
||||
this.columnWidthMapCache = /** @type {Object} */
|
||||
this.getStored('listColumnsWidths') ?? {};
|
||||
|
||||
return this.columnWidthMapCache;
|
||||
}
|
||||
@@ -189,9 +244,9 @@ class ListSettingsHelper {
|
||||
* @param {Object.<string, ListSettingsHelper~columnWidth>} map
|
||||
*/
|
||||
storeColumnWidthMap(map) {
|
||||
this.columnWidthMapCache = undefined;
|
||||
this.columnWidthMapCache = Utils.cloneDeep(map);
|
||||
|
||||
this.storage.set('listColumnsWidths', this.layoutColumnsKey, map);
|
||||
this.store('listColumnsWidths', map);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,7 +255,7 @@ class ListSettingsHelper {
|
||||
clearColumnWidthMap() {
|
||||
this.columnWidthMapCache = undefined;
|
||||
|
||||
this.storage.clear('listColumnsWidths', this.layoutColumnsKey);
|
||||
this.clearStored('listColumnsWidths');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -381,12 +381,15 @@ class EntityManagerEditView extends View {
|
||||
setupDefs() {
|
||||
const scope = this.scope;
|
||||
|
||||
const typeList = (this.getMetadata().get('app.entityTemplateList') || ['Base'])
|
||||
.filter(it => !this.getMetadata().get(`app.entityTemplates.${it}.isNotCreatable`));
|
||||
|
||||
const defs = {
|
||||
fields: {
|
||||
type: {
|
||||
type: 'enum',
|
||||
required: true,
|
||||
options: this.getMetadata().get('app.entityTemplateList') || ['Base'],
|
||||
options: typeList,
|
||||
readOnly: scope !== false,
|
||||
tooltip: true,
|
||||
},
|
||||
|
||||
@@ -101,6 +101,7 @@ class ListRecordView extends View {
|
||||
* @property {boolean} [forceSettings] Force settings. As of v9.2.0.
|
||||
* @property {boolean} [forceAllResultSelectable] Force select all result. As of v9.2.0.
|
||||
* @property {module:search-manager~whereItem} [allResultWhereItem] Where item for select all result. As of v9.2.0.
|
||||
* @property {boolean} [storeSettings=true] To store settings. As of v9.4.0.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -658,7 +659,7 @@ class ListRecordView extends View {
|
||||
},
|
||||
/** @this ListRecordView */
|
||||
'click [data-action="showMore"]': async function () {
|
||||
await this.showMoreRecords();
|
||||
this.showMoreRecords();
|
||||
|
||||
this.focusOnList();
|
||||
},
|
||||
@@ -3754,11 +3755,13 @@ class ListRecordView extends View {
|
||||
return;
|
||||
}
|
||||
|
||||
this._listSettingsHelper = this.options.settingsHelper || new ListSettingsHelper(
|
||||
this._listSettingsHelper = this.options.settingsHelper ?? new ListSettingsHelper(
|
||||
this.entityType,
|
||||
this.layoutName,
|
||||
this.getUser().id,
|
||||
this.getStorage()
|
||||
{
|
||||
useStorage: this.options.storeSettings ?? true,
|
||||
}
|
||||
);
|
||||
|
||||
const view = new RecordListSettingsView({
|
||||
|
||||
@@ -45,11 +45,15 @@ export default class extends VarcharFieldView {
|
||||
{{/if}}
|
||||
`
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {import('collection').default|null}
|
||||
*/
|
||||
portalCollection = null
|
||||
|
||||
data() {
|
||||
const isNotEmpty = this.model.entityType !== 'AuthenticationProvider' ||
|
||||
this.portalCollection;
|
||||
(this.portalCollection && this.portalCollection.length);
|
||||
|
||||
return {
|
||||
value: this.getValueForDisplay(),
|
||||
|
||||
@@ -94,7 +94,10 @@ class SettingsEditTabUrlModalView extends Modal {
|
||||
name: 'onlyAdmin',
|
||||
labelText: this.translate('onlyAdmin', 'fields', 'Admin'),
|
||||
},
|
||||
false
|
||||
{
|
||||
name: 'openInNewTab',
|
||||
labelText: this.translate('openInNewTab', 'fields', 'Admin'),
|
||||
},
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -130,6 +133,9 @@ class SettingsEditTabUrlModalView extends Modal {
|
||||
onlyAdmin: {
|
||||
type: 'bool',
|
||||
},
|
||||
openInNewTab: {
|
||||
type: 'bool',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1260,7 +1260,8 @@ class NavbarSiteView extends View {
|
||||
* label: string,
|
||||
* isGroup: boolean,
|
||||
* aClassName: string,
|
||||
* iconClass: null
|
||||
* iconClass: null,
|
||||
* openInNewTab: boolean,
|
||||
* }}
|
||||
*/
|
||||
prepareTabItemDefs(params, tab, i, vars) {
|
||||
@@ -1273,6 +1274,7 @@ class NavbarSiteView extends View {
|
||||
let isUrl = false;
|
||||
let name = tab;
|
||||
let aClassName = 'nav-link';
|
||||
let openInNewTab = false;
|
||||
|
||||
const label = this.tabsHelper.getTranslatedTabLabel(tab);
|
||||
|
||||
@@ -1290,6 +1292,7 @@ class NavbarSiteView extends View {
|
||||
link = tab.url || '#';
|
||||
color = tab.color;
|
||||
iconClass = tab.iconClass;
|
||||
openInNewTab = tab.openInNewTab ?? false;
|
||||
|
||||
this.urlList.push({name: name, url: link});
|
||||
} else if (this.tabsHelper.isTabGroup(tab)) {
|
||||
@@ -1336,6 +1339,7 @@ class NavbarSiteView extends View {
|
||||
aClassName: aClassName,
|
||||
isGroup: isGroup,
|
||||
isDivider: isDivider,
|
||||
openInNewTab,
|
||||
};
|
||||
|
||||
if (isGroup) {
|
||||
|
||||
118
composer.lock
generated
118
composer.lock
generated
@@ -636,16 +636,16 @@
|
||||
},
|
||||
{
|
||||
"name": "directorytree/imapengine",
|
||||
"version": "v1.19.2",
|
||||
"version": "v1.22.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DirectoryTree/ImapEngine.git",
|
||||
"reference": "8a109e08de9090303e464b8b3059341b05075f11"
|
||||
"reference": "87fca56affd9527e6907a705e6d600c5174d9a5a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/DirectoryTree/ImapEngine/zipball/8a109e08de9090303e464b8b3059341b05075f11",
|
||||
"reference": "8a109e08de9090303e464b8b3059341b05075f11",
|
||||
"url": "https://api.github.com/repos/DirectoryTree/ImapEngine/zipball/87fca56affd9527e6907a705e6d600c5174d9a5a",
|
||||
"reference": "87fca56affd9527e6907a705e6d600c5174d9a5a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -657,7 +657,7 @@
|
||||
"zbateson/mail-mime-parser": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^2.0|^3.0",
|
||||
"pestphp/pest": "^2.0|^3.0|^4.0",
|
||||
"spatie/ray": "^1.0"
|
||||
},
|
||||
"type": "library",
|
||||
@@ -686,7 +686,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/DirectoryTree/ImapEngine/issues",
|
||||
"source": "https://github.com/DirectoryTree/ImapEngine/tree/v1.19.2"
|
||||
"source": "https://github.com/DirectoryTree/ImapEngine/tree/v1.22.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -694,7 +694,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-28T19:25:24+00:00"
|
||||
"time": "2026-02-03T15:43:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/dbal",
|
||||
@@ -812,29 +812,29 @@
|
||||
},
|
||||
{
|
||||
"name": "doctrine/deprecations",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/deprecations.git",
|
||||
"reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38"
|
||||
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
|
||||
"reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
|
||||
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
|
||||
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpunit/phpunit": "<=7.5 || >=13"
|
||||
"phpunit/phpunit": "<=7.5 || >=14"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^9 || ^12 || ^13",
|
||||
"phpstan/phpstan": "1.4.10 || 2.1.11",
|
||||
"doctrine/coding-standard": "^9 || ^12 || ^14",
|
||||
"phpstan/phpstan": "1.4.10 || 2.1.30",
|
||||
"phpstan/phpstan-phpunit": "^1.0 || ^2",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
|
||||
"psr/log": "^1 || ^2 || ^3"
|
||||
},
|
||||
"suggest": {
|
||||
@@ -854,9 +854,9 @@
|
||||
"homepage": "https://www.doctrine-project.org/",
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/deprecations/issues",
|
||||
"source": "https://github.com/doctrine/deprecations/tree/1.1.5"
|
||||
"source": "https://github.com/doctrine/deprecations/tree/1.1.6"
|
||||
},
|
||||
"time": "2025-04-07T20:06:18+00:00"
|
||||
"time": "2026-02-07T07:09:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/event-manager",
|
||||
@@ -1875,16 +1875,16 @@
|
||||
},
|
||||
{
|
||||
"name": "illuminate/collections",
|
||||
"version": "v12.44.0",
|
||||
"version": "v12.51.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/collections.git",
|
||||
"reference": "62b357e85711932da73f90a606d80ac09027d54c"
|
||||
"reference": "1fd7db2203ce5a935fffd2ad40955248fb9f581c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/collections/zipball/62b357e85711932da73f90a606d80ac09027d54c",
|
||||
"reference": "62b357e85711932da73f90a606d80ac09027d54c",
|
||||
"url": "https://api.github.com/repos/illuminate/collections/zipball/1fd7db2203ce5a935fffd2ad40955248fb9f581c",
|
||||
"reference": "1fd7db2203ce5a935fffd2ad40955248fb9f581c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1931,11 +1931,11 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2025-12-21T14:16:59+00:00"
|
||||
"time": "2026-02-09T13:43:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/conditionable",
|
||||
"version": "v12.44.0",
|
||||
"version": "v12.51.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/conditionable.git",
|
||||
@@ -1981,16 +1981,16 @@
|
||||
},
|
||||
{
|
||||
"name": "illuminate/contracts",
|
||||
"version": "v12.43.1",
|
||||
"version": "v12.51.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/contracts.git",
|
||||
"reference": "19e8938edb73047017cfbd443b96844b86da4a59"
|
||||
"reference": "3d4eeab332c04a9eaea90968c19a66f78745e47a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/contracts/zipball/19e8938edb73047017cfbd443b96844b86da4a59",
|
||||
"reference": "19e8938edb73047017cfbd443b96844b86da4a59",
|
||||
"url": "https://api.github.com/repos/illuminate/contracts/zipball/3d4eeab332c04a9eaea90968c19a66f78745e47a",
|
||||
"reference": "3d4eeab332c04a9eaea90968c19a66f78745e47a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2025,11 +2025,11 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2025-11-26T21:36:01+00:00"
|
||||
"time": "2026-01-28T15:26:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/macroable",
|
||||
"version": "v12.43.1",
|
||||
"version": "v12.51.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/macroable.git",
|
||||
@@ -2364,27 +2364,27 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/serializable-closure",
|
||||
"version": "v2.0.7",
|
||||
"version": "v2.0.9",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/serializable-closure.git",
|
||||
"reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd"
|
||||
"reference": "8f631589ab07b7b52fead814965f5a800459cb3e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/cb291e4c998ac50637c7eeb58189c14f5de5b9dd",
|
||||
"reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd",
|
||||
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/8f631589ab07b7b52fead814965f5a800459cb3e",
|
||||
"reference": "8f631589ab07b7b52fead814965f5a800459cb3e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
|
||||
"nesbot/carbon": "^2.67|^3.0",
|
||||
"pestphp/pest": "^2.36|^3.0|^4.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"symfony/var-dumper": "^6.2.0|^7.0.0"
|
||||
"symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@@ -2421,7 +2421,7 @@
|
||||
"issues": "https://github.com/laravel/serializable-closure/issues",
|
||||
"source": "https://github.com/laravel/serializable-closure"
|
||||
},
|
||||
"time": "2025-11-21T20:52:36+00:00"
|
||||
"time": "2026-02-03T06:55:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lasserafn/php-initial-avatar-generator",
|
||||
@@ -3447,16 +3447,16 @@
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.11.0",
|
||||
"version": "3.11.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/CarbonPHP/carbon.git",
|
||||
"reference": "bdb375400dcd162624531666db4799b36b64e4a1"
|
||||
"reference": "f438fcc98f92babee98381d399c65336f3a3827f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/bdb375400dcd162624531666db4799b36b64e4a1",
|
||||
"reference": "bdb375400dcd162624531666db4799b36b64e4a1",
|
||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f",
|
||||
"reference": "f438fcc98f92babee98381d399c65336f3a3827f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3480,7 +3480,7 @@
|
||||
"phpstan/extension-installer": "^1.4.3",
|
||||
"phpstan/phpstan": "^2.1.22",
|
||||
"phpunit/phpunit": "^10.5.53",
|
||||
"squizlabs/php_codesniffer": "^3.13.4"
|
||||
"squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0"
|
||||
},
|
||||
"bin": [
|
||||
"bin/carbon"
|
||||
@@ -3523,14 +3523,14 @@
|
||||
}
|
||||
],
|
||||
"description": "An API extension for DateTime that supports 281 different languages.",
|
||||
"homepage": "https://carbon.nesbot.com",
|
||||
"homepage": "https://carbonphp.github.io/carbon/",
|
||||
"keywords": [
|
||||
"date",
|
||||
"datetime",
|
||||
"time"
|
||||
],
|
||||
"support": {
|
||||
"docs": "https://carbon.nesbot.com/docs",
|
||||
"docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html",
|
||||
"issues": "https://github.com/CarbonPHP/carbon/issues",
|
||||
"source": "https://github.com/CarbonPHP/carbon"
|
||||
},
|
||||
@@ -3548,7 +3548,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-02T21:04:28+00:00"
|
||||
"time": "2026-01-29T09:26:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nikic/fast-route",
|
||||
@@ -6611,16 +6611,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/mime",
|
||||
"version": "v7.4.0",
|
||||
"version": "v7.4.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/mime.git",
|
||||
"reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a"
|
||||
"reference": "b18c7e6e9eee1e19958138df10412f3c4c316148"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a",
|
||||
"reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a",
|
||||
"url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148",
|
||||
"reference": "b18c7e6e9eee1e19958138df10412f3c4c316148",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6631,15 +6631,15 @@
|
||||
},
|
||||
"conflict": {
|
||||
"egulias/email-validator": "~3.0.0",
|
||||
"phpdocumentor/reflection-docblock": "<3.2.2",
|
||||
"phpdocumentor/type-resolver": "<1.4.0",
|
||||
"phpdocumentor/reflection-docblock": "<5.2|>=6",
|
||||
"phpdocumentor/type-resolver": "<1.5.1",
|
||||
"symfony/mailer": "<6.4",
|
||||
"symfony/serializer": "<6.4.3|>7.0,<7.0.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"egulias/email-validator": "^2.1.10|^3.1|^4",
|
||||
"league/html-to-markdown": "^5.0",
|
||||
"phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
|
||||
"phpdocumentor/reflection-docblock": "^5.2",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0",
|
||||
"symfony/property-access": "^6.4|^7.0|^8.0",
|
||||
@@ -6676,7 +6676,7 @@
|
||||
"mime-type"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/mime/tree/v7.4.0"
|
||||
"source": "https://github.com/symfony/mime/tree/v7.4.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -6696,7 +6696,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-16T10:14:42+00:00"
|
||||
"time": "2026-01-27T08:59:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-iconv",
|
||||
@@ -7602,16 +7602,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/translation",
|
||||
"version": "v7.4.3",
|
||||
"version": "v7.4.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/translation.git",
|
||||
"reference": "7ef27c65d78886f7599fdd5c93d12c9243ecf44d"
|
||||
"reference": "bfde13711f53f549e73b06d27b35a55207528877"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/translation/zipball/7ef27c65d78886f7599fdd5c93d12c9243ecf44d",
|
||||
"reference": "7ef27c65d78886f7599fdd5c93d12c9243ecf44d",
|
||||
"url": "https://api.github.com/repos/symfony/translation/zipball/bfde13711f53f549e73b06d27b35a55207528877",
|
||||
"reference": "bfde13711f53f549e73b06d27b35a55207528877",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -7678,7 +7678,7 @@
|
||||
"description": "Provides tools to internationalize your application",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/translation/tree/v7.4.3"
|
||||
"source": "https://github.com/symfony/translation/tree/v7.4.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7698,7 +7698,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-29T09:31:36+00:00"
|
||||
"time": "2026-01-13T10:40:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/translation-contracts",
|
||||
|
||||
741
package-lock.json
generated
741
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,7 @@
|
||||
"js-yaml": "^4.1.1",
|
||||
"pofile": "^1.1.3",
|
||||
"rollup": "^4.44.0",
|
||||
"tar": "^7.5.4"
|
||||
"tar": "^7.5.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/moment": "^6.1.8",
|
||||
@@ -102,5 +102,10 @@
|
||||
"engines": {
|
||||
"npm": ">=8",
|
||||
"node": ">=20"
|
||||
},
|
||||
"overrides": {
|
||||
"grunt": {
|
||||
"minimatch": "~3.1.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1494,6 +1494,10 @@
|
||||
"type": "boolean",
|
||||
"description": "The link will be hidden from the user on the UI but not disabled."
|
||||
},
|
||||
"disabled": {
|
||||
"type": "boolean",
|
||||
"description": "Disables the link."
|
||||
},
|
||||
"apiSpecDisabled": {
|
||||
"type": "boolean",
|
||||
"description": "Do not print the link in the API specification. As of v9.3."
|
||||
|
||||
@@ -156,4 +156,24 @@ class AclTest extends BaseTestCase
|
||||
$this->assertFalse($acl->checkField(Account::ENTITY_TYPE, 'assignedUser'));
|
||||
$this->assertTrue($acl->checkField(Account::ENTITY_TYPE, 'name'));
|
||||
}
|
||||
|
||||
public function testDisabledLink(): void
|
||||
{
|
||||
$metadata = $this->getMetadata();
|
||||
|
||||
$metadata->set('entityDefs', 'Account', [
|
||||
'links' => [
|
||||
'opportunities' => [
|
||||
'disabled' => true,
|
||||
]
|
||||
]
|
||||
]);
|
||||
$metadata->save();
|
||||
|
||||
$this->reCreateApplication();
|
||||
|
||||
$acl = $this->getContainer()->getByClass(Acl::class);
|
||||
|
||||
$this->assertFalse($acl->checkLink('Account', 'opportunities'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2414,6 +2414,15 @@ class MysqlQueryComposerTest extends TestCase
|
||||
$this->assertEquals(['test'], $list);
|
||||
}
|
||||
|
||||
public function testGetAllAttributesFromComplexExpression4()
|
||||
{
|
||||
$expression = "SUM:('тестван', test1, 'тест', test2, link.test3)";
|
||||
|
||||
$list = Util::getAllAttributesFromComplexExpression($expression);
|
||||
|
||||
$this->assertEquals(['test1', 'test2', 'link.test3'], $list);
|
||||
}
|
||||
|
||||
public function testComplexExpressionString1(): void
|
||||
{
|
||||
$queryBuilder = new QueryBuilder();
|
||||
|
||||
Reference in New Issue
Block a user