diff --git a/application/Espo/Classes/FieldProcessing/Portal/UrlLoader.php b/application/Espo/Classes/FieldProcessing/Portal/UrlLoader.php index 46e024ba37..b2b0c84f15 100644 --- a/application/Espo/Classes/FieldProcessing/Portal/UrlLoader.php +++ b/application/Espo/Classes/FieldProcessing/Portal/UrlLoader.php @@ -32,6 +32,7 @@ namespace Espo\Classes\FieldProcessing\Portal; use Espo\ORM\Entity; use Espo\Repositories\Portal as PortalRepository; +use Espo\Entities\Portal; use Espo\Core\{ FieldProcessing\Loader, @@ -50,6 +51,8 @@ class UrlLoader implements Loader public function process(Entity $entity, Params $params): void { + /** @var Portal $entity */ + $this->getPortalRepository()->loadUrlField($entity); } diff --git a/application/Espo/Controllers/User.php b/application/Espo/Controllers/User.php index 81279fec5f..38a4429956 100644 --- a/application/Espo/Controllers/User.php +++ b/application/Espo/Controllers/User.php @@ -156,6 +156,23 @@ class User extends Record return true; } + public function postActionSendPasswordChangeLink(Request $request): bool + { + if (!$this->user->isAdmin()) { + throw new Forbidden(); + } + + $id = $request->getParsedBody()->id ?? null; + + if (!$id) { + throw new BadRequest(); + } + + $this->getUserService()->sendPasswordChangeLink($id); + + return true; + } + public function postActionCreateLink(Request $request): bool { if (!$this->user->isAdmin()) { diff --git a/application/Espo/Core/Password/Recovery.php b/application/Espo/Core/Password/Recovery.php index d22e957233..27eb682a39 100644 --- a/application/Espo/Core/Password/Recovery.php +++ b/application/Espo/Core/Password/Recovery.php @@ -32,11 +32,16 @@ namespace Espo\Core\Password; use Espo\Core\Utils\Util; use Espo\Entities\User; use Espo\Entities\PasswordChangeRequest; +use Espo\Entities\Portal; + +use Espo\Repositories\Portal as PortalRepository; use Espo\Core\Exceptions\Forbidden; use Espo\Core\Exceptions\NotFound; use Espo\Core\Exceptions\Error; +use Espo\Core\Field\DateTime; + use Espo\Core\{ ORM\EntityManager, Utils\Config, @@ -47,46 +52,30 @@ use Espo\Core\{ Job\QueueName, }; -use DateTime; - class Recovery { /** * Milliseconds. */ - const REQUEST_DELAY = 3000; + private const REQUEST_DELAY = 3000; - const REQUEST_LIFETIME = '3 hours'; + private const REQUEST_LIFETIME = '3 hours'; - /** - * @var EntityManager - */ - protected $entityManager; + private const NEW_USER_REQUEST_LIFETIME = '2 days'; - /** - * @var Config - */ - protected $config; + private const EXISTING_USER_REQUEST_LIFETIME = '2 days'; - /** - * @var EmailSender - */ - protected $emailSender; + protected EntityManager $entityManager; - /** - * @var HtmlizerFactory - */ - protected $htmlizerFactory; + protected Config $config; - /** - * @var TemplateFileManager - */ - protected $templateFileManager; + protected EmailSender $emailSender; - /** - * @var Log - */ - private $log; + protected HtmlizerFactory $htmlizerFactory; + + protected TemplateFileManager $templateFileManager; + + private Log $log; public function __construct( EntityManager $entityManager, @@ -107,13 +96,12 @@ class Recovery public function getRequest(string $id): PasswordChangeRequest { $config = $this->config; - $em = $this->entityManager; if ($config->get('passwordRecoveryDisabled')) { throw new Forbidden("Password recovery: Disabled."); } - $request = $em + $request = $this->entityManager ->getRDBRepository('PasswordChangeRequest') ->where([ 'requestId' => $id, @@ -132,11 +120,9 @@ class Recovery return $request; } - public function removeRequest(string $id) + public function removeRequest(string $id): void { - $em = $this->entityManager; - - $request = $em + $request = $this->entityManager ->getRDBRepository('PasswordChangeRequest') ->where([ 'requestId' => $id, @@ -144,14 +130,13 @@ class Recovery ->findOne(); if ($request) { - $em->removeEntity($request); + $this->entityManager->removeEntity($request); } } public function request(string $emailAddress, string $userName, ?string $url): bool { $config = $this->config; - $em = $this->entityManager; $noExposure = $config->get('passwordRecoveryNoExposure') ?? false; @@ -159,8 +144,8 @@ class Recovery throw new Forbidden("Password recovery: Disabled."); } - $user = $em - ->getRDBRepository('User') + $user = $this->entityManager + ->getRDBRepository('User') ->where([ 'userName' => $userName, 'emailAddress' => $emailAddress, @@ -213,14 +198,14 @@ class Recovery return false; } - $passwordChangeRequest = $em + $existingRequest = $this->entityManager ->getRDBRepository('PasswordChangeRequest') ->where([ 'userId' => $user->getId(), ]) ->findOne(); - if ($passwordChangeRequest) { + if ($existingRequest) { if (!$noExposure) { throw new Forbidden(json_encode(['reason' => 'Already-Sent'])); } @@ -230,39 +215,17 @@ class Recovery return false; } - $requestId = Util::generateCryptId(); - - $passwordChangeRequest = $em->getEntity('PasswordChangeRequest'); - - $passwordChangeRequest->set([ - 'userId' => $user->id, - 'requestId' => $requestId, - 'url' => $url, - ]); + $request = $this->createRequestNoSave($user, $url); $microtime = microtime(true); - $this->send($requestId, $emailAddress, $user); + $this->send($request->getRequestId(), $emailAddress, $user); - $em->saveEntity($passwordChangeRequest); - - if (!$passwordChangeRequest->id) { - throw new Error(); - } + $this->entityManager->saveEntity($request); $lifetime = $config->get('passwordRecoveryRequestLifetime') ?? self::REQUEST_LIFETIME; - $dt = new DateTime(); - - $dt->modify('+' . $lifetime); - - $em->createEntity('Job', [ - 'serviceName' => 'User', - 'methodName' => 'removeChangePasswordRequestJob', - 'data' => ['id' => $passwordChangeRequest->id], - 'executeTime' => $dt->format('Y-m-d H:i:s'), - 'queue' => QueueName::Q1, - ]); + $this->createCleanupRequestJob($request->getId(), $lifetime); $timeDiff = $this->getDelay() - floor((microtime(true) - $microtime) / 1000); @@ -273,45 +236,104 @@ class Recovery return true; } - private function getDelay() + private function createRequestNoSave(User $user, ?string $url = null): PasswordChangeRequest + { + $entity = $this->entityManager->getNewEntity(PasswordChangeRequest::ENTITY_TYPE); + + $entity->set([ + 'userId' => $user->getId(), + 'requestId' => Util::generateCryptId(), + 'url' => $url, + ]); + + return $entity; + } + + public function createRequestForNewUser(User $user, ?string $url = null): PasswordChangeRequest + { + $entity = $this->createRequestNoSave($user, $url); + + $this->entityManager->saveEntity($entity); + + $lifetime = $this->config->get('passwordChangeRequestNewUserLifetime') ?? self::NEW_USER_REQUEST_LIFETIME; + + $this->createCleanupRequestJob($entity->getId(), $lifetime); + + return $entity; + } + + public function createAndSendRequestForExistingUser(User $user, ?string $url = null): PasswordChangeRequest + { + if (!$user->getEmailAddressGroup()->getPrimary()) { + throw new Error("No email address."); + } + + $emailAddress = $user->getEmailAddressGroup()->getPrimary()->getAddress(); + + $entity = $this->createRequestNoSave($user, $url); + + $this->entityManager->saveEntity($entity); + + $lifetime = $this->config->get('passwordChangeRequestExistingUserLifetime') ?? + self::EXISTING_USER_REQUEST_LIFETIME; + + $this->createCleanupRequestJob($entity->getId(), $lifetime); + + $this->send($entity->getRequestId(), $emailAddress, $user); + + return $entity; + } + + private function createCleanupRequestJob(string $id, string $lifetime): void + { + $this->entityManager->createEntity('Job', [ + 'serviceName' => 'User', + 'methodName' => 'removeChangePasswordRequestJob', + 'data' => ['id' => $id], + 'executeTime' => DateTime::createNow() + ->modify('+' . $lifetime) + ->getString(), + 'queue' => QueueName::Q1, + ]); + } + + private function getDelay(): int { return $this->config->get('passwordRecoveryRequestDelay') ?? self::REQUEST_DELAY; } - protected function delay(?int $delay = null) + protected function delay(?int $delay = null): void { - $delay = $delay ?? $this->getDelay(); + if ($delay === null) { + $delay = $this->getDelay(); + } usleep($delay * 1000); } - protected function send(string $requestId, string $emailAddress, User $user) + protected function send(string $requestId, string $emailAddress, User $user): void { - $config = $this->config; - $em = $this->entityManager; - $htmlizerFactory = $this->htmlizerFactory; - - $templateFileManager = $this->templateFileManager; - if (!$emailAddress) { return; } - $email = $em->getEntity('Email'); + $email = $this->entityManager->getEntity('Email'); - if (!$this->emailSender->hasSystemSmtp() && !$config->get('internalSmtpServer')) { + if (!$this->emailSender->hasSystemSmtp() && !$this->config->get('internalSmtpServer')) { throw new Error("Password recovery: SMTP credentials are not defined."); } $sender = $this->emailSender->create(); - $subjectTpl = $templateFileManager->getTemplate('passwordChangeLink', 'subject', 'User'); - $bodyTpl = $templateFileManager->getTemplate('passwordChangeLink', 'body', 'User'); + $subjectTpl = $this->templateFileManager->getTemplate('passwordChangeLink', 'subject', 'User'); + $bodyTpl = $this->templateFileManager->getTemplate('passwordChangeLink', 'body', 'User'); - $siteUrl = $config->getSiteUrl(); + $siteUrl = $this->config->getSiteUrl(); if ($user->isPortal()) { - $portal = $em->getRDBRepository('Portal') + /** @var Portal|null $portal */ + $portal = $this->entityManager + ->getRDBRepository(Portal::ENTITY_TYPE) ->distinct() ->join('users') ->where([ @@ -320,10 +342,16 @@ class Recovery ]) ->findOne(); - if ($portal) { - if ($portal->get('customUrl')) { - $siteUrl = $portal->get('customUrl'); - } + if (!$portal) { + throw new Error("Portal user does not belong to any potral."); + } + + $this->getPortalRepository()->loadUrlField($portal); + + $siteUrl = $portal->getUrl(); + + if (!$siteUrl) { + throw new Error("Portal does not have URL."); } } @@ -333,7 +361,7 @@ class Recovery $data['link'] = $link; - $htmlizer = $htmlizerFactory->create(true); + $htmlizer = $this->htmlizerFactory->create(true); $subject = $htmlizer->render($user, $subjectTpl, null, $data, true); $body = $htmlizer->render($user, $bodyTpl, null, $data, true); @@ -347,15 +375,15 @@ class Recovery if (!$this->emailSender->hasSystemSmtp()) { $sender->withSmtpParams([ - 'server' => $config->get('internalSmtpServer'), - 'port' => $config->get('internalSmtpPort'), - 'auth' => $config->get('internalSmtpAuth'), - 'username' => $config->get('internalSmtpUsername'), - 'password' => $config->get('internalSmtpPassword'), - 'security' => $config->get('internalSmtpSecurity'), - 'fromAddress' => $config->get( + 'server' => $this->config->get('internalSmtpServer'), + 'port' => $this->config->get('internalSmtpPort'), + 'auth' => $this->config->get('internalSmtpAuth'), + 'username' => $this->config->get('internalSmtpUsername'), + 'password' => $this->config->get('internalSmtpPassword'), + 'security' => $this->config->get('internalSmtpSecurity'), + 'fromAddress' => $this->config->get( 'internalOutboundEmailFromAddress', - $config->get('outboundEmailFromAddress') + $this->config->get('outboundEmailFromAddress') ), ]); } @@ -363,11 +391,9 @@ class Recovery $sender->send($email); } - private function fail(?string $msg = null, int $errorCode = 403) + private function fail(?string $msg = null, int $errorCode = 403): void { - $config = $this->config; - - $noExposure = $config->get('passwordRecoveryNoExposure') ?? false; + $noExposure = $this->config->get('passwordRecoveryNoExposure') ?? false; if ($msg) { $this->log->warning($msg); @@ -377,14 +403,20 @@ class Recovery if ($errorCode === 403) { throw new Forbidden(); } - else if ($errorCode === 404) { + + if ($errorCode === 404) { throw new NotFound(); } - else { - throw new Error(); - } + + throw new Error(); } $this->delay(); } + + private function getPortalRepository(): PortalRepository + { + /** @var PortalRepository */ + return $this->entityManager->getRDBRepository(Portal::ENTITY_TYPE); + } } diff --git a/application/Espo/Core/Utils/Util.php b/application/Espo/Core/Utils/Util.php index 5676ce1bf9..1046e473ef 100644 --- a/application/Espo/Core/Utils/Util.php +++ b/application/Espo/Core/Utils/Util.php @@ -888,8 +888,13 @@ class Util return $url; } - public static function generatePassword(int $length = 8, int $letters = 5, int $numbers = 3, bool $bothCases = false) - { + public static function generatePassword( + int $length = 8, + int $letters = 5, + int $numbers = 3, + bool $bothCases = false + ): string { + $chars = [ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', '0123456789', @@ -900,28 +905,39 @@ class Util $shuffle = function ($array) { $currentIndex = count($array); + while (0 !== $currentIndex) { $rand = (0 + (1 - 0) * (mt_rand() / mt_getrandmax())); $randomIndex = intval(floor($rand * $currentIndex)); $currentIndex -= 1; $temporaryValue = $array[$currentIndex]; + $array[$currentIndex] = $array[$randomIndex]; $array[$randomIndex] = $temporaryValue; } + return $array; }; $upperCase = 0; $lowerCase = 0; + if ($bothCases) { $upperCase = 1; $lowerCase = 1; - if ($letters >= 2) $letters = $letters - 2; - else $letters = 0; + + if ($letters >= 2) { + $letters = $letters - 2; + } else { + $letters = 0; + } } $either = $length - ($letters + $numbers + $upperCase + $lowerCase); - if ($either < 0) $either = 0; + + if ($either < 0) { + $either = 0; + } $array = []; @@ -930,6 +946,7 @@ class Util $subArray = []; $j = 0; + while ($j < $len) { $rand = (0 + (1 - 0) * (mt_rand() / mt_getrandmax())); $index = intval(floor($rand * strlen($set))); diff --git a/application/Espo/Entities/PasswordChangeRequest.php b/application/Espo/Entities/PasswordChangeRequest.php index 5fdacbcff4..ee3360e89f 100644 --- a/application/Espo/Entities/PasswordChangeRequest.php +++ b/application/Espo/Entities/PasswordChangeRequest.php @@ -32,4 +32,9 @@ namespace Espo\Entities; class PasswordChangeRequest extends \Espo\Core\ORM\Entity { public const ENTITY_TYPE = 'PasswordChangeRequest'; + + public function getRequestId(): string + { + return $this->get('requestId'); + } } diff --git a/application/Espo/Entities/Portal.php b/application/Espo/Entities/Portal.php index dcdce32aa0..d27bf11926 100644 --- a/application/Espo/Entities/Portal.php +++ b/application/Espo/Entities/Portal.php @@ -52,4 +52,9 @@ class Portal extends \Espo\Core\ORM\Entity { return $this->settingsAttributeList; } + + public function getUrl(): ?string + { + return $this->get('url'); + } } diff --git a/application/Espo/EntryPoints/ChangePassword.php b/application/Espo/EntryPoints/ChangePassword.php index 44beee24f3..8f858d95e1 100644 --- a/application/Espo/EntryPoints/ChangePassword.php +++ b/application/Espo/EntryPoints/ChangePassword.php @@ -29,31 +29,29 @@ namespace Espo\EntryPoints; -use Espo\Core\Exceptions\NotFound; use Espo\Core\Exceptions\BadRequest; -use Espo\Core\{ - EntryPoint\EntryPoint, - EntryPoint\Traits\NoAuth, - Utils\Config, - Utils\ClientManager, - ORM\EntityManager, - Api\Request, - Api\Response, -}; +use Espo\Entities\PasswordChangeRequest; + +use Espo\Core\EntryPoint\EntryPoint; +use Espo\Core\EntryPoint\Traits\NoAuth; +use Espo\Core\Utils\Config; +use Espo\Core\Utils\ClientManager; + +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; + +use Espo\ORM\EntityManager; class ChangePassword implements EntryPoint { use NoAuth; - /** @var Config */ - protected $config; + private Config $config; - /** @var ClientManager */ - protected $clientManager; + private ClientManager $clientManager; - /** @var EntityManager */ - protected $entityManager; + private EntityManager $entityManager; public function __construct(Config $config, ClientManager $clientManager, EntityManager $entityManager) { @@ -71,34 +69,36 @@ class ChangePassword implements EntryPoint } $passwordChangeRequest = $this->entityManager - ->getRDBRepository('PasswordChangeRequest') + ->getRDBRepository(PasswordChangeRequest::ENTITY_TYPE) ->where([ 'requestId' => $requestId, ]) ->findOne(); $strengthParams = [ + 'passwordGenerateLength' => $this->config->get('passwordGenerateLength'), + 'passwordGenerateLetterCount' => $this->config->get('passwordGenerateLetterCount'), + 'generateNumberCount' => $this->config->get('generateNumberCount'), 'passwordStrengthLength' => $this->config->get('passwordStrengthLength'), 'passwordStrengthLetterCount' => $this->config->get('passwordStrengthLetterCount'), 'passwordStrengthNumberCount' => $this->config->get('passwordStrengthNumberCount'), 'passwordStrengthBothCases' => $this->config->get('passwordStrengthBothCases'), ]; - if (!$passwordChangeRequest) { - throw new NotFound(); - } - $options = [ 'id' => $requestId, 'strengthParams' => $strengthParams, + 'notFound' => !$passwordChangeRequest, ]; $runScript = " - app.getController('PasswordChangeRequest', function (controller) { - controller.doAction('passwordChange', ".json_encode($options)."); + app.getController('PasswordChangeRequest', controller => { + controller.doAction('passwordChange', " . json_encode($options) . "); }); "; - $this->clientManager->display($runScript); + $response->writeBody( + $this->clientManager->render($runScript) + ); } } diff --git a/application/Espo/Repositories/Portal.php b/application/Espo/Repositories/Portal.php index efdcf8d3e1..de032b8ee1 100644 --- a/application/Espo/Repositories/Portal.php +++ b/application/Espo/Repositories/Portal.php @@ -29,7 +29,7 @@ namespace Espo\Repositories; -use Espo\ORM\Entity; +use Espo\Entities\Portal as PortalEntity; use Espo\Core\Repositories\Database; @@ -41,7 +41,7 @@ class Portal extends Database implements { use Di\ConfigSetter; - public function loadUrlField(Entity $entity) + public function loadUrlField(PortalEntity $entity): void { if ($entity->get('customUrl')) { $entity->set('url', $entity->get('customUrl')); diff --git a/application/Espo/Resources/i18n/en_US/User.json b/application/Espo/Resources/i18n/en_US/User.json index ca4aad54f3..7027f81263 100644 --- a/application/Espo/Resources/i18n/en_US/User.json +++ b/application/Espo/Resources/i18n/en_US/User.json @@ -72,6 +72,7 @@ "Proceed w/o Contact": "Proceed w/o Contact", "Generate New API Key": "Generate New API Key", "Generate New Password": "Generate New Password", + "Send Password Change Link": "Send Password Change Link", "Back to login form": "Back to login form", "Requirements": "Requirements", "Security": "Security", @@ -91,6 +92,7 @@ "portals": "Portals which this user has access to." }, "messages": { + "sendPasswordChangeLinkConfirmation": "An email with a unique link will be sent to the user allowing them to change their password. The link will expire after a specific amount of time.", "passwordRecoverySentIfMatched": "Assuming the entered data matched any user account.", "passwordStrengthLength": "Must be at least {length} characters long.", "passwordStrengthLetterCount": "Must contain at least {count} letter(s).", @@ -120,7 +122,8 @@ "security2FaResetConfimation": "Are you sure you want to reset the current 2FA settings?", "auth2FARequiredHeader": "2 factor authentication required", "auth2FARequired": "You need to set up 2 factor authentication. Use an authenticator application on your mobile phone (e.g. Google Authenticator).", - "ldapUserInEspoNotFound": "User is not found in EspoCRM. Contact your administrator to create the user." + "ldapUserInEspoNotFound": "User is not found in EspoCRM. Contact your administrator to create the user.", + "passwordChangeRequestNotFound": "The password change request is not found. It might be expired. Try to initiate a new password recovery from the [login page]({url})." }, "options": { "gender": { diff --git a/application/Espo/Resources/templates/accessInfo/cs_CZ/body.tpl b/application/Espo/Resources/templates/accessInfo/cs_CZ/body.tpl index 3955acca33..85e1e7d678 100644 --- a/application/Espo/Resources/templates/accessInfo/cs_CZ/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/cs_CZ/body.tpl @@ -1,6 +1,6 @@

Vaše přístupové informace jsou

Přihlašovací jméno: {{userName}}

-

Heslo: {{password}}

+

{{#if password}}Heslo: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/da_DK/body.tpl b/application/Espo/Resources/templates/accessInfo/da_DK/body.tpl index 1a519584bd..cde2dda800 100644 --- a/application/Espo/Resources/templates/accessInfo/da_DK/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/da_DK/body.tpl @@ -1,6 +1,6 @@

Dine Logindetaljer

Brugernavn: {{userName}}

-

Kodeord: {{password}}

+

{{#if password}}Kodeord: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/de_DE/body.tpl b/application/Espo/Resources/templates/accessInfo/de_DE/body.tpl index 5ac61fd432..19fcbbab81 100644 --- a/application/Espo/Resources/templates/accessInfo/de_DE/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/de_DE/body.tpl @@ -1,6 +1,6 @@

Ihre EspoCRM Zugriffsinformation

Benutzername: {{userName}}

-

Passwort: {{password}}

+

{{#if password}}Passwort: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/en_US/body.tpl b/application/Espo/Resources/templates/accessInfo/en_US/body.tpl index 34bd1f5872..448f0ebe0c 100644 --- a/application/Espo/Resources/templates/accessInfo/en_US/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/en_US/body.tpl @@ -1,6 +1,6 @@

Your access information

Username: {{userName}}

-

Password: {{password}}

+

{{#if password}}Password: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/es_ES/body.tpl b/application/Espo/Resources/templates/accessInfo/es_ES/body.tpl index e74f8b2fa6..59bfbeb470 100644 --- a/application/Espo/Resources/templates/accessInfo/es_ES/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/es_ES/body.tpl @@ -1,6 +1,6 @@

Información de tu cuenta

Nombre Usuario: {{userName}}

-

Contraseña: {{password}}

+

{{#if password}}Contraseña: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/es_MX/body.tpl b/application/Espo/Resources/templates/accessInfo/es_MX/body.tpl index e74f8b2fa6..59bfbeb470 100644 --- a/application/Espo/Resources/templates/accessInfo/es_MX/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/es_MX/body.tpl @@ -1,6 +1,6 @@

Información de tu cuenta

Nombre Usuario: {{userName}}

-

Contraseña: {{password}}

+

{{#if password}}Contraseña: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/fr_FR/body.tpl b/application/Espo/Resources/templates/accessInfo/fr_FR/body.tpl index 89c46705dd..5036df7153 100644 --- a/application/Espo/Resources/templates/accessInfo/fr_FR/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/fr_FR/body.tpl @@ -1,6 +1,6 @@

Vos identifiants sont les suivants

Nom d'utilisateur: {{userName}}

-

Mot de passe: {{password}}

+

{{#if password}}Mot de passe: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/hr_HR/body.tpl b/application/Espo/Resources/templates/accessInfo/hr_HR/body.tpl index 67055f556a..dcf1dcc1e1 100644 --- a/application/Espo/Resources/templates/accessInfo/hr_HR/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/hr_HR/body.tpl @@ -1,6 +1,6 @@

Vaši podaci za pristup

Korisničko ime: {{userName}}

-

Lozinka: {{password}}

+

{{#if password}}Lozinka: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/hu_HU/body.tpl b/application/Espo/Resources/templates/accessInfo/hu_HU/body.tpl index f08b4b8eed..ad596d6f11 100644 --- a/application/Espo/Resources/templates/accessInfo/hu_HU/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/hu_HU/body.tpl @@ -1,6 +1,6 @@

A hozzáférési adatai

Felhasználónév: {{userName}}

-

Jelszó: {{password}}

+

{{#if password}}Jelszó: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/id_ID/body.tpl b/application/Espo/Resources/templates/accessInfo/id_ID/body.tpl index 6541dd2dd7..043246b031 100644 --- a/application/Espo/Resources/templates/accessInfo/id_ID/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/id_ID/body.tpl @@ -1,6 +1,6 @@

Akses informasi Anda

Username: {{userName}}

-

Password: {{password}}

+

{{#if password}}Password: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/it_IT/body.tpl b/application/Espo/Resources/templates/accessInfo/it_IT/body.tpl index 4eb84ff622..416d204e28 100644 --- a/application/Espo/Resources/templates/accessInfo/it_IT/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/it_IT/body.tpl @@ -1,6 +1,6 @@

I tuoi dati di accesso

Username: {{userName}}

-

Password: {{password}}

+

{{#if password}}Password: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/lt_LT/body.tpl b/application/Espo/Resources/templates/accessInfo/lt_LT/body.tpl index a27fe3528f..675cbc9f29 100644 --- a/application/Espo/Resources/templates/accessInfo/lt_LT/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/lt_LT/body.tpl @@ -1,6 +1,6 @@

Jūsų prisijungimo informacija

Vartotojo vardas: {{userName}}

-

Slaptažodis: {{password}}

+

{{#if password}}Slaptažodis: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/nb_NO/body.tpl b/application/Espo/Resources/templates/accessInfo/nb_NO/body.tpl index 2199f0a6cc..c1ee6eeac6 100644 --- a/application/Espo/Resources/templates/accessInfo/nb_NO/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/nb_NO/body.tpl @@ -1,6 +1,6 @@

Påloggingsinformasjon

Brukernavn: {{userName}}

-

Passord: {{password}}

+

{{#if password}}Passord: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/nl_NL/body.tpl b/application/Espo/Resources/templates/accessInfo/nl_NL/body.tpl index c9eea2520e..da266fdc10 100644 --- a/application/Espo/Resources/templates/accessInfo/nl_NL/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/nl_NL/body.tpl @@ -1,6 +1,6 @@

Uw gegevens

Gebruikersnaam: {{userName}}

-

Wachtwoord: {{password}}

+

{{#if password}}Wachtwoord: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/pl_PL/body.tpl b/application/Espo/Resources/templates/accessInfo/pl_PL/body.tpl index fa4e9ec0bf..c43edd3856 100644 --- a/application/Espo/Resources/templates/accessInfo/pl_PL/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/pl_PL/body.tpl @@ -1,6 +1,6 @@

Informacje o twoim koncie

Użytkownik: {{userName}}

-

Hasło: {{password}}

+

{{#if password}}Hasło: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/pt_BR/body.tpl b/application/Espo/Resources/templates/accessInfo/pt_BR/body.tpl index a6d72b6263..b9191dbf74 100644 --- a/application/Espo/Resources/templates/accessInfo/pt_BR/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/pt_BR/body.tpl @@ -1,6 +1,6 @@

Informações da sua conta

Usuário: {{userName}}

-

Senha: {{password}}

+

{{#if password}}Senha: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/ro_RO/body.tpl b/application/Espo/Resources/templates/accessInfo/ro_RO/body.tpl index b5a767ba50..796efd091a 100644 --- a/application/Espo/Resources/templates/accessInfo/ro_RO/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/ro_RO/body.tpl @@ -1,6 +1,6 @@

Informațiile tale de acces

Username: {{userName}}

-

Password: {{password}}

+

{{#if password}}Password: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/ru_RU/body.tpl b/application/Espo/Resources/templates/accessInfo/ru_RU/body.tpl index 7c14edd2bf..4a1e190fe7 100644 --- a/application/Espo/Resources/templates/accessInfo/ru_RU/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/ru_RU/body.tpl @@ -1,6 +1,6 @@

Информация о Вашей учетной записи

Имя пользователя: {{userName}}

-

Пароль: {{password}}

+

{{#if password}}Пароль: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/sk_SK/body.tpl b/application/Espo/Resources/templates/accessInfo/sk_SK/body.tpl index adddb0b047..586772f746 100644 --- a/application/Espo/Resources/templates/accessInfo/sk_SK/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/sk_SK/body.tpl @@ -1,6 +1,6 @@

Vaš prístupové informácie

Username: {{userName}}

-

Password: {{password}}

+

{{#if password}}Password: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/sr_RS/body.tpl b/application/Espo/Resources/templates/accessInfo/sr_RS/body.tpl index 67055f556a..dcf1dcc1e1 100644 --- a/application/Espo/Resources/templates/accessInfo/sr_RS/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/sr_RS/body.tpl @@ -1,6 +1,6 @@

Vaši podaci za pristup

Korisničko ime: {{userName}}

-

Lozinka: {{password}}

+

{{#if password}}Lozinka: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/tr_TR/body.tpl b/application/Espo/Resources/templates/accessInfo/tr_TR/body.tpl index fce17784aa..252f5b0fcd 100644 --- a/application/Espo/Resources/templates/accessInfo/tr_TR/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/tr_TR/body.tpl @@ -1,4 +1,4 @@

Username: {{userName}}

-

Password: {{password}}

+

{{#if password}}Password: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/uk_UA/body.tpl b/application/Espo/Resources/templates/accessInfo/uk_UA/body.tpl index d391b134ed..e53880e276 100644 --- a/application/Espo/Resources/templates/accessInfo/uk_UA/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/uk_UA/body.tpl @@ -1,6 +1,6 @@

Ваша інформація для доступу

Ім'я користувача: {{userName}}

-

Пароль: {{password}}

+

{{#if password}}Пароль: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/vi_VN/body.tpl b/application/Espo/Resources/templates/accessInfo/vi_VN/body.tpl index fce17784aa..252f5b0fcd 100644 --- a/application/Espo/Resources/templates/accessInfo/vi_VN/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/vi_VN/body.tpl @@ -1,4 +1,4 @@

Username: {{userName}}

-

Password: {{password}}

+

{{#if password}}Password: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfo/zh_CN/body.tpl b/application/Espo/Resources/templates/accessInfo/zh_CN/body.tpl index d640c323aa..3edb0a22fd 100644 --- a/application/Espo/Resources/templates/accessInfo/zh_CN/body.tpl +++ b/application/Espo/Resources/templates/accessInfo/zh_CN/body.tpl @@ -1,6 +1,6 @@

您的访问信息

用户名: {{userName}}

-

密码: {{password}}

+

{{#if password}}密码: {{password}}{{/if}}

{{siteUrl}}

\ No newline at end of file diff --git a/application/Espo/Resources/templates/accessInfoPortal/de_DE/body.tpl b/application/Espo/Resources/templates/accessInfoPortal/de_DE/body.tpl index da78f47a44..beae4b309a 100644 --- a/application/Espo/Resources/templates/accessInfoPortal/de_DE/body.tpl +++ b/application/Espo/Resources/templates/accessInfoPortal/de_DE/body.tpl @@ -1,7 +1,7 @@

Ihre EspoCRM Zugriffsinformation

Benutzername: {{userName}}

-

Passwort: {{password}}

+

{{#if password}}Passwort: {{password}}{{/if}}

{{#each siteUrlList}}

{{./this}}

diff --git a/application/Espo/Resources/templates/accessInfoPortal/en_US/body.tpl b/application/Espo/Resources/templates/accessInfoPortal/en_US/body.tpl index 5eb1c7ff4b..d019d9fbf2 100644 --- a/application/Espo/Resources/templates/accessInfoPortal/en_US/body.tpl +++ b/application/Espo/Resources/templates/accessInfoPortal/en_US/body.tpl @@ -1,7 +1,7 @@

Your access information

Username: {{userName}}

-

Password: {{password}}

+

{{#if password}}Password: {{password}}{{/if}}

{{#each siteUrlList}}

{{./this}}

diff --git a/application/Espo/Resources/templates/accessInfoPortal/es_ES/body.tpl b/application/Espo/Resources/templates/accessInfoPortal/es_ES/body.tpl index 30f9d1c3ec..15b6e355af 100644 --- a/application/Espo/Resources/templates/accessInfoPortal/es_ES/body.tpl +++ b/application/Espo/Resources/templates/accessInfoPortal/es_ES/body.tpl @@ -1,7 +1,7 @@

Información de tu cuenta

Nombre Usuario: {{userName}}

-

Contraseña: {{password}}

+

{{#if password}}Contraseña: {{password}}{{/if}}

{{#each siteUrlList}}

{{./this}}

diff --git a/application/Espo/Services/User.php b/application/Espo/Services/User.php index c4399efc4f..df9cf19962 100644 --- a/application/Espo/Services/User.php +++ b/application/Espo/Services/User.php @@ -31,11 +31,18 @@ namespace Espo\Services; use Espo\Entities\User as UserEntity; use Espo\Entities\Email as EmailEntity; +use Espo\Entities\PasswordChangeRequest; +use Espo\Entities\Portal as PortalEntity; + +use Espo\Repositories\Portal as PortalRepository; + +use Espo\Core\Mail\Sender; use Espo\Core\{ Exceptions\Forbidden, Exceptions\Error, Exceptions\NotFound, + Exceptions\BadRequest, Utils\Util, Utils\PasswordHash, Utils\ApiKey as ApiKeyUtil, @@ -122,13 +129,11 @@ class User extends Record implements } if ($checkCurrentPassword) { - $passwordHash = new PasswordHash($this->getConfig()); - $u = $this->getEntityManager() ->getRDBRepository('User') ->where([ 'id' => $user->getId(), - 'password' => $passwordHash->hash($currentPassword), + 'password' => $this->createPasswordHashUtil()->hash($currentPassword), ]) ->findOne(); @@ -211,16 +216,19 @@ class User extends Record implements return true; } + private function createRecoveryService(): Recovery + { + return $this->injectableFactory->create(Recovery::class); + } + public function passwordChangeRequest(string $userName, string $emailAddress, ?string $url = null): void { - $recovery = $this->injectableFactory->create(Recovery::class); - - $recovery->request($emailAddress, $userName, $url); + $this->createRecoveryService()->request($emailAddress, $userName, $url); } public function changePasswordByRequest(string $requestId, string $password): stdClass { - $recovery = $this->injectableFactory->create(Recovery::class); + $recovery = $this->createRecoveryService(); $request = $recovery->getRequest($requestId); @@ -247,7 +255,7 @@ class User extends Record implements $id = $data->id; - $p = $this->getEntityManager()->getEntity('PasswordChangeRequest', $id); + $p = $this->getEntityManager()->getEntity(PasswordChangeRequest::ENTITY_TYPE, $id); if ($p) { $this->getEntityManager()->removeEntity($p); @@ -280,11 +288,17 @@ class User extends Record implements public function create(stdClass $data, CreateParams $params): Entity { - $newPassword = null; + $newPassword = $data->password ?? null; - if (property_exists($data, 'password')) { - $newPassword = $data->password; + if ($newPassword === '') { + $newPassword = null; + } + if ($newPassword !== null && !is_string($newPassword)) { + throw new BadRequest(); + } + + if ($newPassword !== null) { if (!$this->checkPasswordStrength($newPassword)) { throw new Forbidden("Password is weak."); } @@ -295,13 +309,23 @@ class User extends Record implements /** @var UserEntity $user */ $user = parent::create($data, $params); - if (!is_null($newPassword) && !empty($data->sendAccessInfo)) { - if ($user->isActive()) { - try { - $this->sendPassword($user, $newPassword); - } - catch (Exception $e) {} + $sendAccessInfo = !empty($data->sendAccessInfo); + + if (!$sendAccessInfo || !$user->isActive() || $user->isApi()) { + return $user; + } + + try { + if ($newPassword !== null) { + $this->sendPassword($user, $newPassword); + + return $user; } + + $this->sendAccessInfoNew($user); + } + catch (Exception $e) { + $this->log->error("Could not send user access info. " . $e->getMessage()); } return $user; @@ -406,10 +430,67 @@ class User extends Record implements return $entity; } - public function generateNewPasswordForUser(string $id, bool $allowNonAdmin = false) + private function generatePassword(): string + { + $length = $this->config->get('passwordStrengthLength'); + $letterCount = $this->config->get('passwordStrengthLetterCount'); + $numberCount = $this->config->get('passwordStrengthNumberCount'); + + $generateLength = $this->config->get('passwordGenerateLength', 10); + $generateLetterCount = $this->config->get('passwordGenerateLetterCount', 4); + $generateNumberCount = $this->config->get('passwordGenerateNumberCount', 2); + + $length = is_null($length) ? $generateLength : $length; + $letterCount = is_null($letterCount) ? $generateLetterCount : $letterCount; + $numberCount = is_null($letterCount) ? $generateNumberCount : $numberCount; + + if ($length < $generateLength) { + $length = $generateLength; + } + + if ($letterCount < $generateLetterCount) { + $letterCount = $generateLetterCount; + } + + if ($numberCount < $generateNumberCount) { + $numberCount = $generateNumberCount; + } + + return Util::generatePassword($length, $letterCount, $numberCount, true); + } + + public function sendPasswordChangeLink(string $id, bool $allowNonAdmin = false): void + { + if (!$allowNonAdmin && !$this->user->isAdmin()) { + throw new Forbidden(); + } + + /** @var UserEntity|null $user */ + $user = $this->entityManager->getEntityById(UserEntity::ENTITY_TYPE, $id); + + if (!$user) { + throw new NotFound(); + } + + if (!$user->isActive()) { + throw new Forbidden("User is not active."); + } + + if ( + !$user->isRegular() && + !$user->isAdmin() && + !$user->isPortal() + ) { + throw new Forbidden(); + } + + $this->createRecoveryService()->createAndSendRequestForExistingUser($user); + } + + public function generateNewPasswordForUser(string $id, bool $allowNonAdmin = false): void { if (!$allowNonAdmin) { - if (!$this->getUser()->isAdmin()) { + if (!$this->user->isAdmin()) { throw new Forbidden(); } } @@ -439,45 +520,34 @@ class User extends Record implements ); } - if (!$this->emailSender->hasSystemSmtp() && !$this->getConfig()->get('internalSmtpServer')) { + if (!$this->isSmtpConfigured()) { throw new Forbidden( "Generate new password: Can't process because SMTP is not configured." ); } - $length = $this->getConfig()->get('passwordStrengthLength'); - $letterCount = $this->getConfig()->get('passwordStrengthLetterCount'); - $numberCount = $this->getConfig()->get('passwordStrengthNumberCount'); - - $generateLength = $this->getConfig()->get('passwordGenerateLength', 10); - $generateLetterCount = $this->getConfig()->get('passwordGenerateLetterCount', 4); - $generateNumberCount = $this->getConfig()->get('passwordGenerateNumberCount', 2); - - $length = is_null($length) ? $generateLength : $length; - $letterCount = is_null($letterCount) ? $generateLetterCount : $letterCount; - $numberCount = is_null($letterCount) ? $generateNumberCount : $numberCount; - - if ($length < $generateLength) { - $length = $generateLength; - } - - if ($letterCount < $generateLetterCount) { - $letterCount = $generateLetterCount; - } - - if ($numberCount < $generateNumberCount) { - $numberCount = $generateNumberCount; - } - - $password = Util::generatePassword($length, $letterCount, $numberCount, true); + $password = $this->generatePassword(); $this->sendPassword($user, $password); - $passwordHash = new PasswordHash($this->getConfig()); + $this->saveUserPassword($user, $password); + } - $user->set('password', $passwordHash->hash($password)); + private function isSmtpConfigured(): bool + { + return $this->emailSender->hasSystemSmtp() || $this->config->get('internalSmtpServer'); + } - $this->getEntityManager()->saveEntity($user); + private function saveUserPassword(UserEntity $user, string $password, bool $silent = false): void + { + $user->set('password', $this->createPasswordHashUtil()->hash($password)); + + $this->entityManager->saveEntity($user, ['silent' => $silent]); + } + + private function createPasswordHashUtil(): PasswordHash + { + return $this->injectableFactory->create(PasswordHash::class); } protected function getInternalUserCount() @@ -619,7 +689,70 @@ class User extends Record implements } } - protected function sendPassword(UserEntity $user, $password) + /** + * @return array{?string,?string,?array} + */ + private function getAccessInfoTemplateData( + UserEntity $user, + ?string $password = null, + ?PasswordChangeRequest $passwordChangeRequest = null + ): array { + + $data = []; + + if ($password !== null) { + $data['password'] = $password; + } + + $urlSuffix = ''; + + if ($passwordChangeRequest !== null) { + $urlSuffix = '?entryPoint=changePassword&id=' . $passwordChangeRequest->getRequestId(); + } + + $siteUrl = $this->config->getSiteUrl() . '/' . $urlSuffix; + + if ($user->isPortal()) { + $subjectTpl = $this->templateFileManager->getTemplate('accessInfoPortal', 'subject', 'User'); + $bodyTpl = $this->templateFileManager->getTemplate('accessInfoPortal', 'body', 'User'); + + $urlList = []; + + $portalList = $this->entityManager + ->getRDBRepository(PortalEntity::ENTITY_TYPE) + ->distinct() + ->join('users') + ->where([ + 'isActive' => true, + 'users.id' => $user->getId(), + ]) + ->find(); + + foreach ($portalList as $portal) { + /** @var PortalEntity $portal */ + $this->getPortalRepository()->loadUrlField($portal); + + $urlList[] = $portal->getUrl() . $urlSuffix; + } + + if (count($urlList) === 0) { + return [null, null, null]; + } + + $data['siteUrlList'] = $urlList; + + return [$subjectTpl, $bodyTpl, $data]; + } + + $subjectTpl = $this->templateFileManager->getTemplate('accessInfo', 'subject', 'User'); + $bodyTpl = $this->templateFileManager->getTemplate('accessInfo', 'body', 'User'); + + $data['siteUrl'] = $siteUrl; + + return [$subjectTpl, $bodyTpl, $data]; + } + + protected function sendPassword(UserEntity $user, string $password): void { $emailAddress = $user->get('emailAddress'); @@ -630,68 +763,17 @@ class User extends Record implements /** @var EmailEntity $email */ $email = $this->getEntityManager()->getEntity('Email'); - if (!$this->emailSender->hasSystemSmtp() && !$this->getConfig()->get('internalSmtpServer')) { + if (!$this->isSmtpConfigured()) { return; } - $templateFileManager = $this->templateFileManager; + [$subjectTpl, $bodyTpl, $data] = $this->getAccessInfoTemplateData($user, $password); - $siteUrl = $this->getConfig()->getSiteUrl() . '/'; - - $data = []; - - if ($user->isPortal()) { - $subjectTpl = $templateFileManager->getTemplate('accessInfoPortal', 'subject', 'User'); - $bodyTpl = $templateFileManager->getTemplate('accessInfoPortal', 'body', 'User'); - - $urlList = []; - - $portalList = $this->entityManager - ->getRDBRepository('Portal') - ->distinct() - ->join('users') - ->where([ - 'isActive' => true, - 'users.id' => $user->getId(), - ]) - ->find(); - - foreach ($portalList as $portal) { - if ($portal->get('customUrl')) { - $urlList[] = $portal->get('customUrl'); - } - else { - $url = $siteUrl . 'portal/'; - - if ($this->getConfig()->get('defaultPortalId') !== $portal->getId()) { - if ($portal->get('customId')) { - $url .= $portal->get('customId'); - } - else { - $url .= $portal->getId(); - } - } - - $urlList[] = $url; - } - } - - if (!count($urlList)) { - return; - } - - $data['siteUrlList'] = $urlList; - } - else { - $subjectTpl = $templateFileManager->getTemplate('accessInfo', 'subject', 'User'); - $bodyTpl = $templateFileManager->getTemplate('accessInfo', 'body', 'User'); - - $data['siteUrl'] = $siteUrl; + if ($data === null) { + return; } - $data['password'] = $password; - - $htmlizer = $this->htmlizerFactory->create(true); + $htmlizer = $this->htmlizerFactory->createNoAcl(); $subject = $htmlizer->render($user, $subjectTpl, null, $data, true); $body = $htmlizer->render($user, $bodyTpl, null, $data, true); @@ -702,24 +784,29 @@ class User extends Record implements 'to' => $emailAddress, ]); + $this->getEmailSenderForAccessInfo()->send($email); + } + + private function getEmailSenderForAccessInfo(): Sender + { $sender = $this->emailSender->create(); if (!$this->emailSender->hasSystemSmtp()) { $sender->withSmtpParams([ - 'server' => $this->getConfig()->get('internalSmtpServer'), - 'port' => $this->getConfig()->get('internalSmtpPort'), - 'auth' => $this->getConfig()->get('internalSmtpAuth'), - 'username' => $this->getConfig()->get('internalSmtpUsername'), - 'password' => $this->getConfig()->get('internalSmtpPassword'), - 'security' => $this->getConfig()->get('internalSmtpSecurity'), - 'fromAddress' => $this->getConfig()->get( + 'server' => $this->config->get('internalSmtpServer'), + 'port' => $this->config->get('internalSmtpPort'), + 'auth' => $this->config->get('internalSmtpAuth'), + 'username' => $this->config->get('internalSmtpUsername'), + 'password' => $this->config->get('internalSmtpPassword'), + 'security' => $this->config->get('internalSmtpSecurity'), + 'fromAddress' => $this->config->get( 'internalOutboundEmailFromAddress', - $this->getConfig()->get('outboundEmailFromAddress') + $this->config->get('outboundEmailFromAddress') ), ]); } - $sender->send($email); + return $sender; } public function delete(string $id, DeleteParams $params): void @@ -803,4 +890,50 @@ class User extends Record implements $this->dataManager->updateCacheTimestamp(); } + + private function sendAccessInfoNew(UserEntity $user): void + { + if ($user->getEmailAddressGroup()->getPrimary() === null) { + throw new Error("Can't send access info for user '{$user->getId()}' w/o email address."); + } + + if (!$this->isSmtpConfigured()) { + throw new Error("Can't send access info. SMTP is not configured."); + } + + $stubPassword = $this->generatePassword(); + + $this->saveUserPassword($user, $stubPassword, true); + + $request = $this->createRecoveryService()->createRequestForNewUser($user); + + [$subjectTpl, $bodyTpl, $data] = $this->getAccessInfoTemplateData($user, null, $request); + + if ($data === null) { + throw new Error("Could not send access info."); + } + + $emailAddress = $user->getEmailAddressGroup()->getPrimary()->getAddress(); + + /** @var EmailEntity $email */ + $email = $this->entityManager->getEntity(EmailEntity::ENTITY_TYPE); + + $htmlizer = $this->htmlizerFactory->createNoAcl(); + + $subject = $htmlizer->render($user, $subjectTpl, null, $data, true); + $body = $htmlizer->render($user, $bodyTpl, null, $data, true); + + $email + ->addToAddress($emailAddress) + ->setSubject($subject) + ->setBody($body); + + $this->getEmailSenderForAccessInfo()->send($email); + } + + private function getPortalRepository(): PortalRepository + { + /** @var PortalRepository */ + return $this->entityManager->getRDBRepository(PortalEntity::ENTITY_TYPE); + } } diff --git a/client/res/templates/user/password-change-request.tpl b/client/res/templates/user/password-change-request.tpl index e581750181..1ea890cd1b 100644 --- a/client/res/templates/user/password-change-request.tpl +++ b/client/res/templates/user/password-change-request.tpl @@ -1,23 +1,43 @@
-
+

{{translate 'Change Password' scope='User'}}

-
-
- -
{{{password}}}
-
-
- -
{{{passwordConfirm}}}
-
-
- -
+ {{#unless notFound}} +
+
+ +
{{{password}}}
+
+
+ +
{{{generatePassword}}}
+
+
+
+ +
{{{passwordConfirm}}}
+
+
+ +
{{{passwordPreview}}}
+
+
+
+ +
+ {{else}} +

{{complexText notFoundMessage}}

+ {{/unless}}
diff --git a/client/src/controllers/password-change-request.js b/client/src/controllers/password-change-request.js index 90e0b34540..5262cb326d 100644 --- a/client/src/controllers/password-change-request.js +++ b/client/src/controllers/password-change-request.js @@ -40,7 +40,8 @@ define('controllers/password-change-request', 'controller', function (Dep) { this.entire('views/user/password-change-request', { requestId: options.id, strengthParams: options.strengthParams, - }, function (view) { + notFound: options.notFound, + }, view => { view.render(); }); }, diff --git a/client/src/views/user/fields/generate-password.js b/client/src/views/user/fields/generate-password.js index dc10000801..ef46032300 100644 --- a/client/src/views/user/fields/generate-password.js +++ b/client/src/views/user/fields/generate-password.js @@ -30,7 +30,8 @@ define('views/user/fields/generate-password', 'views/fields/base', function (Dep return Dep.extend({ - _template: '', + templateContent: '', events: { 'click [data-action="generatePassword"]': function () { @@ -41,12 +42,35 @@ define('views/user/fields/generate-password', 'views/fields/base', function (Dep setup: function () { Dep.prototype.setup.call(this); - this.listenTo(this.model, 'change:password', function (model, value, o) { - if (o.isGenerated) return; + this.listenTo(this.model, 'change:password', (model, value, o) => { + if (o.isGenerated) { + return; + } + this.model.set({ passwordPreview: '', }); - }, this); + }); + + this.strengthParams = this.options.strengthParams || {}; + + this.passwordStrengthLength = this.strengthParams.passwordStrengthLength || + this.getConfig().get('passwordStrengthLength'); + + this.passwordStrengthLetterCount = this.strengthParams.passwordStrengthLetterCount || + this.getConfig().get('passwordStrengthLetterCount'); + + this.passwordStrengthNumberCount = this.strengthParams.passwordStrengthNumberCount || + this.getConfig().get('passwordStrengthNumberCount'); + + this.passwordGenerateLength = this.strengthParams.passwordGenerateLength || + this.getConfig().get('passwordGenerateLength'); + + this.passwordGenerateLetterCount = this.strengthParams.passwordGenerateLetterCount || + this.getConfig().get('passwordGenerateLetterCount'); + + this.passwordGenerateNumberCount = this.strengthParams.passwordGenerateNumberCount || + this.getConfig().get('passwordGenerateNumberCount'); }, fetch: function () { @@ -54,13 +78,13 @@ define('views/user/fields/generate-password', 'views/fields/base', function (Dep }, actionGeneratePassword: function () { - var length = this.getConfig().get('passwordStrengthLength'); - var letterCount = this.getConfig().get('passwordStrengthLetterCount'); - var numberCount = this.getConfig().get('passwordStrengthNumberCount'); + var length = this.passwordStrengthLength; + var letterCount = this.passwordStrengthLetterCount; + var numberCount = this.passwordStrengthNumberCount; - var generateLength = this.getConfig().get('passwordGenerateLength') || 10; - var generateLetterCount = this.getConfig().get('passwordGenerateLetterCount') || 4; - var generateNumberCount = this.getConfig().get('passwordGenerateNumberCount') || 2; + var generateLength = this.passwordGenerateLength || 10; + var generateLetterCount = this.passwordGenerateLetterCount || 4; + var generateNumberCount = this.passwordGenerateNumberCount || 2; length = (typeof length === 'undefined') ? generateLength : length; letterCount = (typeof letterCount === 'undefined') ? generateLetterCount : letterCount; @@ -94,17 +118,25 @@ define('views/user/fields/generate-password', 'views/fields/base', function (Dep if (bothCases) { upperCase = 1; lowerCase = 1; - if (letters >= 2) letters = letters - 2; - else letters = 0; + + if (letters >= 2) { + letters = letters - 2; + } else { + letters = 0; + } } var either = length - (letters + numbers + upperCase + lowerCase); - if (either < 0) either = 0; + + if (either < 0) { + either = 0; + } var setList = [letters, numbers, either, upperCase, lowerCase]; var shuffle = function (array) { var currentIndex = array.length, temporaryValue, randomIndex; + while (0 !== currentIndex) { randomIndex = Math.floor(Math.random() * currentIndex); currentIndex -= 1; @@ -112,6 +144,7 @@ define('views/user/fields/generate-password', 'views/fields/base', function (Dep array[currentIndex] = array[randomIndex]; array[randomIndex] = temporaryValue; } + return array; }; diff --git a/client/src/views/user/password-change-request.js b/client/src/views/user/password-change-request.js index 8202a63e38..51a9e335e1 100644 --- a/client/src/views/user/password-change-request.js +++ b/client/src/views/user/password-change-request.js @@ -26,7 +26,7 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -define('views/user/password-change-request', ['view', 'model'], function (Dep, Model) { +define('views/user/password-change-request', ['view', 'model', 'lib!Espo'], function (Dep, Model, Espo) { return Dep.extend({ @@ -34,24 +34,27 @@ define('views/user/password-change-request', ['view', 'model'], function (Dep, M data: function () { return { - requestId: this.options.requestId + requestId: this.options.requestId, + notFound: this.options.notFound, + notFoundMessage: this.notFoundMessage, }; }, events: { 'click #btn-submit': function () { this.submit(); - } + }, }, setup: function () { var model = this.model = new Model; + model.name = 'User'; this.createView('password', 'views/user/fields/password', { model: model, mode: 'edit', - el: this.options.el + ' .field[data-name="password"]', + el: this.getSelector() + ' .field[data-name="password"]', defs: { name: 'password', params: { @@ -64,7 +67,7 @@ define('views/user/password-change-request', ['view', 'model'], function (Dep, M this.createView('passwordConfirm', 'views/fields/password', { model: model, mode: 'edit', - el: this.options.el + ' .field[data-name="passwordConfirm"]', + el: this.getSelector() + ' .field[data-name="passwordConfirm"]', defs: { name: 'passwordConfirm', params: { @@ -72,6 +75,34 @@ define('views/user/password-change-request', ['view', 'model'], function (Dep, M }, }, }); + + this.createView('generatePassword', 'views/user/fields/generate-password', { + model: model, + mode: 'detail', + readOnly: true, + el: this.getSelector() + ' .field[data-name="generatePassword"]', + defs: { + name: 'generatePassword', + }, + strengthParams: this.options.strengthParams, + }); + + this.createView('passwordPreview', 'views/fields/base', { + model: model, + mode: 'detail', + readOnly: true, + el: this.getSelector() + ' .field[data-name="passwordPreview"]', + defs: { + name: 'passwordPreview', + }, + }); + + this.model.on('change:passwordPreview', () => this.reRender()); + + let url = this.baseUrl = window.location.href.split('?')[0]; + + this.notFoundMessage = this.translate('passwordChangeRequestNotFound', 'messages', 'User') + .replace('{url}', url); }, submit: function () { @@ -79,7 +110,7 @@ define('views/user/password-change-request', ['view', 'model'], function (Dep, M this.getView('passwordConfirm').fetchToModel(); var notValid = this.getView('password').validate() || - this.getView('passwordConfirm').validate(); + this.getView('passwordConfirm').validate(); var password = this.model.get('password'); @@ -87,30 +118,31 @@ define('views/user/password-change-request', ['view', 'model'], function (Dep, M return; } - var $submit = this.$el.find('.btn-submit'); + let $submit = this.$el.find('.btn-submit'); + $submit.addClass('disabled'); - Espo.Ajax.postRequest('User/changePasswordByRequest', { - requestId: this.options.requestId, - password: password, - }).then( - function (data) { + Espo.Ajax + .postRequest('User/changePasswordByRequest', { + requestId: this.options.requestId, + password: password, + }) + .then(data => { this.$el.find('.password-change').remove(); - var url = data.url || this.getConfig().get('siteUrl'); + var url = data.url || this.baseUrl; - var msg = this.translate('passwordChangedByRequest', 'messages', 'User'); - msg += ' ' + this.translate('Login', 'labels', 'User') + '.'; + var msg = this.translate('passwordChangedByRequest', 'messages', 'User') + + ' ' + this.translate('Login', 'labels', 'User') + '.'; - this.$el.find('.msg-box').removeClass('hidden').html('' + msg + ''); - }.bind(this) - ).fail( - function () { - $submit.removeClass('disabled'); - }.bind(this) - ); + this.$el.find('.msg-box') + .removeClass('hidden') + .html('' + msg + ''); + }) + .catch(() => + $submit.removeClass('disabled') + ); }, }); }); - diff --git a/client/src/views/user/record/detail.js b/client/src/views/user/record/detail.js index 40735880a2..c27a7e8fa0 100644 --- a/client/src/views/user/record/detail.js +++ b/client/src/views/user/record/detail.js @@ -77,12 +77,21 @@ define('views/user/record/detail', 'views/record/detail', function (Dep) { } if ( - this.getUser().isAdmin() - && - (this.model.isRegular() || this.model.isAdmin() || this.model.isPortal()) - && + this.getUser().isAdmin() && + ( + this.model.isRegular() || + this.model.isAdmin() || + this.model.isPortal() + ) && !this.model.isSuperAdmin() ) { + this.addDropdownItem({ + name: 'sendPasswordChangeLink', + label: 'Send Password Change Link', + action: 'sendPasswordChangeLink', + hidden: !this.model.get('emailAddress'), + }); + this.addDropdownItem({ name: 'generateNewPassword', label: 'Generate New Password', @@ -91,13 +100,15 @@ define('views/user/record/detail', 'views/record/detail', function (Dep) { }); if (!this.model.get('emailAddress')) { - this.listenTo(this.model, 'sync', function () { + this.listenTo(this.model, 'sync', () => { if (this.model.get('emailAddress')) { this.showActionItem('generateNewPassword'); + this.showActionItem('sendPasswordChangeLink'); } else { this.hideActionItem('generateNewPassword'); + this.hideActionItem('sendPasswordChangeLink'); } - }, this); + }); } } @@ -105,10 +116,10 @@ define('views/user/record/detail', 'views/record/detail', function (Dep) { this.hideActionItem('duplicate'); } - if (this.model.id == this.getUser().id) { - this.listenTo(this.model, 'after:save', function () { + if (this.model.id === this.getUser().id) { + this.listenTo(this.model, 'after:save', () => { this.getUser().set(this.model.toJSON()); - }.bind(this)); + }); } this.setupFieldAppearance(); @@ -144,29 +155,27 @@ define('views/user/record/detail', 'views/record/detail', function (Dep) { 'emailAddress', ]; - nonAdminReadOnlyFieldList = nonAdminReadOnlyFieldList.filter( - function (item) { - if (!this.model.hasField(item)) { - return true; - } + nonAdminReadOnlyFieldList = nonAdminReadOnlyFieldList.filter(item => { + if (!this.model.hasField(item)) { + return true; + } - var aclDefs = this.getMetadata().get(['entityAcl', 'User', 'fields', item]); + var aclDefs = this.getMetadata().get(['entityAcl', 'User', 'fields', item]); - if (!aclDefs) { - return true; - } + if (!aclDefs) { + return true; + } - if (aclDefs.nonAdminReadOnly) { - return true; - } + if (aclDefs.nonAdminReadOnly) { + return true; + } - return false; - }.bind(this), - ); + return false; + }); - nonAdminReadOnlyFieldList.forEach(function (field) { + nonAdminReadOnlyFieldList.forEach((field) => { this.setFieldReadOnly(field, true); - }, this); + }); if (!this.getAcl().checkScope('Team')) { this.setFieldReadOnly('defaultTeam', true); @@ -174,11 +183,11 @@ define('views/user/record/detail', 'views/record/detail', function (Dep) { }, setupFieldAppearance: function () { - this.controlFieldAppearance(); - this.listenTo(this.model, 'change', function () { + + this.listenTo(this.model, 'change', () => { this.controlFieldAppearance(); - }, this); + }); }, controlFieldAppearance: function () { @@ -223,7 +232,7 @@ define('views/user/record/detail', 'views/record/detail', function (Dep) { if (this.model.id === this.getUser().id) { this.setFieldReadOnly('type'); } else { - if (this.model.get('type') == 'admin' || this.model.get('type') == 'regular') { + if (this.model.get('type') === 'admin' || this.model.get('type') === 'regular') { this.setFieldNotReadOnly('type'); this.setFieldOptionList('type', ['regular', 'admin']); } else { @@ -245,17 +254,16 @@ define('views/user/record/detail', 'views/record/detail', function (Dep) { this.createView('changePassword', 'views/modals/change-password', { userId: this.model.id - }, function (view) { + }, (view) => { view.render(); this.notify(false); - this.listenToOnce(view, 'changed', function () { - setTimeout(function () { + this.listenToOnce(view, 'changed', () => { + setTimeout(() => { this.getBaseController().logout(); - }.bind(this), 2000); - }, this); - - }.bind(this)); + }, 2000); + }); + }); }, actionPreferences: function () { @@ -279,19 +287,23 @@ define('views/user/record/detail', 'views/record/detail', function (Dep) { data: { id: this.model.id, } - }).done(function (aclData) { + }).then((aclData) => { this.createView('access', 'views/user/modals/access', { aclData: aclData, model: this.model, - }, function (view) { + }, (view) => { this.notify(false); + view.render(); - }.bind(this)); - }.bind(this)); + }); + }); }, getGridLayout: function (callback) { - this._helper.layoutManager.get(this.model.name, this.options.layoutName || this.layoutName, function (simpleLayout) { + this._helper + .layoutManager + .get(this.model.name, this.options.layoutName || this.layoutName, (simpleLayout) => { + var layout = Espo.Utils.cloneDeep(simpleLayout); if (!this.getUser().isPortal()) { @@ -342,42 +354,55 @@ define('views/user/record/detail', 'views/record/detail', function (Dep) { }; callback(gridLayout); - }.bind(this)); + }); }, actionGenerateNewApiKey: function () { - this.confirm(this.translate('confirmation', 'messages'), function () { + this.confirm(this.translate('confirmation', 'messages'), () => { this.ajaxPostRequest('User/action/generateNewApiKey', { - id: this.model.id - }).then(function (data) { + id: this.model.id, + }).then((data) => { this.model.set(data); - }.bind(this)); - }.bind(this)); + }); + }); }, actionViewSecurity: function () { this.createView('dialog', 'views/user/modals/security', { userModel: this.model, - }, function (view) { + }, (view) => { view.render(); - }, this); + }); + }, + + actionSendPasswordChangeLink: function () { + this.confirm({ + message: this.translate('sendPasswordChangeLinkConfirmation', 'messages', 'User'), + confirmText: this.translate('Send', 'labels', 'Email'), + }) + .then(() => { + Espo.Ui.notify(this.translate('pleaseWait', 'messages')); + + Espo.Ajax.postRequest('User/action/sendPasswordChangeLink', { + id: this.model.id, + }).then(() => { + Espo.Ui.success(this.translate('Done')); + }); + }); }, actionGenerateNewPassword: function () { this.confirm( this.translate('generateAndSendNewPassword', 'messages', 'User') - ).then( - function () { - Espo.Ui.notify(this.translate('pleaseWait', 'messages')); - Espo.Ajax.postRequest('User/action/generateNewPassword', { - id: this.model.id - }).then( - function () { - Espo.Ui.success(this.translate('Done')); - }.bind(this) - ); - }.bind(this) - ); + ).then(() => { + Espo.Ui.notify(this.translate('pleaseWait', 'messages')); + + Espo.Ajax.postRequest('User/action/generateNewPassword', { + id: this.model.id, + }).then(() => { + Espo.Ui.success(this.translate('Done')); + }); + }); }, }); diff --git a/client/src/views/user/record/edit.js b/client/src/views/user/record/edit.js index 2dea7ac184..fc41f272e4 100644 --- a/client/src/views/user/record/edit.js +++ b/client/src/views/user/record/edit.js @@ -37,66 +37,84 @@ define('views/user/record/edit', ['views/record/edit', 'views/user/record/detail this.setupNonAdminFieldsAccess(); - if (this.model.id == this.getUser().id) { - this.listenTo(this.model, 'after:save', function () { + if (this.model.id === this.getUser().id) { + this.listenTo(this.model, 'after:save', () => { this.getUser().set(this.model.toJSON()); - }, this); + }); } this.hideField('sendAccessInfo'); this.passwordInfoMessage = this.getPasswordSendingMessage(); + if (!this.passwordInfoMessage) { this.hideField('passwordInfo'); } - var passwordChanged = false; + let passwordChanged = false; - this.listenToOnce(this.model, 'change:password', function (model) { + this.listenToOnce(this.model, 'change:password', (model) => { passwordChanged = true; - if (this.isPasswordSendable()) { - this.showField('sendAccessInfo'); - this.model.set('sendAccessInfo', true); - } - }, this); - this.listenTo(this.model, 'change', function (model) { - if (!passwordChanged) return; - if (!model.hasChanged('emailAddress') && !model.hasChanged('portalsIds')) return; + this.controlSendAccessInfoField(); + }); - if (this.isPasswordSendable()) { - this.showField('sendAccessInfo'); - this.model.set('sendAccessInfo', true); - } else { - this.hideField('sendAccessInfo'); - this.model.set('sendAccessInfo', false); + this.listenTo(this.model, 'change', (model) => { + if (!this.model.isNew() && !passwordChanged) { + return; } - }, this); + + if ( + !model.hasChanged('emailAddress') && + !model.hasChanged('portalsIds') + ) { + return; + } + + this.controlSendAccessInfoField(); + }); Detail.prototype.setupFieldAppearance.call(this); this.hideField('passwordPreview'); - this.listenTo(this.model, 'change:passwordPreview', function (model, value) { + + this.listenTo(this.model, 'change:passwordPreview', (model, value) => { value = value || ''; + if (value.length) { this.showField('passwordPreview'); } else { this.hideField('passwordPreview'); } - }, this); + }); - this.listenTo(this.model, 'after:save', function () { + this.listenTo(this.model, 'after:save', () => { this.model.unset('password', {silent: true}); this.model.unset('passwordConfirm', {silent: true}); - }, this); + }); + }, + + controlSendAccessInfoField: function () { + if (this.isPasswordSendable()) { + this.showField('sendAccessInfo'); + this.model.set('sendAccessInfo', true); + } else { + this.hideField('sendAccessInfo'); + this.model.set('sendAccessInfo', false); + } }, isPasswordSendable: function () { if (this.model.isPortal()) { - if (!(this.model.get('portalsIds') || []).length) return false; + if (!(this.model.get('portalsIds') || []).length) { + return false; + } + } + + if (!this.model.get('emailAddress')) { + return false; } - if (!this.model.get('emailAddress')) return false; return true; }, @@ -150,7 +168,7 @@ define('views/user/record/edit', ['views/record/edit', 'views/user/record/detail name: 'password', type: 'password', params: { - required: this.isNew, + required: false, readyToChange: true, }, view: 'views/user/fields/password', @@ -166,7 +184,7 @@ define('views/user/record/edit', ['views/record/edit', 'views/user/record/detail name: 'passwordConfirm', type: 'password', params: { - required: this.isNew, + required: false, readyToChange: true } }, @@ -216,9 +234,11 @@ define('views/user/record/edit', ['views/record/edit', 'views/user/record/detail if (this.getConfig().get('smtpServer') && this.getConfig().get('smtpServer') !== '') { return ''; } + var msg = this.translate('setupSmtpBefore', 'messages', 'User').replace('{url}', '#Admin/outboundEmails'); msg = this.getHelper().transfromMarkdownInlineText(msg); + return msg; }, @@ -241,7 +261,7 @@ define('views/user/record/edit', ['views/record/edit', 'views/user/record/detail errorHandlerUserNameExists: function () { Espo.Ui.error(this.translate('userNameExists', 'messages', 'User')) - } + }, }); });