diff --git a/application/Espo/Controllers/User.php b/application/Espo/Controllers/User.php index 17a7138d01..e6d450efac 100644 --- a/application/Espo/Controllers/User.php +++ b/application/Espo/Controllers/User.php @@ -25,11 +25,12 @@ namespace Espo\Controllers; use \Espo\Core\Exceptions\Error; use \Espo\Core\Exceptions\NotFound; use \Espo\Core\Exceptions\Forbidden; +use \Espo\Core\Exceptions\BadRequest; class User extends \Espo\Core\Controllers\Record -{ +{ public function actionAcl($params, $data, $request) - { + { $userId = $request->get('id'); if (empty($userId)) { throw new Error(); @@ -46,12 +47,28 @@ class User extends \Espo\Core\Controllers\Record $acl = new \Espo\Core\Acl($user, $this->getConfig(), $this->getContainer()->get('fileManager'), $this->getMetadata()); - return $acl->toArray(); + return $acl->toArray(); } public function actionChangeOwnPassword($params, $data) { return $this->getService('User')->changePassword($this->getUser()->id, $data['password']); } + + public function actionPasswordChangeRequest($params, $data, $request) + { + if (!$request->isPost()) { + throw new Forbidden(); + } + + if (empty($data['userName']) || empty($data['emailAddress'])) { + throw new BadRequest(); + } + + $userName = $data['userName']; + $emailAddress = $data['emailAddress']; + + return $this->getService('User')->passwordChangeRequest($userName, $emailAddress); + } } diff --git a/application/Espo/Entities/PasswordChangeRequest.php b/application/Espo/Entities/PasswordChangeRequest.php new file mode 100644 index 0000000000..425b0c8f6b --- /dev/null +++ b/application/Espo/Entities/PasswordChangeRequest.php @@ -0,0 +1,29 @@ +dependencies[] = 'mailSender'; - $this->dependencies[] = 'language'; + $this->dependencies[] = 'container'; } protected $internalFields = array('password'); protected function getMailSender() { - return $this->injections['mailSender']; + return $this->getContainer()->get('mailSender'); } protected function getLanguage() { - return $this->injections['language']; + return $this->getContainer()->get('language'); + } + + protected function getContainer() + { + return $this->injections['container']; } public function getEntity($id) @@ -91,6 +97,73 @@ class User extends Record return true; } + public function passwordChangeRequest($userName, $emailAddress) + { + $user = $this->getEntityManager()->getRepository('User')->where(array( + 'userName' => $userName, + 'emailAddress' => $emailAddress + ))->findOne(); + + if (empty($user)) { + throw new NotFound(); + } + + $userId = $user->id; + + $passwordChangeRequest = $this->getEntityManager()->getRepository('PasswordChangeRequest')->where(array( + 'userId' => $userId + ))->findOne(); + if ($passwordChangeRequest) { + throw new Forbidden(); + } + + $requestId = uniqid(); + + $passwordChangeRequest = $this->getEntityManager()->getEntity('PasswordChangeRequest'); + $passwordChangeRequest->set(array( + 'userId' => $userId, + 'requestId' => $requestId + )); + + $this->sendChangePasswordLink($requestId, $emailAddress); + + $this->getEntityManager()->saveEntity($passwordChangeRequest); + + if (!$passwordChangeRequest->id) { + throw new Error(); + } + + $dt = new \DateTime(); + $dt->add(\DateTimeInterval('P'. self::PASSWORD_CHANGE_REQUEST_LIFETIME . 'i')); + + $job->set(array( + 'serviceName' => 'User', + 'method' => 'removeChangePasswordRequestJob', + 'data' => json_encode(array( + 'id' => $passwordChangeRequest->id, + )), + 'executeTime' => $dt->format('Y-m-d H:i:s') , + )); + + $this->getEntityManager()->saveEntity($job); + + return true; + } + + public function removeChangePasswordRequestJob($data) + { + $id = $data->id; + if (empty($id)) { + return; + } + + $p = $this->getEntityManager()->getEntity('PasswordChangeRequest', $data->id); + if ($p) { + $this->getEntityManager()->removeEntity($p); + } + return true; + } + protected function hashPassword($password) { $config = $this->getConfig(); @@ -168,6 +241,35 @@ class User extends Record $this->getMailSender()->send($email); } + protected function sendChangePasswordLink($requestId, $emailAddress, Entity $user = null) + { + if (empty($emailAddress)) { + return; + } + + $email = $this->getEntityManager()->getEntity('Email'); + + if (!$this->getConfig()->get('smtpServer')) { + return; + } + + $subject = $this->getLanguage()->translate('passwordChangeLinkEmailSubject', 'messages', 'User'); + $body = $this->getLanguage()->translate('passwordChangeLinkEmailBody', 'messages', 'User'); + + $link = $this->getConfig()->get('siteUrl') . '?entryPoint=changePassword&id=' . $requestId; + + $body = str_replace('{link}', $this->getConfig()->get('siteUrl'), $body); + + $email->set(array( + 'subject' => $subject, + 'body' => $body, + 'isHtml' => false, + 'to' => $emailAddress + )); + + $this->getMailSender()->send($email); + } + public function deleteEntity($id) { if ($id == 'system') { diff --git a/frontend/client/res/templates/login.tpl b/frontend/client/res/templates/login.tpl index f71541905a..5561e60645 100644 --- a/frontend/client/res/templates/login.tpl +++ b/frontend/client/res/templates/login.tpl @@ -1,5 +1,5 @@
-
+
@@ -8,15 +8,16 @@
- +
- +
- + {{translate 'Forgot Password?' scope='User'}} +
diff --git a/frontend/client/res/templates/modals/password-change-request.tpl b/frontend/client/res/templates/modals/password-change-request.tpl new file mode 100644 index 0000000000..f3becd2825 --- /dev/null +++ b/frontend/client/res/templates/modals/password-change-request.tpl @@ -0,0 +1,15 @@ + +
+ +
+ +
+
+
+ +
+ +
+
+ + diff --git a/frontend/client/src/views/login.js b/frontend/client/src/views/login.js index 8a541dd279..a9656b029b 100644 --- a/frontend/client/src/views/login.js +++ b/frontend/client/src/views/login.js @@ -36,6 +36,9 @@ Espo.define('Views.Login', 'View', function (Dep) { 'submit #login-form': function (e) { this.login(); return false; + }, + 'click a[data-action="passwordChangeRequest"]': function (e) { + this.showPasswordChangeRequest(); } }, @@ -62,17 +65,17 @@ Espo.define('Views.Login', 'View', function (Dep) { if (userName == '') { var $el = $("#field-userName"); - var message = this.getLanguage().translate('Username can not be empty!'); + var message = this.getLanguage().translate('Username can not be empty', 'labels', 'User'); $el.popover({ placement: 'bottom', content: message, trigger: 'manual', }).popover('show'); - var cell = $el.closest('.form-group'); - cell.addClass('has-error'); + var $cell = $el.closest('.form-group'); + $cell.addClass('has-error'); this.$el.one('mousedown click', function () { - cell.removeClass('has-error'); + $cell.removeClass('has-error'); $el.popover('destroy'); }); return; @@ -118,6 +121,15 @@ Espo.define('Views.Login', 'View', function (Dep) { }); this.notify('Wrong username/password', 'error'); }, + + showPasswordChangeRequest: function () { + this.notify('Please wait...'); + this.createView('passwordChangeRequest', 'Modals.PasswordChangeRequest', { + }, function (view) { + view.render(); + view.notify(false); + }); + } }); }); diff --git a/frontend/client/src/views/modals/password-change-request.js b/frontend/client/src/views/modals/password-change-request.js new file mode 100644 index 0000000000..4288a89d9f --- /dev/null +++ b/frontend/client/src/views/modals/password-change-request.js @@ -0,0 +1,141 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014 Yuri Kuznetsov, Taras Machyshyn, Oleksiy Avramenko + * Website: http://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://www.gnu.org/licenses/. + ************************************************************************/ + +Espo.define('Views.Modals.PasswordChangeRequest', 'Views.Modal', function (Dep) { + + return Dep.extend({ + + cssName: 'password-change-request', + + template: 'modals.password-change-request', + + setup: function () { + + this.buttons = [ + { + name: 'submit', + label: 'Submit', + style: 'danger', + onClick: function (dialog) { + this.submit(); + }.bind(this) + }, + { + name: 'cancel', + label: 'Cancel', + onClick: function (dialog) { + dialog.close(); + } + } + ]; + + this.header = this.translate('Password Change Request', 'labels', 'User'); + }, + + submit: function () { + var $userName = this.$el.find('input[name="userName"]'); + var $emailAddress = this.$el.find('input[name="emailAddress"]'); + + $userName.popover('destroy'); + $emailAddress.popover('destroy'); + + var userName = $userName.val(); + var emailAddress = $emailAddress.val(); + + var isValid = true; + if (userName == '') { + isValid = false; + + var message = this.getLanguage().translate('Username can not be empty', 'labels', 'User'); + + $userName.popover({ + placement: 'bottom', + content: message, + trigger: 'manual', + }).popover('show'); + + var $cellUserName = $userName.closest('.form-group'); + $cellUserName.addClass('has-error'); + + + $userName.one('mousedown click', function () { + $cellUserName.removeClass('has-error'); + $userName.popover('destroy'); + }); + + } + + var isValid = true; + if (emailAddress == '') { + isValid = false; + + var message = this.getLanguage().translate('Email Address can not be empty', 'labels', 'User'); + + $emailAddress.popover({ + placement: 'bottom', + content: message, + trigger: 'manual', + }).popover('show'); + + var $cellEmailAddress = $emailAddress.closest('.form-group'); + $cellEmailAddress.addClass('has-error'); + + $emailAddress.one('mousedown click', function () { + $cellEmailAddress.removeClass('has-error'); + $emailAddress.popover('destroy'); + }); + } + + if (!isValid) return; + + $submit = this.$el.find('.msg-box'); + $submit.addClass('disabled'); + this.notify('Please wait...'); + + $.ajax({ + url: 'PasswordChangeRequest', + type: 'POST', + data: JSON.stringify({ + userName: userName, + emailAddress: emailAddress + }), + error: function (xhr) { + if (xhr.status == 404) { + this.notify(this.translate('Username/Email Address not found', 'labels', 'User'), 'error'); + xhr.errorIsHandled = true; + } + $submit.removeClass('disabled'); + }.bind(this) + }).done(function () { + var msg = this.translate('The unique link has been sent to the specified email address.', 'labels', 'User'); + + this.$el.find('.cell-userName').addClass('hidden'); + this.$el.find('.cell-emailAddress').addClass('hidden'); + $submit.addClass('hidden'); + this.$el.find('.msg-box').removeClass('hidden'); + + this.$el.find('.msg-box').html('' + msg + ''); + }.bind(this)); + } + + }); +}); +