password change link

This commit is contained in:
Yuri Kuznetsov
2022-01-28 13:39:28 +02:00
parent a2b2118f9f
commit c4be84af32
43 changed files with 763 additions and 417 deletions

View File

@@ -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);
}

View File

@@ -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()) {

View File

@@ -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);
}
}

View File

@@ -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)));

View File

@@ -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');
}
}

View File

@@ -52,4 +52,9 @@ class Portal extends \Espo\Core\ORM\Entity
{
return $this->settingsAttributeList;
}
public function getUrl(): ?string
{
return $this->get('url');
}
}

View File

@@ -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)
);
}
}

View File

@@ -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'));

View File

@@ -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": {

View File

@@ -1,6 +1,6 @@
<h3>Vaše přístupové informace jsou</h3>
<p>Přihlašovací jméno: {{userName}}</p>
<p>Heslo: {{password}}</p>
<p>{{#if password}}Heslo: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Dine Logindetaljer</h3>
<p>Brugernavn: {{userName}}</p>
<p>Kodeord: {{password}}</p>
<p>{{#if password}}Kodeord: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Ihre EspoCRM Zugriffsinformation</h3>
<p>Benutzername: {{userName}}</p>
<p>Passwort: {{password}}</p>
<p>{{#if password}}Passwort: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Your access information</h3>
<p>Username: {{userName}}</p>
<p>Password: {{password}}</p>
<p>{{#if password}}Password: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Información de tu cuenta</h3>
<p>Nombre Usuario: {{userName}}</p>
<p>Contraseña: {{password}}</p>
<p>{{#if password}}Contraseña: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Información de tu cuenta</h3>
<p>Nombre Usuario: {{userName}}</p>
<p>Contraseña: {{password}}</p>
<p>{{#if password}}Contraseña: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Vos identifiants sont les suivants</h3>
<p>Nom d'utilisateur: {{userName}}</p>
<p>Mot de passe: {{password}}</p>
<p>{{#if password}}Mot de passe: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Vaši podaci za pristup</h3>
<p>Korisničko ime: {{userName}}</p>
<p>Lozinka: {{password}}</p>
<p>{{#if password}}Lozinka: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>A hozzáférési adatai</h3>
<p>Felhasználónév: {{userName}}</p>
<p>Jelszó: {{password}}</p>
<p>{{#if password}}Jelszó: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Akses informasi Anda</h3>
<p>Username: {{userName}}</p>
<p>Password: {{password}}</p>
<p>{{#if password}}Password: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>I tuoi dati di accesso</h3>
<p>Username: {{userName}}</p>
<p>Password: {{password}}</p>
<p>{{#if password}}Password: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Jūsų prisijungimo informacija</h3>
<p>Vartotojo vardas: {{userName}}</p>
<p>Slaptažodis: {{password}}</p>
<p>{{#if password}}Slaptažodis: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Påloggingsinformasjon</h3>
<p>Brukernavn: {{userName}}</p>
<p>Passord: {{password}}</p>
<p>{{#if password}}Passord: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Uw gegevens</h3>
<p>Gebruikersnaam: {{userName}}</p>
<p>Wachtwoord: {{password}}</p>
<p>{{#if password}}Wachtwoord: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Informacje o twoim koncie</h3>
<p>Użytkownik: {{userName}}</p>
<p>Hasło: {{password}}</p>
<p>{{#if password}}Hasło: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Informações da sua conta</h3>
<p>Usuário: {{userName}}</p>
<p>Senha: {{password}}</p>
<p>{{#if password}}Senha: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Informațiile tale de acces</h3>
<p>Username: {{userName}}</p>
<p>Password: {{password}}</p>
<p>{{#if password}}Password: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Информация о Вашей учетной записи</h3>
<p>Имя пользователя: {{userName}}</p>
<p>Пароль: {{password}}</p>
<p>{{#if password}}Пароль: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Vaš prístupové informácie</h3>
<p>Username: {{userName}}</p>
<p>Password: {{password}}</p>
<p>{{#if password}}Password: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Vaši podaci za pristup</h3>
<p>Korisničko ime: {{userName}}</p>
<p>Lozinka: {{password}}</p>
<p>{{#if password}}Lozinka: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,4 +1,4 @@
<p>Username: {{userName}}</p>
<p>Password: {{password}}</p>
<p>{{#if password}}Password: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>Ваша інформація для доступу</h3>
<p>Ім'я користувача: {{userName}}</p>
<p>Пароль: {{password}}</p>
<p>{{#if password}}Пароль: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,4 +1,4 @@
<p>Username: {{userName}}</p>
<p>Password: {{password}}</p>
<p>{{#if password}}Password: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,6 +1,6 @@
<h3>您的访问信息</h3>
<p>用户名: {{userName}}</p>
<p>密码: {{password}}</p>
<p>{{#if password}}密码: {{password}}{{/if}}</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>

View File

@@ -1,7 +1,7 @@
<h3>Ihre EspoCRM Zugriffsinformation</h3>
<p>Benutzername: {{userName}}</p>
<p>Passwort: {{password}}</p>
<p>{{#if password}}Passwort: {{password}}{{/if}}</p>
{{#each siteUrlList}}
<p><a href="{{./this}}">{{./this}}</a></p>

View File

@@ -1,7 +1,7 @@
<h3>Your access information</h3>
<p>Username: {{userName}}</p>
<p>Password: {{password}}</p>
<p>{{#if password}}Password: {{password}}{{/if}}</p>
{{#each siteUrlList}}
<p><a href="{{./this}}">{{./this}}</a></p>

View File

@@ -1,7 +1,7 @@
<h3>Información de tu cuenta</h3>
<p>Nombre Usuario: {{userName}}</p>
<p>Contraseña: {{password}}</p>
<p>{{#if password}}Contraseña: {{password}}{{/if}}</p>
{{#each siteUrlList}}
<p><a href="{{./this}}">{{./this}}</a></p>

View File

@@ -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<string,mixed>}
*/
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);
}
}

View File

@@ -1,23 +1,43 @@
<div class="container content">
<div class="col-md-4 col-md-offset-3 col-sm-8 col-sm-offset-2">
<div class="col-md-8 col-md-offset-1 col-sm-12 col-sm-offset-0">
<div class="panel panel-default password-change">
<div class="panel-heading">
<h4 class="panel-title">{{translate 'Change Password' scope='User'}}</h4>
</div>
<div class="panel-body">
<div>
<div class="cell form-group">
<label for="login" class="control-label">{{translate 'newPassword' category='fields' scope='User'}}</label>
<div class="field" data-name="password">{{{password}}}</div>
</div>
<div class="cell form-group">
<label for="login" class="control-label">{{translate 'newPasswordConfirm' category='fields' scope='User'}}</label>
<div class="field" data-name="passwordConfirm">{{{passwordConfirm}}}</div>
</div>
<div>
<button type="button" class="btn btn-danger" id="btn-submit">{{translate 'Submit'}}</button>
</div>
{{#unless notFound}}
<div class="row">
<div class="cell form-group col-sm-6">
<label
for="login"
class="control-label"
>{{translate 'newPassword' category='fields' scope='User'}}</label>
<div class="field" data-name="password">{{{password}}}</div>
</div>
<div class="cell form-group col-sm-6">
<label class="control-label"></label>
<div class="field" data-name="generatePassword">{{{generatePassword}}}</div>
</div>
</div>
<div class="row">
<div class="cell form-group col-sm-6">
<label
for="login"
class="control-label"
>{{translate 'newPasswordConfirm' category='fields' scope='User'}}</label>
<div class="field" data-name="passwordConfirm">{{{passwordConfirm}}}</div>
</div>
<div class="cell form-group col-sm-6">
<label class="control-label"></label>
<div class="field" data-name="passwordPreview">{{{passwordPreview}}}</div>
</div>
</div>
<div>
<button type="button" class="btn btn-danger" id="btn-submit">{{translate 'Submit'}}</button>
</div>
{{else}}
<p class="complex-text">{{complexText notFoundMessage}}</p>
{{/unless}}
</div>
</div>
<div class="msg-box hidden"></div>

View File

@@ -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();
});
},

View File

@@ -30,7 +30,8 @@ define('views/user/fields/generate-password', 'views/fields/base', function (Dep
return Dep.extend({
_template: '<button type="button" class="btn btn-default" data-action="generatePassword">{{translate \'Generate\' scope=\'User\'}}</button>',
templateContent: '<button type="button" class="btn btn-default" data-action="generatePassword">' +
'{{translate \'Generate\' scope=\'User\'}}</button>',
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;
};

View File

@@ -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 += ' <a href="' + url + '">' + this.translate('Login', 'labels', 'User') + '</a>.';
var msg = this.translate('passwordChangedByRequest', 'messages', 'User') +
' <a href="' + url + '">' + this.translate('Login', 'labels', 'User') + '</a>.';
this.$el.find('.msg-box').removeClass('hidden').html('<span class="text-success">' + msg + '</span>');
}.bind(this)
).fail(
function () {
$submit.removeClass('disabled');
}.bind(this)
);
this.$el.find('.msg-box')
.removeClass('hidden')
.html('<span class="text-success">' + msg + '</span>');
})
.catch(() =>
$submit.removeClass('disabled')
);
},
});
});

View File

@@ -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'));
});
});
},
});

View File

@@ -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'))
}
},
});
});