From ec2fc5916c19b3fcc19b039421047b0e7eec2962 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Sat, 23 Nov 2024 11:05:25 +0200 Subject: [PATCH] web-to-lead forms --- .../LeadCapture/ExampleLoader.php | 18 + .../RecordHooks/LeadCapture/BeforeCreate.php | 5 +- application/Espo/Controllers/LeadCapture.php | 18 + .../FieldValidationManager.php | 18 +- .../Espo/Core/Loaders/BaseLanguage.php | 16 +- .../Espo/Core/Utils/Client/ActionRenderer.php | 34 +- .../Utils/Client/ActionRenderer/Params.php | 48 ++ .../Espo/Core/Utils/Client/RenderParams.php | 41 ++ application/Espo/Core/Utils/Client/Script.php | 38 ++ .../Espo/Core/Utils/Client/SecurityParams.php | 40 ++ application/Espo/Core/Utils/ClientManager.php | 95 +++- .../Core/Utils/Language/LanguageFactory.php | 50 ++ application/Espo/Core/Utils/ThemeManager.php | 5 + application/Espo/Entities/LeadCapture.php | 69 +++ .../Espo/EntryPoints/LeadCaptureForm.php | 77 +++ .../Espo/Hooks/LeadCapture/ClearCache.php | 62 +++ .../Espo/Modules/Crm/Entities/Lead.php | 10 + .../Espo/Resources/i18n/en_US/Admin.json | 2 +- .../Resources/i18n/en_US/Integration.json | 9 +- .../Resources/i18n/en_US/LeadCapture.json | 17 +- .../Resources/layouts/LeadCapture/detail.json | 17 +- .../metadata/clientDefs/LeadCapture.json | 86 +++- .../metadata/entityDefs/LeadCapture.json | 116 ++++- .../Espo/Resources/metadata/fields/email.json | 5 + .../Espo/Resources/metadata/fields/phone.json | 5 + .../integrations/GoogleReCaptcha.json | 23 + application/Espo/Resources/routes.json | 6 + .../Espo/Tools/App/LanguageService.php | 36 +- application/Espo/Tools/Captcha/Checker.php | 147 ++++++ .../Espo/Tools/LeadCapture/Api/PostForm.php | 60 +++ .../Espo/Tools/LeadCapture/CaptureService.php | 142 ++++-- .../Espo/Tools/LeadCapture/FormResult.php | 37 ++ .../Espo/Tools/LeadCapture/FormService.php | 450 ++++++++++++++++++ .../Espo/Tools/LeadCapture/Service.php | 36 +- client/res/templates/fields/email/edit.tpl | 13 +- client/res/templates/fields/phone/edit.tpl | 16 +- client/src/controllers/lead-capture-form.js | 59 +++ client/src/language.js | 11 + client/src/metadata.js | 10 + client/src/views/fields/array.js | 5 + client/src/views/fields/email.js | 36 +- client/src/views/fields/phone.js | 25 +- .../views/lead-capture/fields/field-list.js | 174 ++++++- client/src/views/lead-capture/form.js | 196 ++++++++ .../src/views/lead-capture/record/detail.js | 18 + .../views/lead-capture/record/panels/form.js | 36 ++ client/src/views/record/detail.js | 5 +- frontend/less/espo/custom.less | 14 +- frontend/less/espo/layout-top.less | 10 +- 49 files changed, 2323 insertions(+), 143 deletions(-) create mode 100644 application/Espo/Core/Utils/Client/RenderParams.php create mode 100644 application/Espo/Core/Utils/Client/Script.php create mode 100644 application/Espo/Core/Utils/Client/SecurityParams.php create mode 100644 application/Espo/Core/Utils/Language/LanguageFactory.php create mode 100644 application/Espo/EntryPoints/LeadCaptureForm.php create mode 100644 application/Espo/Hooks/LeadCapture/ClearCache.php create mode 100644 application/Espo/Resources/metadata/integrations/GoogleReCaptcha.json create mode 100644 application/Espo/Tools/Captcha/Checker.php create mode 100644 application/Espo/Tools/LeadCapture/Api/PostForm.php create mode 100644 application/Espo/Tools/LeadCapture/FormResult.php create mode 100644 application/Espo/Tools/LeadCapture/FormService.php create mode 100644 client/src/controllers/lead-capture-form.js create mode 100644 client/src/views/lead-capture/form.js create mode 100644 client/src/views/lead-capture/record/panels/form.js diff --git a/application/Espo/Classes/FieldProcessing/LeadCapture/ExampleLoader.php b/application/Espo/Classes/FieldProcessing/LeadCapture/ExampleLoader.php index 62d8d77335..d8b88005de 100644 --- a/application/Espo/Classes/FieldProcessing/LeadCapture/ExampleLoader.php +++ b/application/Espo/Classes/FieldProcessing/LeadCapture/ExampleLoader.php @@ -60,6 +60,7 @@ class ExampleLoader implements Loader $this->processRequestUrl($entity); $this->processRequestPayload($entity); + $this->processFormUrl($entity); } private function processRequestUrl(LeadCapture $entity): void @@ -121,4 +122,21 @@ class ExampleLoader implements Loader $entity->set('exampleRequestPayload', $requestPayload); } + + private function processFormUrl(LeadCapture $entity): void + { + $formId = $entity->getFormId(); + $siteUrl = $this->config->getSiteUrl(); + + if (!$entity->hasFormEnabled() || !$formId) { + /** @noinspection PhpRedundantOptionalArgumentInspection */ + $entity->set('formUrl', null); + + return; + } + + $formUrl = "$siteUrl?entryPoint=leadCaptureForm&id=$formId"; + + $entity->set('formUrl', $formUrl); + } } diff --git a/application/Espo/Classes/RecordHooks/LeadCapture/BeforeCreate.php b/application/Espo/Classes/RecordHooks/LeadCapture/BeforeCreate.php index dd0ea5c3dd..c78548e7d0 100644 --- a/application/Espo/Classes/RecordHooks/LeadCapture/BeforeCreate.php +++ b/application/Espo/Classes/RecordHooks/LeadCapture/BeforeCreate.php @@ -46,8 +46,7 @@ class BeforeCreate implements SaveHook public function process(Entity $entity): void { - $apiKey = $this->service->generateApiKey(); - - $entity->setApiKey($apiKey); + $entity->setApiKey($this->service->generateApiKey()); + $entity->setFormId($this->service->generateFormId()); } } diff --git a/application/Espo/Controllers/LeadCapture.php b/application/Espo/Controllers/LeadCapture.php index 2671e69bac..600a2c63c2 100644 --- a/application/Espo/Controllers/LeadCapture.php +++ b/application/Espo/Controllers/LeadCapture.php @@ -110,6 +110,24 @@ class LeadCapture extends Record ->getValueMap(); } + /** + * @throws BadRequest + * @throws NotFound + * @throws Forbidden + */ + public function postActionGenerateNewFormId(Request $request): stdClass + { + $data = $request->getParsedBody(); + + if (empty($data->id)) { + throw new BadRequest(); + } + + return $this->getLeadCaptureService() + ->generateNewFormIdForEntity($data->id) + ->getValueMap(); + } + /** * @return stdClass[] * @throws Forbidden diff --git a/application/Espo/Core/FieldValidation/FieldValidationManager.php b/application/Espo/Core/FieldValidation/FieldValidationManager.php index f00ed3667c..5f718de9c1 100644 --- a/application/Espo/Core/FieldValidation/FieldValidationManager.php +++ b/application/Espo/Core/FieldValidation/FieldValidationManager.php @@ -166,9 +166,21 @@ class FieldValidationManager /** * Check a specific field for a specific validation type. + * + * @param Entity $entity An entity to check. + * @param string $field A field to check. + * @param string $type A validation type. + * @param ?stdClass $data A payload. + * @param mixed $value To override a validation value. */ - public function check(Entity $entity, string $field, string $type, ?stdClass $data = null): bool - { + public function check( + Entity $entity, + string $field, + string $type, + ?stdClass $data = null, + mixed $value = null + ): bool { + $data ??= (object) []; $entityType = $entity->getEntityType(); @@ -178,7 +190,7 @@ class FieldValidationManager return false; } - $validationValue = $this->fieldUtil->getEntityTypeFieldParam($entityType, $field, $type); + $validationValue = $value ?? $this->fieldUtil->getEntityTypeFieldParam($entityType, $field, $type); $isMandatory = in_array($type, $this->getMandatoryValidationList($entityType, $field)); $skip = !$isMandatory && (is_null($validationValue) || $validationValue === false); diff --git a/application/Espo/Core/Loaders/BaseLanguage.php b/application/Espo/Core/Loaders/BaseLanguage.php index e0662cceea..a464e1f963 100644 --- a/application/Espo/Core/Loaders/BaseLanguage.php +++ b/application/Espo/Core/Loaders/BaseLanguage.php @@ -30,21 +30,19 @@ namespace Espo\Core\Loaders; use Espo\Core\Container\Loader; -use Espo\Core\InjectableFactory; use Espo\Core\Utils\Config; -use Espo\Core\Utils\Language as LanguageService; +use Espo\Core\Utils\Language; class BaseLanguage implements Loader { - public function __construct(private InjectableFactory $injectableFactory, protected Config $config) - {} + public function __construct( + private Language\LanguageFactory $languageFactory, + protected Config $config + ) {} - public function load(): LanguageService + public function load(): Language { - return $this->injectableFactory->createWith(LanguageService::class, [ - 'language' => $this->getLanguage(), - 'useCache' => $this->config->get('useCache') ?? false, - ]); + return $this->languageFactory->create($this->getLanguage()); } protected function getLanguage(): string diff --git a/application/Espo/Core/Utils/Client/ActionRenderer.php b/application/Espo/Core/Utils/Client/ActionRenderer.php index b704eeda9d..8643c4236c 100644 --- a/application/Espo/Core/Utils/Client/ActionRenderer.php +++ b/application/Espo/Core/Utils/Client/ActionRenderer.php @@ -52,19 +52,30 @@ class ActionRenderer $params->getController(), $params->getAction(), $params->getData(), - $params->initAuth() + $params->initAuth(), + $params->getScripts(), ); - $this->clientManager->writeHeaders($response); + $securityParams = new SecurityParams( + frameAncestors: $params->getFrameAncestors(), + ); + + $this->clientManager->writeHeaders($response, $securityParams); $response->writeBody($body); } /** - * @deprecated Use`write`. * @param ?array $data + * @param Script[] $scripts */ - public function render(string $controller, string $action, ?array $data = null, bool $initAuth = false): string - { + private function render( + string $controller, + string $action, + ?array $data, + bool $initAuth, + array $scripts, + ): string { + $encodedData = Json::encode($data); $initAuthPart = $initAuth ? "app.initAuth();" : ''; @@ -73,12 +84,17 @@ class ActionRenderer " {$initAuthPart} app.doAction({ - controllerClassName: '{$controller}', - action: '{$action}', - options: {$encodedData}, + controllerClassName: '$controller', + action: '$action', + options: $encodedData, }); "; - return $this->clientManager->render($script); + $params = new RenderParams( + runScript: $script, + scripts: $scripts, + ); + + return $this->clientManager->render($params); } } diff --git a/application/Espo/Core/Utils/Client/ActionRenderer/Params.php b/application/Espo/Core/Utils/Client/ActionRenderer/Params.php index 0e07d79de8..3c22b098b9 100644 --- a/application/Espo/Core/Utils/Client/ActionRenderer/Params.php +++ b/application/Espo/Core/Utils/Client/ActionRenderer/Params.php @@ -29,6 +29,8 @@ namespace Espo\Core\Utils\Client\ActionRenderer; +use Espo\Core\Utils\Client\Script; + /** * @immutable */ @@ -37,6 +39,10 @@ class Params /** @var ?array */ private ?array $data; private bool $initAuth = false; + /** @var string[] */ + private array $frameAncestors = []; + /** @var Script[] */ + private array $scripts = []; /** * @param ?array $data @@ -76,6 +82,30 @@ class Params return $obj; } + /** + * @param string[] $frameAncestors + * @since 9.0.0 + */ + public function withFrameAncestors(array $frameAncestors): self + { + $obj = clone $this; + $obj->frameAncestors = $frameAncestors; + + return $obj; + } + + /** + * @param Script[] $scripts + * @since 9.0.0 + */ + public function withScripts(array $scripts): self + { + $obj = clone $this; + $obj->scripts = $scripts; + + return $obj; + } + public function getController(): string { return $this->controller; @@ -98,4 +128,22 @@ class Params { return $this->initAuth; } + + /** + * @return string[] + * @since 9.0.0 + */ + public function getFrameAncestors(): array + { + return $this->frameAncestors; + } + + /** + * @return Script[] + * @since 9.0.0 + */ + public function getScripts(): array + { + return $this->scripts; + } } diff --git a/application/Espo/Core/Utils/Client/RenderParams.php b/application/Espo/Core/Utils/Client/RenderParams.php new file mode 100644 index 0000000000..69701c8e2e --- /dev/null +++ b/application/Espo/Core/Utils/Client/RenderParams.php @@ -0,0 +1,41 @@ +. + * + * 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\Client; + +class RenderParams +{ + /** + * @param Script[] $scripts + */ + public function __construct( + readonly public ?string $runScript = null, + readonly public array $scripts = [], + ) {} +} diff --git a/application/Espo/Core/Utils/Client/Script.php b/application/Espo/Core/Utils/Client/Script.php new file mode 100644 index 0000000000..7f91db698a --- /dev/null +++ b/application/Espo/Core/Utils/Client/Script.php @@ -0,0 +1,38 @@ +. + * + * 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\Client; + +class Script +{ + public function __construct( + readonly public string $source, + readonly public bool $cacheBusting = false, + ) {} +} diff --git a/application/Espo/Core/Utils/Client/SecurityParams.php b/application/Espo/Core/Utils/Client/SecurityParams.php new file mode 100644 index 0000000000..6f896e57ef --- /dev/null +++ b/application/Espo/Core/Utils/Client/SecurityParams.php @@ -0,0 +1,40 @@ +. + * + * 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\Client; + +class SecurityParams +{ + /** + * @param string[] $frameAncestors + */ + public function __construct( + readonly public array $frameAncestors = [], + ) {} +} diff --git a/application/Espo/Core/Utils/ClientManager.php b/application/Espo/Core/Utils/ClientManager.php index 18cdfa3d19..10e89f8c64 100644 --- a/application/Espo/Core/Utils/ClientManager.php +++ b/application/Espo/Core/Utils/ClientManager.php @@ -33,6 +33,9 @@ use Espo\Core\Api\Response; use Espo\Core\Api\ResponseWrapper; use Espo\Core\Utils\Client\DevModeJsFileListProvider; use Espo\Core\Utils\Client\LoaderParamsProvider; +use Espo\Core\Utils\Client\RenderParams; +use Espo\Core\Utils\Client\Script; +use Espo\Core\Utils\Client\SecurityParams; use Espo\Core\Utils\File\Manager as FileManager; use Slim\Psr7\Response as Psr7Response; @@ -62,7 +65,7 @@ class ClientManager private FileManager $fileManager, private DevModeJsFileListProvider $devModeJsFileListProvider, private Module $module, - private LoaderParamsProvider $loaderParamsProvider + private LoaderParamsProvider $loaderParamsProvider, ) { $this->nonce = Util::generateKey(); } @@ -80,43 +83,45 @@ class ClientManager /** * @todo Move to a separate class. */ - public function writeHeaders(Response $response): void + public function writeHeaders(Response $response, ?SecurityParams $params = null): void { if ($this->config->get('clientSecurityHeadersDisabled')) { return; } + $params ??= new SecurityParams(); + $response->setHeader('X-Content-Type-Options', 'nosniff'); - $this->writeXFrameOptionsHeader($response); - $this->writeContentSecurityPolicyHeader($response); + $this->writeContentSecurityPolicyHeader($response, $params); $this->writeStrictTransportSecurityHeader($response); } - private function writeXFrameOptionsHeader(Response $response): void - { - if ($this->config->get('clientXFrameOptionsHeaderDisabled')) { - return; - } - - $response->setHeader('X-Frame-Options', 'SAMEORIGIN'); - } - - private function writeContentSecurityPolicyHeader(Response $response): void + private function writeContentSecurityPolicyHeader(Response $response, SecurityParams $params): void { if ($this->config->get('clientCspDisabled')) { return; } - $scriptSrc = "script-src 'self' 'nonce-$this->nonce' 'unsafe-eval'"; + $string = "script-src 'self' 'nonce-$this->nonce' 'unsafe-eval'"; + /** @var string[] $scriptSourceList */ $scriptSourceList = $this->config->get('clientCspScriptSourceList') ?? []; foreach ($scriptSourceList as $src) { - $scriptSrc .= ' ' . $src; + $string .= ' ' . $src; } - $response->setHeader('Content-Security-Policy', $scriptSrc); + // Checking the parameter for bc. + if (!$this->config->get('clientXFrameOptionsHeaderDisabled')) { + $string .= '; frame-ancestors'; + + foreach (["'self'", ...$params->frameAncestors] as $item) { + $string .= ' ' . $item; + } + } + + $response->setHeader('Content-Security-Policy', $string); } private function writeStrictTransportSecurityHeader(Response $response): void @@ -137,7 +142,7 @@ class ClientManager */ public function display(?string $runScript = null, ?string $htmlFilePath = null, array $vars = []): void { - $body = $this->render($runScript, $htmlFilePath, $vars); + $body = $this->renderInternal($runScript, $htmlFilePath, $vars); $response = new ResponseWrapper(new Psr7Response()); @@ -148,10 +153,32 @@ class ClientManager } /** - * @param array $vars + * Render. + * + * @param RenderParams $params Parameters. + * @return string A result HTML. */ - public function render(?string $runScript = null, ?string $htmlFilePath = null, array $vars = []): string + public function render(RenderParams $params): string { + return $this->renderInternal( + $params->runScript, + null, + [], + $params->scripts + ); + } + + /** + * @param array $vars + * @param Script[] $additionalScripts + */ + private function renderInternal( + ?string $runScript = null, + ?string $htmlFilePath = null, + array $vars = [], + array $additionalScripts = [] + ): string { + $runScript ??= $this->runScript; $htmlFilePath ??= $this->mainHtmlFilePath; @@ -176,6 +203,10 @@ class ClientManager array_map(fn ($file) => $this->getScriptItemHtml($file, $appTimestamp), $jsFileList) ); + foreach ($additionalScripts as $it) { + $scriptsHtml .= $this->getScriptItemHtml(null, $appTimestamp, true, $it->cacheBusting, $it->source); + } + $additionalStyleSheetsHtml = implode('', array_map(fn ($file) => $this->getCssItemHtml($file, $appTimestamp), $cssFileList) ); @@ -295,12 +326,28 @@ class ClientManager return $this->config->get('appTimestamp', 0); } - private function getScriptItemHtml(string $file, int $appTimestamp): string - { - $src = $this->basePath . $file . '?r=' . $appTimestamp; + private function getScriptItemHtml( + ?string $file, + int $appTimestamp, + bool $withNonce = false, + bool $cache = true, + ?string $source = null, + ): string { + + $src = $source ?? $this->basePath . $file; + + if ($cache) { + $src .= '?r=' . $appTimestamp; + } + + $noncePart = ''; + + if ($withNonce) { + $noncePart = " nonce=\"$this->nonce\""; + } return $this->getTabHtml() . - ""; + ""; } private function getCssItemHtml(string $file, int $appTimestamp): string diff --git a/application/Espo/Core/Utils/Language/LanguageFactory.php b/application/Espo/Core/Utils/Language/LanguageFactory.php new file mode 100644 index 0000000000..459cc30b76 --- /dev/null +++ b/application/Espo/Core/Utils/Language/LanguageFactory.php @@ -0,0 +1,50 @@ +. + * + * 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\Language; + +use Espo\Core\InjectableFactory; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\Language; + +class LanguageFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + private Config $config, + ) {} + + public function create(string $language): Language + { + return $this->injectableFactory->createWith(Language::class, [ + 'language' => $language, + 'useCache' => $this->config->get('useCache') ?? false, + ]); + } +} diff --git a/application/Espo/Core/Utils/ThemeManager.php b/application/Espo/Core/Utils/ThemeManager.php index 0d7b0b0506..a82e8afeda 100644 --- a/application/Espo/Core/Utils/ThemeManager.php +++ b/application/Espo/Core/Utils/ThemeManager.php @@ -54,4 +54,9 @@ class ThemeManager { return $this->metadata->get(['themes', $this->getName(), 'logo']) ?? $this->defaultLogoSrc; } + + public function isDark(): bool + { + return (bool) $this->metadata->get("themes.{$this->getName()}.isDark"); + } } diff --git a/application/Espo/Entities/LeadCapture.php b/application/Espo/Entities/LeadCapture.php index b8e891d2d9..09e25c713a 100644 --- a/application/Espo/Entities/LeadCapture.php +++ b/application/Espo/Entities/LeadCapture.php @@ -31,6 +31,7 @@ namespace Espo\Entities; use Espo\Core\Name\Field; use Espo\Core\ORM\Entity; +use stdClass; class LeadCapture extends Entity { @@ -44,6 +45,49 @@ class LeadCapture extends Entity return $this->get('subscribeToTargetList') && $this->get('subscribeContactToTargetList'); } + public function hasFormCaptcha(): bool + { + return (bool) $this->get('formCaptcha'); + } + + /** + * @return string[] + */ + public function getFormFrameAncestors(): array + { + return $this->get('formFrameAncestors') ?? []; + } + + public function getFormText(): ?string + { + return $this->get('formText'); + } + + public function getFormSuccessText(): ?string + { + return $this->get('formSuccessText'); + } + + public function getFormLanguage(): ?string + { + return $this->get('formLanguage'); + } + + public function getFormSuccessRedirectUrl(): ?string + { + $url = $this->get('formSuccessRedirectUrl'); + + if (!$url) { + return null; + } + + if (!str_contains($url, '://')) { + $url = 'https://' . $url; + } + + return $url; + } + /** * @return string[] */ @@ -52,6 +96,16 @@ class LeadCapture extends Entity return $this->get('fieldList') ?? []; } + public function isFieldRequired(string $field): bool + { + /** @var stdClass $fieldParams */ + $fieldParams = $this->get('fieldParams') ?? (object) []; + /** @var stdClass $itParams */ + $itParams = $fieldParams->$field ?? (object) []; + + return (bool) ($itParams->required ?? false); + } + public function getOptInConfirmationSuccessMessage(): ?string { return $this->get('optInConfirmationSuccessMessage'); @@ -92,6 +146,16 @@ class LeadCapture extends Entity return (bool) $this->get('subscribeContactToTargetList'); } + public function getFormId(): ?string + { + return $this->get('formId'); + } + + public function setFormId(string $apiKey): self + { + return $this->set('formId', $apiKey); + } + public function getApiKey(): ?string { return $this->get('apiKey'); @@ -146,4 +210,9 @@ class LeadCapture extends Entity { return $this->get('phoneNumberCountry'); } + + public function hasFormEnabled(): bool + { + return (bool) $this->get('formEnabled'); + } } diff --git a/application/Espo/EntryPoints/LeadCaptureForm.php b/application/Espo/EntryPoints/LeadCaptureForm.php new file mode 100644 index 0000000000..90056e69a0 --- /dev/null +++ b/application/Espo/EntryPoints/LeadCaptureForm.php @@ -0,0 +1,77 @@ +. + * + * 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\EntryPoints; + +use Espo\Core\Utils\Client\Script; +use Espo\Tools\LeadCapture\FormService; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\EntryPoint\EntryPoint; +use Espo\Core\EntryPoint\Traits\NoAuth; +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\NotFound; +use Espo\Core\Utils\Client\ActionRenderer; + +/** + * @noinspection PhpUnused + */ +class LeadCaptureForm implements EntryPoint +{ + use NoAuth; + + public function __construct( + private ActionRenderer $actionRenderer, + private FormService $service, + ) {} + + /** + * @throws BadRequest + * @throws NotFound + */ + public function run(Request $request, Response $response): void + { + $id = $request->getQueryParam('id'); + + if (!$id) { + throw new BadRequest("No ID."); + } + + [$leadCapture, $data, $captchaScript] = $this->service->getData($id); + + $params = new ActionRenderer\Params('controllers/lead-capture-form', 'show', $data); + $params = $params->withFrameAncestors($leadCapture->getFormFrameAncestors()); + + if ($captchaScript) { + $params = $params->withScripts([new Script(source: $captchaScript)]); + } + + $this->actionRenderer->write($response, $params); + } +} diff --git a/application/Espo/Hooks/LeadCapture/ClearCache.php b/application/Espo/Hooks/LeadCapture/ClearCache.php new file mode 100644 index 0000000000..ca080ab4eb --- /dev/null +++ b/application/Espo/Hooks/LeadCapture/ClearCache.php @@ -0,0 +1,62 @@ +. + * + * 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\LeadCapture; + +use Espo\Core\Hook\Hook\AfterSave; +use Espo\Core\Utils\DataCache; +use Espo\Entities\LeadCapture; +use Espo\ORM\Entity; +use Espo\ORM\Repository\Option\SaveOptions; + +/** + * @implements AfterSave + */ +class ClearCache implements AfterSave +{ + private const CACHE_KEY_PREFIX = 'leadCaptureForm'; + + public function __construct( + private DataCache $dataCache, + ) {} + + public function afterSave(Entity $entity, SaveOptions $options): void + { + if ($entity->isNew()) { + return; + } + + $this->dataCache->clear($this->getCacheKey($entity)); + } + + private function getCacheKey(LeadCapture $leadCapture): string + { + return self::CACHE_KEY_PREFIX . '/' . $leadCapture->getId(); + } +} diff --git a/application/Espo/Modules/Crm/Entities/Lead.php b/application/Espo/Modules/Crm/Entities/Lead.php index e018bcf7ab..9a678859a3 100644 --- a/application/Espo/Modules/Crm/Entities/Lead.php +++ b/application/Espo/Modules/Crm/Entities/Lead.php @@ -177,4 +177,14 @@ class Lead extends Person return $this; } + + public function setSource(?string $source): self + { + return $this->set('source', $source); + } + + public function setCampaign(Link|Campaign|null $campaign): self + { + return $this->setRelatedLinkOrEntity('campaign', $campaign); + } } diff --git a/application/Espo/Resources/i18n/en_US/Admin.json b/application/Espo/Resources/i18n/en_US/Admin.json index f0fa6c6700..1da70b4271 100644 --- a/application/Espo/Resources/i18n/en_US/Admin.json +++ b/application/Espo/Resources/i18n/en_US/Admin.json @@ -301,7 +301,7 @@ "authLog": "Login history.", "appLog": "Application log.", "appSecrets": "Store sensitive information like API keys, passwords, and other secrets.", - "leadCapture": "API entry points for Web-to-Lead.", + "leadCapture": "Lead capture endpoints and web forms.", "attachments": "All file attachments stored in the system.", "systemRequirements": "System Requirements for EspoCRM.", "apiUsers": "Separate users for integration purposes.", diff --git a/application/Espo/Resources/i18n/en_US/Integration.json b/application/Espo/Resources/i18n/en_US/Integration.json index 8dc068efcc..5fd0379576 100644 --- a/application/Espo/Resources/i18n/en_US/Integration.json +++ b/application/Espo/Resources/i18n/en_US/Integration.json @@ -4,16 +4,21 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "redirectUri": "Redirect URI", - "apiKey": "API Key" + "apiKey": "API Key", + "siteKey": "Site Key", + "secretKey": "Secret Key", + "scoreThreshold": "Score Threshold" }, "titles": { - "GoogleMaps": "Google Maps" + "GoogleMaps": "Google Maps", + "GoogleReCaptcha": "Google reCAPTCHA" }, "messages": { "selectIntegration": "Select an integration from menu.", "noIntegrations": "No Integrations is available." }, "help": { + "GoogleReCaptcha": "Obtain the Site Key and Secret Key from [Google](https://www.google.com/recaptcha/).", "Google": "**Obtain OAuth 2.0 credentials from the Google Developers Console.**\n\nVisit [Google Developers Console](https://console.developers.google.com/project) to obtain OAuth 2.0 credentials such as a Client ID and Client Secret that are known to both Google and EspoCRM application.", "GoogleMaps": "Obtain API key [here](https://developers.google.com/maps/documentation/javascript/get-api-key)." } diff --git a/application/Espo/Resources/i18n/en_US/LeadCapture.json b/application/Espo/Resources/i18n/en_US/LeadCapture.json index be9da81bec..c6c2cd89ad 100644 --- a/application/Espo/Resources/i18n/en_US/LeadCapture.json +++ b/application/Espo/Resources/i18n/en_US/LeadCapture.json @@ -23,7 +23,17 @@ "smtpAccount": "SMTP Account", "inboundEmail": "Group Email Account", "duplicateCheck": "Duplicate Check", - "phoneNumberCountry": "Telephone country code" + "phoneNumberCountry": "Telephone country code", + "fieldParams": "Field Params", + "formId": "Form ID", + "formEnabled": "Web Form", + "formUrl": "Form URL", + "formSuccessText": "Text to display after form submission", + "formText": "Text to display on form", + "formSuccessRedirectUrl": "URL to redirect to after form submission", + "formLanguage": "Language used on form", + "formFrameAncestors": "Allowed hosts for form embedding", + "formCaptcha": "Use Captcha" }, "links": { "targetList": "Target List", @@ -37,7 +47,9 @@ "Create LeadCapture": "Create Entry Point", "Generate New API Key": "Generate New API Key", "Request": "Request", - "Confirm Opt-In": "Confirm Opt-In" + "Confirm Opt-In": "Confirm Opt-In", + "Generate New Form ID": "Generate New Form ID", + "Web Form": "Web Form" }, "messages": { "generateApiKey": "Create new API Key", @@ -45,6 +57,7 @@ "optInIsConfirmed": "Opt-in is confirmed." }, "tooltips": { + "formCaptcha": "To be able to use Captcha, you need to configure it under **Administration** > **Integrations**.", "optInConfirmationSuccessMessage": "Markdown is supported." } } diff --git a/application/Espo/Resources/layouts/LeadCapture/detail.json b/application/Espo/Resources/layouts/LeadCapture/detail.json index 64bad50fda..63a1954ae7 100644 --- a/application/Espo/Resources/layouts/LeadCapture/detail.json +++ b/application/Espo/Resources/layouts/LeadCapture/detail.json @@ -5,9 +5,20 @@ [{"name": "subscribeToTargetList", "inlineEditDisabled": true}, {"name": "campaign"}], [{"name": "subscribeContactToTargetList"}, {"name": "targetList"}], [{"name": "targetTeam"}, {"name": "leadSource"}], - [{"name": "fieldList"}], - [{"name": "duplicateCheck"}, {"name": "phoneNumberCountry"}], - [{"name": "apiKey"}, false] + [{"name": "duplicateCheck"}, {"name": "phoneNumberCountry"}] + ] + }, + { + "rows": [ + [{"name": "fieldList"}, {"name": "apiKey"}] + ] + }, + { + "rows": [ + [{"name": "formEnabled"}, false], + [{"name": "formText"}, {"name": "formSuccessText"}], + [{"name": "formSuccessRedirectUrl"}, {"name": "formLanguage"}], + [{"name": "formFrameAncestors"}, {"name": "formCaptcha"}] ] }, { diff --git a/application/Espo/Resources/metadata/clientDefs/LeadCapture.json b/application/Espo/Resources/metadata/clientDefs/LeadCapture.json index 26fbb0e420..53cb944fe0 100644 --- a/application/Espo/Resources/metadata/clientDefs/LeadCapture.json +++ b/application/Espo/Resources/metadata/clientDefs/LeadCapture.json @@ -143,6 +143,82 @@ } ] } + }, + "formSuccessText": { + "visible": { + "conditionGroup": [ + { + "type": "isTrue", + "attribute": "formEnabled" + } + ] + } + }, + "formText": { + "visible": { + "conditionGroup": [ + { + "type": "isTrue", + "attribute": "formEnabled" + } + ] + } + }, + "formSuccessRedirectUrl": { + "visible": { + "conditionGroup": [ + { + "type": "isTrue", + "attribute": "formEnabled" + } + ] + } + }, + "formLanguage": { + "visible": { + "conditionGroup": [ + { + "type": "isTrue", + "attribute": "formEnabled" + } + ] + } + }, + "formFrameAncestors": { + "visible": { + "conditionGroup": [ + { + "type": "isTrue", + "attribute": "formEnabled" + } + ] + } + }, + "formCaptcha": { + "visible": { + "conditionGroup": [ + { + "type": "isTrue", + "attribute": "formEnabled" + } + ] + } + } + }, + "panels": { + "form": { + "visible": { + "conditionGroup": [ + { + "type": "isNotEmpty", + "attribute": "id" + }, + { + "type": "isTrue", + "attribute": "formEnabled" + } + ] + } } } }, @@ -152,7 +228,15 @@ "name": "request", "label": "Request", "isForm": true, - "view": "views/lead-capture/record/panels/request" + "view": "views/lead-capture/record/panels/request", + "notRefreshable": true + }, + { + "name": "form", + "label": "Web Form", + "isForm": true, + "view": "views/lead-capture/record/panels/form", + "notRefreshable": true } ] }, diff --git a/application/Espo/Resources/metadata/entityDefs/LeadCapture.json b/application/Espo/Resources/metadata/entityDefs/LeadCapture.json index 95e5eedaa0..3e76112e1d 100644 --- a/application/Espo/Resources/metadata/entityDefs/LeadCapture.json +++ b/application/Espo/Resources/metadata/entityDefs/LeadCapture.json @@ -5,27 +5,33 @@ "maxLength": 100 }, "campaign": { - "type": "link" + "type": "link", + "audited": true }, "isActive": { "type": "bool", - "default": true + "default": true, + "audited": true }, "subscribeToTargetList": { "type": "bool", - "default": true + "default": true, + "audited": true }, "subscribeContactToTargetList": { "type": "bool", - "default": true + "default": true, + "audited": true }, "targetList": { - "type": "link" + "type": "link", + "audited": true }, "fieldList": { "type": "multiEnum", "default": ["firstName", "lastName", "emailAddress"], "view": "views/lead-capture/fields/field-list", + "displayAsList": true, "required": true, "ignoreFieldList": [ "targetList", @@ -45,47 +51,117 @@ "phoneNumberIsInvalid", "opportunityAmountCurrency", "originalEmail" - ] + ], + "webFormFieldTypeList": [ + "varchar", + "email", + "phone", + "text", + "personName", + "enum", + "multiEnum", + "array", + "checklist", + "int", + "float", + "currency", + "date", + "datetime", + "boolean", + "url", + "urlMultiple", + "address" + ], + "audited": true + }, + "fieldParams": { + "type": "jsonObject", + "audited": true }, "duplicateCheck": { "type": "bool", - "default": true + "default": true, + "audited": true }, "optInConfirmation": { - "type": "bool" + "type": "bool", + "audited": true }, "optInConfirmationEmailTemplate": { - "type": "link" + "type": "link", + "audited": true }, "optInConfirmationLifetime": { "type": "int", "default": 48, - "min": 1 + "min": 1, + "audited": true }, "optInConfirmationSuccessMessage": { "type": "text", - "tooltip": true + "tooltip": true, + "audited": true }, "createLeadBeforeOptInConfirmation": { - "type": "bool" + "type": "bool", + "audited": true }, "skipOptInConfirmationIfSubscribed": { - "type": "bool" + "type": "bool", + "audited": true }, "leadSource": { "type": "enum", "customizationOptionsDisabled": true, "optionsPath": "entityDefs.Lead.fields.source.options", "translation": "Lead.options.source", - "default": "Web Site" + "default": "Web Site", + "audited": true }, "apiKey": { "type": "varchar", "maxLength": 36, "readOnly": true }, + "formId": { + "type": "varchar", + "maxLength": 17, + "readOnly": true + }, + "formEnabled": { + "type": "bool", + "audited": true + }, + "formText": { + "type": "text", + "tooltip": "optInConfirmationSuccessMessage" + }, + "formSuccessText": { + "type": "text", + "tooltip": "optInConfirmationSuccessMessage" + }, + "formSuccessRedirectUrl": { + "type": "url", + "audited": true + }, + "formLanguage": { + "type": "enum", + "maxLength": 5, + "view": "views/preferences/fields/language", + "audited": true + }, + "formFrameAncestors": { + "type": "urlMultiple", + "audited": true + }, + "formCaptcha": { + "type": "bool", + "audited": true, + "tooltip": true + }, "targetTeam": { - "type": "link" + "type": "link", + "audited": true }, "exampleRequestUrl": { "type": "varchar", @@ -109,8 +185,16 @@ "notStorable": true, "readOnly": true }, + "formUrl": { + "type": "url", + "notStorable": true, + "readOnly": true, + "copyToClipboard": true, + "index": true + }, "inboundEmail": { - "type": "link" + "type": "link", + "audited": true }, "smtpAccount": { "type": "base", diff --git a/application/Espo/Resources/metadata/fields/email.json b/application/Espo/Resources/metadata/fields/email.json index 0a75a05dda..baf8eb6325 100644 --- a/application/Espo/Resources/metadata/fields/email.json +++ b/application/Espo/Resources/metadata/fields/email.json @@ -8,6 +8,11 @@ { "name": "audited", "type": "bool" + }, + { + "name": "onlyPrimary", + "type": "bool", + "hidden": true } ], "actualFields": [ diff --git a/application/Espo/Resources/metadata/fields/phone.json b/application/Espo/Resources/metadata/fields/phone.json index 97090fe356..32df32e1dc 100644 --- a/application/Espo/Resources/metadata/fields/phone.json +++ b/application/Espo/Resources/metadata/fields/phone.json @@ -26,6 +26,11 @@ { "name": "audited", "type": "bool" + }, + { + "name": "onlyPrimary", + "type": "bool", + "hidden": true } ], "actualFields": [ diff --git a/application/Espo/Resources/metadata/integrations/GoogleReCaptcha.json b/application/Espo/Resources/metadata/integrations/GoogleReCaptcha.json new file mode 100644 index 0000000000..cf344e6fcb --- /dev/null +++ b/application/Espo/Resources/metadata/integrations/GoogleReCaptcha.json @@ -0,0 +1,23 @@ +{ + "fields": { + "siteKey": { + "type": "varchar", + "maxLength": 255, + "required": true + }, + "secretKey": { + "type": "password", + "maxLength": 255, + "required": true + }, + "scoreThreshold": { + "type": "float", + "min": 0.0, + "max": 1.0, + "default": 0.3, + "required": true + } + }, + "allowUserAccounts": false, + "view": "views/admin/integrations/edit" +} diff --git a/application/Espo/Resources/routes.json b/application/Espo/Resources/routes.json index c26382eb1d..2d27606c16 100644 --- a/application/Espo/Resources/routes.json +++ b/application/Espo/Resources/routes.json @@ -78,6 +78,12 @@ "method": "get", "actionClassName": "Espo\\Tools\\GlobalSearch\\Api\\Get" }, + { + "route": "/LeadCapture/form/:id", + "method": "post", + "actionClassName": "Espo\\Tools\\LeadCapture\\Api\\PostForm", + "noAuth": true + }, { "route": "/LeadCapture/:apiKey", "method": "post", diff --git a/application/Espo/Tools/App/LanguageService.php b/application/Espo/Tools/App/LanguageService.php index f7c4cf2709..3631871c17 100644 --- a/application/Espo/Tools/App/LanguageService.php +++ b/application/Espo/Tools/App/LanguageService.php @@ -62,23 +62,15 @@ class LanguageService /** * @return array */ - public function getDataForFrontend(bool $default = false): array + public function getDataForFrontendFromLanguage(LanguageUtil $language): array { - if ($default) { - $languageObj = $this->getDefaultLanguage(); - } else { - $languageObj = $this->getLanguage(); - } - - $data = $languageObj->getAll(); + $data = $language->getAll(); if ($this->user->isSystem()) { unset($data['Global']['scopeNames']); unset($data['Global']['scopeNamesPlural']); unset($data['Global']['dashlets']); unset($data['Global']['links']); - unset($data['Global']['fields']); - unset($data['Global']['options']); foreach ($data as $k => $item) { if ( @@ -145,20 +137,34 @@ class LanguageService } if (!$this->user->isAdmin()) { - $this->prepareDataNonAdmin($data, $languageObj); + $this->prepareDataNonAdmin($data, $language); } } $data['User']['fields'] = $data['User']['fields'] ?? []; - $data['User']['fields']['password'] = $languageObj->translate('password', 'fields', 'User'); - $data['User']['fields']['passwordConfirm'] = $languageObj->translate('passwordConfirm', 'fields', 'User'); - $data['User']['fields']['newPassword'] = $languageObj->translate('newPassword', 'fields', 'User'); - $data['User']['fields']['newPasswordConfirm'] = $languageObj->translate('newPasswordConfirm', 'fields', 'User'); + $data['User']['fields']['password'] = $language->translate('password', 'fields', 'User'); + $data['User']['fields']['passwordConfirm'] = $language->translate('passwordConfirm', 'fields', 'User'); + $data['User']['fields']['newPassword'] = $language->translate('newPassword', 'fields', 'User'); + $data['User']['fields']['newPasswordConfirm'] = $language->translate('newPasswordConfirm', 'fields', 'User'); return $data; } + /** + * @return array + */ + public function getDataForFrontend(bool $default = false): array + { + if ($default) { + $languageObj = $this->getDefaultLanguage(); + } else { + $languageObj = $this->getLanguage(); + } + + return $this->getDataForFrontendFromLanguage($languageObj); + } + /** * @param array $data */ diff --git a/application/Espo/Tools/Captcha/Checker.php b/application/Espo/Tools/Captcha/Checker.php new file mode 100644 index 0000000000..8ec4f19f28 --- /dev/null +++ b/application/Espo/Tools/Captcha/Checker.php @@ -0,0 +1,147 @@ +. + * + * 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\Captcha; + +use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Utils\Json; +use Espo\Core\Utils\Log; +use Espo\Entities\Integration; +use Espo\ORM\EntityManager; +use RuntimeException; + +class Checker +{ + private const URL = 'https://www.google.com/recaptcha/api/siteverify'; + private const SCORE_THRESHOLD = 0.2; + private const TIMEOUT = 20; + + public function __construct( + private EntityManager $entityManager, + private Log $log, + ) {} + + /** + * @throws BadRequest + * @throws Forbidden + */ + public function check(string $token, string $action): void + { + [$secret, $scoreThreshold] = $this->getCaptchaSecretKey(); + + if ($secret && $token === '') { + throw new BadRequest("No captcha token."); + } + + if (!$secret) { + throw new Forbidden("Captcha not configured."); + } + + $ch = curl_init(); + + curl_setopt($ch, CURLOPT_URL, self::URL); + curl_setopt($ch, CURLOPT_POST, true); + + $data = [ + 'secret' => $secret, + 'response' => $token, + ]; + + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT); + + $response = curl_exec($ch); + + curl_close($ch); + + if (!is_string($response)) { + throw new RuntimeException("Bad CURL response."); + } + + $responseData = Json::decode($response, true); + + if (!is_array($responseData)) { + throw new RuntimeException("Bad response from ReCaptcha."); + } + + $success = $responseData['success'] ?? null; + $score = $responseData['score'] ?? null; + $resultAction = $responseData['action'] ?? null; + + if (!$success) { + $this->log->error("Captcha error; action: {action}; response: {response}", [ + 'action' => $action, + 'response' => $response, + ]); + + throw new Forbidden("ReCaptcha error."); + } + + if (!is_string($resultAction)) { + throw new RuntimeException("No or bad action in ReCaptcha response."); + } + + if (!is_int($score) && !is_float($score)) { + throw new RuntimeException("No score in ReCaptcha response."); + } + + if ($action !== $resultAction) { + throw new Forbidden("ReCaptcha action mismatch."); + } + + if ($score < $scoreThreshold) { + throw new Forbidden("ReCaptcha low score."); + } + } + + /** + * @return array{?string, ?int} + */ + private function getCaptchaSecretKey(): array + { + $entity = $this->entityManager + ->getRepositoryByClass(Integration::class) + ->getById('GoogleReCaptcha'); + + if (!$entity) { + return [null, null]; + } + + $secretKey = $entity->get('secretKey'); + $scoreThreshold = $entity->get('scoreThreshold') ?? self::SCORE_THRESHOLD; + + if (!$secretKey) { + return [null, null]; + } + + return [$secretKey, $scoreThreshold]; + } +} diff --git a/application/Espo/Tools/LeadCapture/Api/PostForm.php b/application/Espo/Tools/LeadCapture/Api/PostForm.php new file mode 100644 index 0000000000..38ed0d77e1 --- /dev/null +++ b/application/Espo/Tools/LeadCapture/Api/PostForm.php @@ -0,0 +1,60 @@ +. + * + * 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\LeadCapture\Api; + +use Espo\Core\Api\Action; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Api\ResponseComposer; +use Espo\Core\Exceptions\BadRequest; +use Espo\Tools\LeadCapture\CaptureService; + +/** + * @noinspection PhpUnused + */ +class PostForm implements Action +{ + public function __construct( + private CaptureService $service, + ) {} + + public function process(Request $request): Response + { + $data = $request->getParsedBody(); + $id = $request->getRouteParam('id') ?? throw new BadRequest(); + $captchaToken = $request->getHeader('X-Captcha-Token'); + + $result = $this->service->captureForm($id, $data, $captchaToken); + + return ResponseComposer::json([ + 'redirectUrl' => $result->redirectUrl, + ]); + } +} diff --git a/application/Espo/Tools/LeadCapture/CaptureService.php b/application/Espo/Tools/LeadCapture/CaptureService.php index 906eddb8f4..1c31e7f05c 100644 --- a/application/Espo/Tools/LeadCapture/CaptureService.php +++ b/application/Espo/Tools/LeadCapture/CaptureService.php @@ -29,6 +29,10 @@ namespace Espo\Tools\LeadCapture; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Field\Link; +use Espo\Core\FieldValidation\Exceptions\ValidationError; +use Espo\Core\FieldValidation\Failure; use Espo\Core\Name\Field; use Espo\Core\PhoneNumber\Sanitizer as PhoneNumberSanitizer; use Espo\Core\Job\JobSchedulerFactory; @@ -36,7 +40,7 @@ use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\NotFound; use Espo\Core\FieldValidation\FieldValidationManager; -use Espo\Core\FieldValidation\FieldValidationParams; +use Espo\Core\FieldValidation\FieldValidationParams as ValidationParams; use Espo\Core\HookManager; use Espo\Core\Job\QueueName; use Espo\Core\ORM\EntityManager; @@ -47,7 +51,7 @@ use Espo\Core\Utils\Language; use Espo\Core\Utils\Log; use Espo\ORM\Entity; use Espo\Entities\UniqueId; -use Espo\Entities\LeadCapture as LeadCaptureEntity; +use Espo\Entities\LeadCapture; use Espo\Entities\LeadCaptureLogRecord; use Espo\Modules\Crm\Entities\Campaign; use Espo\Modules\Crm\Entities\TargetList; @@ -55,6 +59,7 @@ use Espo\Modules\Crm\Tools\Campaign\LogService as CampaignService; use Espo\Modules\Crm\Entities\Contact; use Espo\Modules\Crm\Entities\Lead; use Espo\Tools\LeadCapture\Jobs\OptInConfirmation; +use Espo\Tools\Captcha\Checker as CaptchaChecker; use stdClass; use DateTime; @@ -70,9 +75,42 @@ class CaptureService private JobSchedulerFactory $jobSchedulerFactory, private CampaignService $campaignService, private PhoneNumberSanitizer $phoneNumberSanitizer, - private ServiceContainer $serviceContainer + private ServiceContainer $serviceContainer, + private CaptchaChecker $captchaChecker, ) {} + /** + * Capture a lead from a web form. + * + * @param string $id A form ID. + * @param stdClass $data A payload. + * @param ?string $captchaToken A captcha token. + * @throws BadRequest + * @throws Error + * @throws NotFound + * @throws Forbidden + */ + public function captureForm(string $id, stdClass $data, ?string $captchaToken = null): FormResult + { + $leadCapture = $this->getLeadCaptureByFormId($id); + + $apiKey = $leadCapture->getApiKey(); + + if (!$apiKey) { + throw new Error("No API key."); + } + + if ($leadCapture->hasFormCaptcha()) { + $this->captchaChecker->check($captchaToken ?? '', 'leadCaptureSubmit'); + } + + $this->capture($apiKey, $data); + + return new FormResult( + redirectUrl: $leadCapture->getFormSuccessRedirectUrl(), + ); + } + /** * Capture a lead. A main entry method. * @@ -84,18 +122,7 @@ class CaptureService */ public function capture(string $apiKey, stdClass $data): void { - /** @var ?LeadCaptureEntity $leadCapture */ - $leadCapture = $this->entityManager - ->getRDBRepositoryByClass(LeadCaptureEntity::class) - ->where([ - 'apiKey' => $apiKey, - 'isActive' => true, - ]) - ->findOne(); - - if (!$leadCapture) { - throw new NotFound('Api key is not valid.'); - } + $leadCapture = $this->getLeadCapture($apiKey); if (!$leadCapture->optInConfirmation()) { $this->proceed($leadCapture, $data); @@ -199,7 +226,7 @@ class CaptureService * @throws Error */ private function proceed( - LeadCaptureEntity $leadCapture, + LeadCapture $leadCapture, stdClass $data, ?string $leadId = null, bool $isLogged = false @@ -307,7 +334,7 @@ class CaptureService (!$isContactOptedIn || !$leadCapture->subscribeToTargetList()) && $leadCapture->subscribeContactToTargetList() ) { - $this->hookManager->process(LeadCaptureEntity::ENTITY_TYPE, 'afterLeadCapture', $leadCapture, [], [ + $this->hookManager->process(LeadCapture::ENTITY_TYPE, 'afterLeadCapture', $leadCapture, [], [ 'targetId' => $contact->getId(), 'targetType' => Contact::ENTITY_TYPE, ]); @@ -357,7 +384,7 @@ class CaptureService if ($toRelateLead || !$leadCapture->subscribeToTargetList()) { $this->hookManager->process( - LeadCaptureEntity::ENTITY_TYPE, + LeadCapture::ENTITY_TYPE, 'afterLeadCapture', $leadCapture, [], @@ -424,12 +451,12 @@ class CaptureService return new ConfirmResult( ConfirmResult::STATUS_EXPIRED, $this->defaultLanguage - ->translateLabel('optInConfirmationExpired', 'messages', LeadCaptureEntity::ENTITY_TYPE) + ->translateLabel('optInConfirmationExpired', 'messages', LeadCapture::ENTITY_TYPE) ); } - /** @var ?LeadCaptureEntity $leadCapture */ - $leadCapture = $this->entityManager->getEntityById(LeadCaptureEntity::ENTITY_TYPE, $leadCaptureId); + /** @var ?LeadCapture $leadCapture */ + $leadCapture = $this->entityManager->getEntityById(LeadCapture::ENTITY_TYPE, $leadCaptureId); if (!$leadCapture) { throw new Error("LeadCapture Confirm: LeadCapture not found."); @@ -457,10 +484,9 @@ class CaptureService * @throws BadRequest * @throws Error */ - private function getLeadWithPopulatedData(LeadCaptureEntity $leadCapture, stdClass $data): Lead + private function getLeadWithPopulatedData(LeadCapture $leadCapture, stdClass $data): Lead { - /** @var Lead $lead */ - $lead = $this->entityManager->getNewEntity(Lead::ENTITY_TYPE); + $lead = $this->entityManager->getRDBRepositoryByClass(Lead::class)->getNew(); $fieldList = $leadCapture->getFieldList(); @@ -474,21 +500,35 @@ class CaptureService $this->setFields($fieldList, $data, $lead); if ($leadCapture->getLeadSource()) { - $lead->set('source', $leadCapture->getLeadSource()); + $lead->setSource($leadCapture->getLeadSource()); } if ($leadCapture->getCampaignId()) { - $lead->set('campaignId', $leadCapture->getCampaignId()); + $lead->setCampaign(Link::create($leadCapture->getCampaignId())); } if ($leadCapture->getTargetTeamId()) { $lead->addLinkMultipleId(Field::TEAMS, $leadCapture->getTargetTeamId()); } - $validationParams = FieldValidationParams::create()->withTypeSkipFieldList('required', $fieldList); + $validationParams = ValidationParams::create()->withTypeSkipFieldList('required', $fieldList); $this->fieldValidationManager->process($lead, $data, $validationParams); + foreach ($fieldList as $field) { + if (!$leadCapture->isFieldRequired($field)) { + continue; + } + + $notValid = $this->fieldValidationManager->check($lead, $field, 'required', $data, true); + + if (!$notValid) { + $failure = new Failure(Lead::ENTITY_TYPE, $field, 'required'); + + throw ValidationError::create($failure); + } + } + return $lead; } @@ -498,7 +538,7 @@ class CaptureService * lead: ?Lead, * } */ - private function findLeadDuplicates(LeadCaptureEntity $leadCapture, Lead $lead): array + private function findLeadDuplicates(LeadCapture $leadCapture, Lead $lead): array { $duplicate = null; $contact = null; @@ -585,7 +625,7 @@ class CaptureService } private function log( - LeadCaptureEntity $leadCapture, + LeadCapture $leadCapture, Entity $target, stdClass $data, bool $isNew = true @@ -654,7 +694,7 @@ class CaptureService } if ($isEmpty) { - throw new BadRequest('noRequiredFields'); + throw new BadRequest('empty'); } } @@ -664,7 +704,7 @@ class CaptureService private function sanitizePhoneNumber( array $fieldList, stdClass $data, - LeadCaptureEntity $leadCapture + LeadCapture $leadCapture ): void { if ( @@ -689,4 +729,44 @@ class CaptureService unset($data->{Field::PHONE_NUMBER . 'IsInvalid'}); unset($data->{Field::PHONE_NUMBER . 'IsOptedOut'}); } + + /** + * @throws NotFound + */ + private function getLeadCapture(string $apiKey): LeadCapture + { + $leadCapture = $this->entityManager + ->getRDBRepositoryByClass(LeadCapture::class) + ->where([ + 'apiKey' => $apiKey, + 'isActive' => true, + ]) + ->findOne(); + + if (!$leadCapture) { + throw new NotFound('Form ID is not valid.'); + } + + return $leadCapture; + } + + /** + * @throws NotFound + */ + private function getLeadCaptureByFormId(string $id): LeadCapture + { + $leadCapture = $this->entityManager + ->getRDBRepositoryByClass(LeadCapture::class) + ->where([ + 'formId' => $id, + 'isActive' => true, + ]) + ->findOne(); + + if (!$leadCapture) { + throw new NotFound('API key is not valid.'); + } + + return $leadCapture; + } } diff --git a/application/Espo/Tools/LeadCapture/FormResult.php b/application/Espo/Tools/LeadCapture/FormResult.php new file mode 100644 index 0000000000..be6c3740f1 --- /dev/null +++ b/application/Espo/Tools/LeadCapture/FormResult.php @@ -0,0 +1,37 @@ +. + * + * 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\LeadCapture; + +class FormResult +{ + public function __construct( + readonly public ?string $redirectUrl, + ) {} +} diff --git a/application/Espo/Tools/LeadCapture/FormService.php b/application/Espo/Tools/LeadCapture/FormService.php new file mode 100644 index 0000000000..2529d27d5a --- /dev/null +++ b/application/Espo/Tools/LeadCapture/FormService.php @@ -0,0 +1,450 @@ +. + * + * 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\LeadCapture; + +use Espo\Core\Exceptions\NotFound; +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Utils\Address\CountryDataProvider; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\DataCache; +use Espo\Core\Utils\Language; +use Espo\Core\Utils\Metadata; +use Espo\Core\Utils\ThemeManager; +use Espo\Entities\Integration; +use Espo\Entities\LeadCapture; +use Espo\Modules\Crm\Entities\Lead; +use Espo\ORM\Defs\EntityDefs; +use Espo\ORM\EntityManager; +use Espo\Tools\App\LanguageService; +use RuntimeException; + +class FormService +{ + private const CACHE_KEY_PREFIX = 'leadCaptureForm'; + + public function __construct( + private EntityManager $entityManager, + private Config $config, + private Metadata $metadata, + private Language $defaultLanguage, + private CountryDataProvider $countryDataProvider, + private LanguageService $languageService, + private Language\LanguageFactory $languageFactory, + private DataCache $dataCache, + private ThemeManager $themeManager, + ) {} + + /** + * @return array{LeadCapture, array, ?string} + * @throws NotFound + */ + public function getData(string $id): array + { + $leadCapture = $this->getLeadCapture($id); + $captchaKey = $this->getCaptchaKey($leadCapture); + $captchaScript = $this->getCaptchaScript($captchaKey); + + $data = $this->getDataInternal($leadCapture); + + $data['captchaKey'] = $captchaKey; + $data['isDark'] = $this->themeManager->isDark(); + + return [$leadCapture, $data, $captchaScript]; + } + + /** + * @return array + */ + private function getDataInternal(LeadCapture $leadCapture): array + { + $cacheKey = $this->getCacheKey($leadCapture); + + if ($this->config->get('useCache') && $this->dataCache->has($cacheKey)) { + return $this->getFromCache($cacheKey); + } + + $data = $this->prepareData($leadCapture); + + $this->dataCache->store($cacheKey, $data); + + return $data; + } + + private function getRequestUrl(LeadCapture $leadCapture): string + { + $formId = $leadCapture->getFormId(); + + if (!$formId) { + throw new RuntimeException("No API key."); + } + + return "LeadCapture/form/$formId"; + } + + /** + * @return string[] + */ + private function getFieldList(LeadCapture $leadCapture): array + { + /** @var string[] $allowedTypeList */ + $allowedTypeList = $this->metadata->get("entityDefs.LeadCapture.fields.fieldList.webFormFieldTypeList") ?? []; + + $entityDefs = $this->entityManager->getDefs()->getEntity(Lead::ENTITY_TYPE); + + $fieldList = []; + + foreach ($leadCapture->getFieldList() as $field) { + if (!$entityDefs->hasField($field)) { + continue; + } + + $itemDefs = $entityDefs->getField($field); + + if (!in_array($itemDefs->getType(), $allowedTypeList)) { + continue; + } + + $fieldList[] = $field; + } + + return $fieldList; + } + + /** + * @param string[] $fieldList + * @param array> $languageData + * @return array> + */ + private function getFieldDefs( + array $fieldList, + LeadCapture $leadCapture, + array &$languageData, + Language $language + ): array { + + $entityDefs = $this->entityManager->getDefs()->getEntity(Lead::ENTITY_TYPE); + + $fieldDefs = []; + + foreach ($fieldList as $field) { + $fieldDefs[$field] = $this->metadata->get("entityDefs.Lead.fields.$field"); + + if (!$fieldDefs[$field]) { + continue; + } + + $this->applyFieldDefsItem( + $leadCapture, + $entityDefs, + $field, + $fieldDefs, + $languageData, + $language + ); + } + + return $fieldDefs; + } + + /** + * @param string[] $fieldList + * @return array + */ + private function getDetailLayout(array $fieldList): array + { + $rows = []; + + foreach ($fieldList as $field) { + $rows[] = [['name' => $field]]; + } + + return [['rows' => $rows]]; + } + + /** + * @param string[] $fieldList + * @return array + */ + private function getMetadataFields(array $fieldList): array + { + $metadataFields = []; + + $entityDefs = $this->entityManager->getDefs()->getEntity(Lead::ENTITY_TYPE); + + foreach ($fieldList as $field) { + $type = $entityDefs->getField($field)->getType(); + + if (array_key_exists($type, $metadataFields)) { + continue; + } + + $metadataFields[$type] = $this->metadata->get("fields.$type"); + } + + return $metadataFields; + } + + /** + * @return array + */ + private function getConfig(): array + { + $params = [ + 'decimalMark', + 'thousandSeparator', + 'phoneNumberInternational', + 'phoneNumberExtensions', + 'phoneNumberPreferredCountryList', + 'defaultCurrency', + 'currencyList', + 'currencyDecimalPlaces', + 'addressFormat', + ]; + + $data = []; + + foreach ($params as $param) { + $data[$param] = $this->config->get($param); + } + + return $data; + } + + /** + * @return array + */ + private function getAppParams(): array + { + return [ + 'addressCountryData' => $this->countryDataProvider->get(), + ]; + } + + /** + * @param array> $fieldDefs + * @param array> $languageData + */ + private function applyFieldDefsItem( + LeadCapture $leadCapture, + EntityDefs $entityDefs, + string $field, + array &$fieldDefs, + array &$languageData, + Language $language, + ): void { + + $fieldDefs[$field]['required'] = $leadCapture->isFieldRequired($field); + + $itDefs = $entityDefs->getField($field); + + $type = $itDefs->getType(); + + if ($type === FieldType::ADDRESS) { + $subList = [ + $field . 'Street', + $field . 'Country', + $field . 'State', + $field . 'PostalCode', + $field . 'City', + ]; + + foreach ($subList as $susField) { + $fieldDefs[$susField] = $this->metadata->get("entityDefs.Lead.fields.$susField"); + } + } + + if ($type === FieldType::EMAIL) { + if ($leadCapture->optInConfirmation()) { + $fieldDefs[$field]['required'] = true; + } + + $fieldDefs[$field]['onlyPrimary'] = true; + } + + if ($type === FieldType::PHONE) { + $fieldDefs[$field]['onlyPrimary'] = true; + } + + if ( + in_array($type, [ + FieldType::ENUM, + FieldType::MULTI_ENUM, + FieldType::ARRAY, + FieldType::CHECKLIST, + ]) + ) { + $reference = $itDefs->getParam('optionsReference'); + + if ($reference) { + [$refEntityType, $refField] = explode('.', $reference); + + $options = $this->entityManager + ->getDefs() + ->tryGetEntity($refEntityType) + ?->tryGetField($refField) + ?->getParam('options'); + + $fieldDefs[$field]['options'] = $options; + unset($fieldDefs[$field]['optionsReference']); + + $languageData[Lead::ENTITY_TYPE] ??= []; + $languageData[Lead::ENTITY_TYPE]['options'] ??= []; + $languageData[Lead::ENTITY_TYPE]['options'][$field] = + $language->get("$refEntityType.options.$refField"); + } + } + } + + /** + * @return array + */ + private function getLanguageData(Language $language): array + { + $data = $this->languageService->getDataForFrontendFromLanguage($language); + + $data[Lead::ENTITY_TYPE] = $language->get(Lead::ENTITY_TYPE); + + return $data; + } + + /** + * @throws NotFound + */ + public function getLeadCapture(string $id): LeadCapture + { + $leadCapture = $this->entityManager + ->getRDBRepositoryByClass(LeadCapture::class) + ->where(['formId' => $id]) + ->findOne(); + + if (!$leadCapture || !$leadCapture->hasFormEnabled()) { + throw new NotFound(); + } + + return $leadCapture; + } + + private function getSuccessText(LeadCapture $leadCapture): string + { + return $leadCapture->getFormSuccessText() ?? $this->defaultLanguage->translateLabel('Posted'); + } + + private function getCacheKey(LeadCapture $leadCapture): string + { + return self::CACHE_KEY_PREFIX . '/' . $leadCapture->getId(); + } + + /** + * @return array + */ + private function getFromCache(string $cacheKey): array + { + /** @var array */ + return $this->dataCache->get($cacheKey); + } + + /** + * @return array + */ + private function prepareData(LeadCapture $leadCapture): array + { + $language = $this->getLanguage($leadCapture); + + $languageData = $this->getLanguageData($language); + $fieldList = $this->getFieldList($leadCapture); + $fieldDefs = $this->getFieldDefs($fieldList, $leadCapture, $languageData, $language); + $detailLayout = $this->getDetailLayout($fieldList); + $metadataFields = $this->getMetadataFields($fieldList); + $successText = $this->getSuccessText($leadCapture); + $text = $leadCapture->getFormText(); + $config = $this->getConfig(); + $appParams = $this->getAppParams(); + + return [ + 'requestUrl' => $this->getRequestUrl($leadCapture), + 'fieldDefs' => (object) $fieldDefs, + 'metadata' => [ + 'fields' => (object) $metadataFields, + 'app' => [ + 'regExpPatterns' => $this->metadata->get("app.regExpPatterns"), + ], + ], + 'detailLayout' => $detailLayout, + 'language' => $languageData, + 'successText' => $successText, + 'text' => $text, + 'config' => (object) $config, + 'appParams' => (object) $appParams, + ]; + } + + private function getLanguage(LeadCapture $leadCapture): Language + { + $language = $this->defaultLanguage; + + if ($leadCapture->getFormLanguage()) { + $language = $this->languageFactory->create($leadCapture->getFormLanguage()); + } + + return $language; + } + + private function getCaptchaKey(LeadCapture $leadCapture): ?string + { + if (!$leadCapture->hasFormCaptcha()) { + return null; + } + + $entity = $this->entityManager + ->getRepositoryByClass(Integration::class) + ->getById('GoogleReCaptcha'); + + if (!$entity) { + return null; + } + + $siteKey = $entity->get('siteKey'); + + if (!$siteKey) { + return null; + } + + return $siteKey; + } + + private function getCaptchaScript(?string $siteKey): ?string + { + if (!$siteKey) { + return null; + } + + return 'https://www.google.com/recaptcha/api.js?data-theme=dark&render=' . $siteKey; + } +} diff --git a/application/Espo/Tools/LeadCapture/Service.php b/application/Espo/Tools/LeadCapture/Service.php index d81b2044fe..de68ee31b6 100644 --- a/application/Espo/Tools/LeadCapture/Service.php +++ b/application/Espo/Tools/LeadCapture/Service.php @@ -37,7 +37,6 @@ use Espo\Core\Utils\Util; use Espo\Entities\InboundEmail; use Espo\Entities\LeadCapture as LeadCaptureEntity; use Espo\Entities\User; -use Espo\ORM\Entity; use Espo\ORM\EntityManager; use stdClass; @@ -71,9 +70,9 @@ class Service * @throws NotFound * @throws Forbidden */ - public function generateNewApiKeyForEntity(string $id): Entity + public function generateNewApiKeyForEntity(string $id): LeadCaptureEntity { - $service = $this->recordServiceContainer->get(LeadCaptureEntity::ENTITY_TYPE); + $service = $this->recordServiceContainer->getByClass(LeadCaptureEntity::class); $entity = $service->getEntity($id); @@ -81,7 +80,31 @@ class Service throw new NotFound(); } - $entity->set('apiKey', $this->generateApiKey()); + $entity->setApiKey($this->generateApiKey()); + + $this->entityManager->saveEntity($entity); + + $service->prepareEntityForOutput($entity); + + return $entity; + } + + /** + * @throws ForbiddenSilent + * @throws NotFound + * @throws Forbidden + */ + public function generateNewFormIdForEntity(string $id): LeadCaptureEntity + { + $service = $this->recordServiceContainer->getByClass(LeadCaptureEntity::class); + + $entity = $service->getEntity($id); + + if (!$entity) { + throw new NotFound(); + } + + $entity->setFormId($this->generateFormId()); $this->entityManager->saveEntity($entity); @@ -95,6 +118,11 @@ class Service return Util::generateApiKey(); } + public function generateFormId(): string + { + return Util::generateId(); + } + /** * @return stdClass[] * @throws Forbidden diff --git a/client/res/templates/fields/email/edit.tpl b/client/res/templates/fields/email/edit.tpl index 962e7751c4..873b312cc1 100644 --- a/client/res/templates/fields/email/edit.tpl +++ b/client/res/templates/fields/email/edit.tpl @@ -1,13 +1,13 @@ -
{{#each emailAddressData}} - - +{{#unless onlyPrimary}} + +{{/unless}} diff --git a/client/res/templates/fields/phone/edit.tpl b/client/res/templates/fields/phone/edit.tpl index 78d947db4f..022c010e9c 100644 --- a/client/res/templates/fields/phone/edit.tpl +++ b/client/res/templates/fields/phone/edit.tpl @@ -1,13 +1,15 @@
{{#each phoneNumberData}} -
+
+ {{#unless ../onlyPrimary}} - + {{/unless}} + + {{#unless ../onlyPrimary}} + {{/unless}}
{{/each}}
- +{{#unless onlyPrimary}} + +{{/unless}} diff --git a/client/src/controllers/lead-capture-form.js b/client/src/controllers/lead-capture-form.js new file mode 100644 index 0000000000..4b3eecac90 --- /dev/null +++ b/client/src/controllers/lead-capture-form.js @@ -0,0 +1,59 @@ +/************************************************************************ + * 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 . + * + * 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 Controller from 'controller'; +import LeadCaptureFormView from 'views/lead-capture/form'; + +// noinspection JSUnusedGlobalSymbols +export default class LeadCaptureFormController extends Controller { + + // noinspection JSUnusedGlobalSymbols + actionShow(data) { + this.prepareContainer(); + + const view = new LeadCaptureFormView({formData: data}); + view.setSelector('body > .content'); + + this.viewFactory.prepare(view, () => view.render()); + } + + /** + * @private + */ + prepareContainer() { + // Prevents recaptcha removal. + + const existingContainer = document.body.querySelector('.container'); + existingContainer.remove(); + + const container = document.createElement('div'); + container.classList.add('container', 'content'); + + document.body.prepend(container); + } +} diff --git a/client/src/language.js b/client/src/language.js index 7bc03590b2..4d147bdee5 100644 --- a/client/src/language.js +++ b/client/src/language.js @@ -317,6 +317,17 @@ class Language { return pointer; } + + /** + * Do not use. + * + * @param {string} [scope] + * @param {Record} [data] + * @internal + */ + setScopeData(scope, data) { + this.data[scope] = data; + } } Object.assign(Language.prototype, Events); diff --git a/client/src/metadata.js b/client/src/metadata.js index 0d4c213107..d74d2ee19c 100644 --- a/client/src/metadata.js +++ b/client/src/metadata.js @@ -259,6 +259,16 @@ class Metadata { return scopeList; } + + /** + * Do not use. + * + * @internal + * @param {Record} data + */ + setData(data) { + this.data = data; + } } Object.assign(Metadata.prototype, Events); diff --git a/client/src/views/fields/array.js b/client/src/views/fields/array.js index 8425bfae5a..37bb9fb21c 100644 --- a/client/src/views/fields/array.js +++ b/client/src/views/fields/array.js @@ -613,6 +613,11 @@ class ArrayFieldView extends BaseFieldView { return list.join(', '); } + /** + * @protected + * @param {string} value + * @return {string} + */ getItemHtml(value) { // Do not use the `html` method to avoid XSS. diff --git a/client/src/views/fields/email.js b/client/src/views/fields/email.js index ccf6d74d57..db3e9b5258 100644 --- a/client/src/views/fields/email.js +++ b/client/src/views/fields/email.js @@ -49,6 +49,7 @@ class EmailFieldView extends VarcharFieldView { /** * @typedef {Object} module:views/fields/email~params * @property {boolean} [required] Required. + * @property {boolean} [onlyPrimary] Only primary. */ /** @@ -270,7 +271,7 @@ class EmailFieldView extends VarcharFieldView { validateMaxCount() { /** @type {number|null} */ - const maxCount = this.getConfig().get('emailAddressMaxCount'); + const maxCount = this.maxCount; if (!maxCount) { return false; @@ -355,6 +356,7 @@ class EmailFieldView extends VarcharFieldView { } data.itemMaxLength = this.itemMaxLength; + data.onlyPrimary = this.params.onlyPrimary; return data; } @@ -610,6 +612,7 @@ class EmailFieldView extends VarcharFieldView { this.erasedPlaceholder = 'ERASED:'; this.emailAddressOptedOutByDefault = this.getConfig().get('emailAddressIsOptedOutByDefault'); + this.maxCount = this.getConfig().get('emailAddressMaxCount'); this.itemMaxLength = this.getMetadata() .get(['entityDefs', 'EmailAddress', 'fields', 'name', 'maxLength']) || 255; @@ -617,6 +620,15 @@ class EmailFieldView extends VarcharFieldView { this.validations.push(() => this.validateMaxCount()); } + /** + * @return {{ + * emailAddress: string, + * primary: boolean, + * optOut: boolean, + * invalid: boolean, + * lower: string, + * }[]} + */ fetchEmailAddressData() { const data = []; @@ -649,7 +661,27 @@ class EmailFieldView extends VarcharFieldView { fetch() { const data = {}; - const addressData = this.fetchEmailAddressData() || []; + const addressData = this.fetchEmailAddressData(); + + if (this.params.onlyPrimary) { + if (addressData.length > 0) { + data[this.name] = addressData[0].emailAddress; + + data[this.dataFieldName] = [ + { + emailAddress: addressData[0].emailAddress, + lower: addressData[0].lower, + primary: true, + } + ]; + } else { + data[this.name] = null; + data[this.dataFieldName] = null; + + } + + return data; + } data[this.dataFieldName] = addressData; data[this.name] = null; diff --git a/client/src/views/fields/phone.js b/client/src/views/fields/phone.js index 27ae7f30fc..1d9ae423a2 100644 --- a/client/src/views/fields/phone.js +++ b/client/src/views/fields/phone.js @@ -53,6 +53,7 @@ class PhoneFieldView extends VarcharFieldView { /** * @typedef {Object} module:views/fields/phone~params * @property {boolean} [required] Required. + * @property {boolean} [onlyPrimary] Only primary. */ /** @@ -266,7 +267,7 @@ class PhoneFieldView extends VarcharFieldView { validateMaxCount() { /** @type {number|null} */ - const maxCount = this.getConfig().get('phoneNumberMaxCount'); + const maxCount = this.maxCount; if (!maxCount) { return false; @@ -453,6 +454,7 @@ class PhoneFieldView extends VarcharFieldView { } data.itemMaxLength = this.itemMaxLength; + data.onlyPrimary = this.params.onlyPrimary; // noinspection JSValidateTypes return data; @@ -655,6 +657,7 @@ class PhoneFieldView extends VarcharFieldView { this.useInternational = this.getConfig().get('phoneNumberInternational') || false; this.allowExtensions = this.getConfig().get('phoneNumberExtensions') || false; this.preferredCountryList = this.getConfig().get('phoneNumberPreferredCountryList') || []; + this.maxCount = this.getConfig().get('phoneNumberMaxCount'); if (this.useInternational && !this.isListMode() && !this.isSearchMode()) { this._codeNames = intlTelInputGlobals.getCountryData() @@ -754,7 +757,25 @@ class PhoneFieldView extends VarcharFieldView { fetch() { const data = {}; - const addressData = this.fetchPhoneNumberData() || []; + const addressData = this.fetchPhoneNumberData(); + + if (this.params.onlyPrimary) { + if (addressData.length > 0) { + data[this.name] = addressData[0].phoneNumber; + + data[this.dataFieldName] = [ + { + phoneNumber: addressData[0].phoneNumber, + primary: true, + } + ]; + } else { + data[this.name] = null; + data[this.dataFieldName] = null; + } + + return data; + } data[this.dataFieldName] = addressData; data[this.name] = null; diff --git a/client/src/views/lead-capture/fields/field-list.js b/client/src/views/lead-capture/fields/field-list.js index f34cd4d71b..e6b76d63b4 100644 --- a/client/src/views/lead-capture/fields/field-list.js +++ b/client/src/views/lead-capture/fields/field-list.js @@ -26,9 +26,74 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -import MultiEnumFieldView from 'views/fields/multi-enum'; +import ArrayFieldView from 'views/fields/array'; -export default class extends MultiEnumFieldView { +export default class extends ArrayFieldView { + + // language=Handlebars + detailTemplateContent = ` + {{#unless isEmpty}} + {{#each items}} +
{{label}}{{#if required}} *{{/if}}
+ {{/each}} + {{else}} + {{#if valueIsSet}}{{translate 'None'}} + {{else}} + + {{/if}} + {{/unless}} + ` + + /** + * @private + * @type {string[]} + */ + webFormNotAllowedFields + + data() { + /** @type {string[]|null} */ + const items = this.model.get(this.name); + + if (!items) { + return super.data(); + } + + const dataItems = items.map(it => { + return { + label: this.translatedOptions[it] || it, + strikeThrough: this.model.attributes.formEnabled && this.webFormNotAllowedFields.includes(it), + required: this.isFieldRequired(it), + }; + }) + + return { + ...super.data(), + items: dataItems, + }; + } + + getAttributeList() { + return [...super.getAttributeList(), 'formEnabled', 'fieldParams']; + } + + setup() { + this.webFormNotAllowedFields = []; + + super.setup(); + + this.listenTo(this.model, 'change:formEnabled', (m, v, o) => { + if (!o.ui || !this.isDetailMode()) { + return; + } + + this.reRender(); + }); + + this.addActionHandler('toggleRequired', (e, target) => this.toggleRequired(target.dataset.value)); + } setupOptions() { this.params.options = []; @@ -39,7 +104,11 @@ export default class extends MultiEnumFieldView { /** @type {string[]} */ const ignoreFieldList = this.getMetadata() - .get(['entityDefs', 'LeadCapture', 'fields', 'fieldList', 'ignoreFieldList']) || []; + .get(`entityDefs.LeadCapture.fields.fieldList.ignoreFieldList`) || []; + + /** @type {string[]} */ + const webFormTypeList = this.getMetadata() + .get(`entityDefs.LeadCapture.fields.fieldList.webFormFieldTypeList`) || []; for (const field in fields) { const defs = fields[field]; @@ -52,8 +121,107 @@ export default class extends MultiEnumFieldView { continue; } + if (!webFormTypeList.includes(defs.type)) { + this.webFormNotAllowedFields.push(field); + } + this.params.options.push(field); this.translatedOptions[field] = this.translate(field, 'fields', 'Lead'); } } + + getItemHtml(value) { + const html = super.getItemHtml(value); + + const div = document.createElement('div'); + div.innerHTML = html; + + /** @type {HTMLElement} */ + const item = div.querySelector('.list-group-item'); + + const group = document.createElement('div'); + group.classList.add('btn-group', 'pull-right'); + + const button = document.createElement('button'); + button.classList.add('btn', 'btn-link', 'btn-sm', 'dropdown-toggle'); + button.innerHTML = ``; + button.dataset.toggle = 'dropdown'; + button.type = 'button'; + + const ul = document.createElement('ul'); + ul.classList.add('dropdown-menu', 'pull-right'); + + const li = document.createElement('li'); + const a = document.createElement('a'); + a.dataset.value = value; + a.dataset.action = 'toggleRequired'; + + a.role = 'button'; + a.tabIndex = 0; + + if (this.isFieldRequired(value)) { + a.innerHTML += ``; + } + + const textDiv = document.createElement('div'); + textDiv.textContent = this.translate('required', 'fields', 'Admin'); + a.append(textDiv); + + li.append(a); + + ul.append(li); + + group.append(button, ul); + item.append(group); + + if (this.isFieldRequired(value)) { + const text = div.querySelector('.text'); + + if (text) { + text.innerHTML += ' *'; + } + } + + return div.innerHTML; + } + + /** + * @param {string} field + * @return {boolean} + */ + isFieldRequired(field) { + const params = this.model.attributes.fieldParams || {}; + const fieldParams = params[field] || {}; + + return !!fieldParams.required; + } + + /** + * @private + * @param {string} field + */ + toggleRequired(field) { + const params = Espo.Utils.cloneDeep(this.model.attributes.fieldParams || {}); + + if (!params[field]) { + params[field] = {}; + } + + if (!('required' in params[field])) { + params[field].required = false; + } + + params[field].required = !params[field].required; + + const newParams = {}; + + /** @type {string[]} */ + const fieldList = this.model.attributes.fieldList || []; + + fieldList.forEach(it => newParams[it] = params[it]); + + this.model.set('fieldParams', newParams, {ui: true}); + + this.reRender(); + } } diff --git a/client/src/views/lead-capture/form.js b/client/src/views/lead-capture/form.js new file mode 100644 index 0000000000..10074f62cf --- /dev/null +++ b/client/src/views/lead-capture/form.js @@ -0,0 +1,196 @@ +/************************************************************************ + * 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 . + * + * 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 View from 'view'; +import EditRecordView from 'views/record/edit'; +import Model from 'model'; + +export default class LeadCaptureFormView extends View { + + // language=Handlebars + templateContent = ` +
+ {{#if isPosted}} +
+
+
{{complexText successText}}
+
+
+ {{else}} + {{#if text}} +
+
+
{{complexText text}}
+
+
+ {{/if}} +
{{{record}}}
+ {{/if}} +
+ ` + + isPosted = false + + /** + * @param {{ + * formData: { + * requestUrl: string, + * detailLayout: module:views/record/detail~panelDefs[], + * fieldDefs: Record, + * metadata: Record, + * language: Record., + * successText: string, + * text: string|null, + * config: Record, + * appParams: Record, + * captchaKey: boolean, + * isDark: boolean, + * }, + * }} options + */ + constructor(options) { + super(); + + this.formData = options.formData; + } + + data() { + return { + isPosted: this.isPosted, + successText: this.formData.successText, + text: this.formData.text, + }; + } + + setup() { + this.getMetadata().setData(this.formData.metadata); + this.getConfig().setMultiple(this.formData.config); + this.getHelper().appParams.setAll(this.formData.appParams); + this.getHelper().fieldManager.defs = this.getMetadata().get('fields'); + + if (this.formData.captchaKey) { + // noinspection JSUnresolvedReference + grecaptcha.ready(() => { + // noinspection SpellCheckingInspection + /** @type {HTMLElement|null} */ + const badge = document.querySelector('.grecaptcha-badge'); + + if (badge) { + badge.style.zIndex = '4'; + } + }); + } + + for (const it in this.formData.language) { + this.getLanguage().setScopeData(it, this.formData.language[it]); + } + + this.model = new Model({}, { + defs: {fields: this.formData.fieldDefs}, + entityType: 'Lead', + }); + + this.model.url = this.formData.requestUrl; + + this.recordView = new EditRecordView({ + model: this.model, + detailLayout: this.formData.detailLayout, + buttonList: [ + { + name: 'save', + text: this.translate('Submit'), + style: 'primary', + onClick: () => this.actionCreate(), + } + ], + sideView: null, + bottomView: null, + isWide: true, + shortcutKeysEnabled: true, + }); + + this.assignView('record', this.recordView, '.record'); + } + + async actionCreate() { + if (this.recordView.validate()) { + Espo.Ui.error(this.translate('Not valid')); + + return; + } + + this.recordView.disableActionItems(); + + Espo.Ui.notify(' ... ') + + const token = await this.processCaptcha(); + + const headers = token ? {'X-Captcha-Token': token} : undefined; + + /** @type {{redirectUrl: string|null}} */ + let result; + + try { + result = await Espo.Ajax.postRequest(this.model.url, this.model.attributes, {headers: headers}); + } catch (e) { + this.recordView.enableActionItems(); + + return; + } + + Espo.Ui.notify(); + this.isPosted = true; + + this.recordView.remove(); + + await this.reRender(); + + if (result.redirectUrl) { + document.location.href = result.redirectUrl; + } + } + + /** + * @return {Promise} + */ + async processCaptcha() { + if (!this.formData.captchaKey) { + return null; + } + + return new Promise(resolve => { + // noinspection JSUnresolvedReference + grecaptcha.ready(async () => { + // noinspection JSUnresolvedReference + const token = await grecaptcha.execute(this.formData.captchaKey, {action: 'leadCaptureSubmit'}); + + resolve(token); + }); + }) + } +} diff --git a/client/src/views/lead-capture/record/detail.js b/client/src/views/lead-capture/record/detail.js index 10493542f1..1a6bc5aea5 100644 --- a/client/src/views/lead-capture/record/detail.js +++ b/client/src/views/lead-capture/record/detail.js @@ -38,6 +38,12 @@ export default class extends DetailRecordView { name: 'generateNewApiKey', onClick: () => this.actionGenerateNewApiKey(), }); + + this.addDropdownItem({ + label: 'Generate New Form ID', + name: 'generateNewFormId', + onClick: () => this.actionGenerateNewFormId(), + }); } actionGenerateNewApiKey() { @@ -45,7 +51,19 @@ export default class extends DetailRecordView { Espo.Ajax.postRequest('LeadCapture/action/generateNewApiKey', {id: this.model.id}) .then(data => { this.model.set(data); + + Espo.Ui.success(this.translate('Done')); }); }); } + + async actionGenerateNewFormId() { + await this.confirm(this.translate('confirmation', 'messages')); + + const data = await Espo.Ajax.postRequest('LeadCapture/action/generateNewFormId', {id: this.model.id}); + + this.model.set(data); + + Espo.Ui.success(this.translate('Done')); + } } diff --git a/client/src/views/lead-capture/record/panels/form.js b/client/src/views/lead-capture/record/panels/form.js new file mode 100644 index 0000000000..bee3222f4e --- /dev/null +++ b/client/src/views/lead-capture/record/panels/form.js @@ -0,0 +1,36 @@ +/************************************************************************ + * 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 . + * + * 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 SidePanelView from 'views/record/panels/side'; + +export default class extends SidePanelView { + + fieldList = [ + 'formUrl' + ] +} diff --git a/client/src/views/record/detail.js b/client/src/views/record/detail.js index e48d3827a2..cbc020f6d9 100644 --- a/client/src/views/record/detail.js +++ b/client/src/views/record/detail.js @@ -52,8 +52,8 @@ class DetailRecordView extends BaseRecordView { * @property {boolean} [editModeDisabled] * @property {boolean} [confirmLeaveDisabled] * @property {boolean} [isWide] - * @property {string} [sideView] - * @property {string} [bottomView] + * @property {string|null} [sideView] + * @property {string|null} [bottomView] * @property {string} [inlineEditDisabled] Disable inline edit. * @property {boolean} [buttonsDisabled] Disable buttons. * @property {string} [navigateButtonsDisabled] @@ -64,6 +64,7 @@ class DetailRecordView extends BaseRecordView { * @property {module:views/record/detail~dropdownItem[]} [dropdownItemList] Dropdown items. * @property {Object.} [dataObject] Additional data. * @property {Record} [rootData] Data from the root view. + * @property {boolean} [shortcutKeysEnabled] Enable shortcut keys. */ /** diff --git a/frontend/less/espo/custom.less b/frontend/less/espo/custom.less index 7aba9c63c7..455c6805a2 100644 --- a/frontend/less/espo/custom.less +++ b/frontend/less/espo/custom.less @@ -3858,6 +3858,18 @@ body:has(.modal-backdrop) { } } +.email-address-block.only-primary, +.phone-number-block.only-primary { + input.form-control { + border-top-left-radius: var(--border-radius); + border-top-right-radius: var(--border-radius); + border-bottom-left-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); + } + + display: block; +} + .input-group-currency { display: flex; @@ -3925,7 +3937,7 @@ body > .autocomplete-suggestions.text-search-suggestions { } } -.input-group-item > .iti { +.input-phone-number-item > .iti { //top: 3px; position: unset; display: block; diff --git a/frontend/less/espo/layout-top.less b/frontend/less/espo/layout-top.less index c468769cf5..fd445c02e9 100644 --- a/frontend/less/espo/layout-top.less +++ b/frontend/less/espo/layout-top.less @@ -390,11 +390,13 @@ body:not([data-navbar="side"]) { } } - .stick-sub { - margin-top: var(--navbar-height); + &:has(> #header > #navbar) { + .stick-sub { + margin-top: var(--navbar-height); - @media screen and (max-width: @screen-xs-max) { - margin-top: 0; + @media screen and (max-width: @screen-xs-max) { + margin-top: 0; + } } }