diff --git a/back/methods/account/recover-password.js b/back/methods/account/recover-password.js new file mode 100644 index 000000000..ddea76829 --- /dev/null +++ b/back/methods/account/recover-password.js @@ -0,0 +1,30 @@ +module.exports = Self => { + Self.remoteMethod('recoverPassword', { + description: 'Send email to the user', + accepts: [ + { + arg: 'email', + type: 'string', + description: 'The email of user', + required: true + } + ], + http: { + path: `/recoverPassword`, + verb: 'POST' + } + }); + + Self.recoverPassword = async function(email) { + const models = Self.app.models; + + try { + await models.user.resetPassword({email, emailTemplate: 'recover-password'}); + } catch (err) { + if (err.code === 'EMAIL_NOT_FOUND') + return; + else + throw err; + } + }; +}; diff --git a/back/methods/account/specs/set-password.spec.js b/back/methods/account/specs/set-password.spec.js index c76fd52b8..fe71873de 100644 --- a/back/methods/account/specs/set-password.spec.js +++ b/back/methods/account/specs/set-password.spec.js @@ -1,6 +1,6 @@ const app = require('vn-loopback/server/server'); -describe('account changePassword()', () => { +describe('account setPassword()', () => { it('should throw an error when password does not meet requirements', async() => { let req = app.models.Account.setPassword(1, 'insecurePass'); diff --git a/back/models/account.js b/back/models/account.js index f74052b5c..c2502380a 100644 --- a/back/models/account.js +++ b/back/models/account.js @@ -1,4 +1,7 @@ +/* eslint max-len: ["error", { "code": 150 }]*/ const md5 = require('md5'); +const LoopBackContext = require('loopback-context'); +const {Email} = require('vn-print'); module.exports = Self => { require('../methods/account/login')(Self); @@ -6,6 +9,7 @@ module.exports = Self => { require('../methods/account/acl')(Self); require('../methods/account/change-password')(Self); require('../methods/account/set-password')(Self); + require('../methods/account/recover-password')(Self); require('../methods/account/validate-token')(Self); require('../methods/account/privileges')(Self); @@ -27,17 +31,62 @@ module.exports = Self => { ctx.data.password = md5(ctx.data.password); }); + Self.afterRemote('prototype.patchAttributes', async(ctx, instance) => { + if (!ctx.args || !ctx.args.data.email) return; + const models = Self.app.models; + + const loopBackContext = LoopBackContext.getCurrentContext(); + const httpCtx = {req: loopBackContext.active}; + const httpRequest = httpCtx.req.http.req; + const headers = httpRequest.headers; + const origin = headers.origin; + const url = origin.split(':'); + + const userId = ctx.instance.id; + const user = await models.user.findById(userId); + + class Mailer { + async send(verifyOptions, cb) { + const params = { + url: verifyOptions.verifyHref, + recipient: verifyOptions.to, + lang: ctx.req.getLocale() + }; + + const email = new Email('email-verify', params); + email.send(); + + cb(null, verifyOptions.to); + } + } + + const options = { + type: 'email', + to: instance.email, + from: {}, + redirect: `${origin}/#!/account/${instance.id}/basic-data?emailConfirmed`, + template: false, + mailer: new Mailer, + host: url[1].split('/')[2], + port: url[2], + protocol: url[0], + user: Self + }; + + await user.verify(options); + }); + Self.remoteMethod('getCurrentUserData', { description: 'Gets the current user data', accepts: [ { arg: 'ctx', - type: 'Object', + type: 'object', http: {source: 'context'} } ], returns: { - type: 'Object', + type: 'object', root: true }, http: { @@ -58,7 +107,7 @@ module.exports = Self => { * * @param {Integer} userId The user id * @param {String} name The role name - * @param {Object} options Options + * @param {object} options Options * @return {Boolean} %true if user has the role, %false otherwise */ Self.hasRole = async function(userId, name, options) { @@ -70,8 +119,8 @@ module.exports = Self => { * Get all user roles. * * @param {Integer} userId The user id - * @param {Object} options Options - * @return {Object} User role list + * @param {object} options Options + * @return {object} User role list */ Self.getRoles = async(userId, options) => { let result = await Self.rawSql( diff --git a/back/models/account.json b/back/models/account.json index d0c17e70f..5e35c711a 100644 --- a/back/models/account.json +++ b/back/models/account.json @@ -40,6 +40,9 @@ "email": { "type": "string" }, + "emailVerified": { + "type": "boolean" + }, "created": { "type": "date" }, @@ -88,16 +91,23 @@ "principalType": "ROLE", "principalId": "$everyone", "permission": "ALLOW" - }, + }, + { + "property": "recoverPassword", + "accessType": "EXECUTE", + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "ALLOW" + }, { - "property": "logout", + "property": "logout", "accessType": "EXECUTE", "principalType": "ROLE", "principalId": "$authenticated", "permission": "ALLOW" }, { - "property": "validateToken", + "property": "validateToken", "accessType": "EXECUTE", "principalType": "ROLE", "principalId": "$authenticated", diff --git a/back/models/specs/account.spec.js b/back/models/specs/account.spec.js index c52bc4378..f31c81b75 100644 --- a/back/models/specs/account.spec.js +++ b/back/models/specs/account.spec.js @@ -1,14 +1,14 @@ -const app = require('vn-loopback/server/server'); +const models = require('vn-loopback/server/server').models; describe('loopback model Account', () => { it('should return true if the user has the given role', async() => { - let result = await app.models.Account.hasRole(1, 'employee'); + let result = await models.Account.hasRole(1, 'employee'); expect(result).toBeTruthy(); }); it('should return false if the user doesnt have the given role', async() => { - let result = await app.models.Account.hasRole(1, 'administrator'); + let result = await models.Account.hasRole(1, 'administrator'); expect(result).toBeFalsy(); }); diff --git a/back/models/specs/user.spec.js b/back/models/specs/user.spec.js new file mode 100644 index 000000000..124afdc0c --- /dev/null +++ b/back/models/specs/user.spec.js @@ -0,0 +1,32 @@ +const models = require('vn-loopback/server/server').models; +const LoopBackContext = require('loopback-context'); + +describe('account recoverPassword()', () => { + const userId = 1107; + + const activeCtx = { + accessToken: {userId: userId}, + http: { + req: { + headers: {origin: 'http://localhost'} + } + } + }; + + beforeEach(() => { + spyOn(LoopBackContext, 'getCurrentContext').and.returnValue({ + active: activeCtx + }); + }); + + it('should send email with token', async() => { + const userId = 1107; + const user = await models.Account.findById(userId); + + await models.Account.recoverPassword(user.email); + + const result = await models.AccessToken.findOne({where: {userId: userId}}); + + expect(result).toBeDefined(); + }); +}); diff --git a/back/models/user.js b/back/models/user.js new file mode 100644 index 000000000..b24d702b3 --- /dev/null +++ b/back/models/user.js @@ -0,0 +1,27 @@ +const LoopBackContext = require('loopback-context'); +const {Email} = require('vn-print'); + +module.exports = function(Self) { + Self.on('resetPasswordRequest', async function(info) { + const loopBackContext = LoopBackContext.getCurrentContext(); + const httpCtx = {req: loopBackContext.active}; + const httpRequest = httpCtx.req.http.req; + const headers = httpRequest.headers; + const origin = headers.origin; + + const user = await Self.app.models.Account.findById(info.user.id); + const params = { + recipient: info.email, + lang: user.lang, + url: `${origin}/#!/reset-password?access_token=${info.accessToken.id}` + }; + + const options = Object.assign({}, info.options); + for (const param in options) + params[param] = options[param]; + + const email = new Email(options.emailTemplate, params); + + return email.send(); + }); +}; diff --git a/db/changes/10502-november/00-aclUserPassword.sql b/db/changes/10502-november/00-aclUserPassword.sql new file mode 100644 index 000000000..b92b54c28 --- /dev/null +++ b/db/changes/10502-november/00-aclUserPassword.sql @@ -0,0 +1,2 @@ +DELETE FROM `salix`.`ACL` + WHERE model = 'UserPassword'; diff --git a/e2e/helpers/selectors.js b/e2e/helpers/selectors.js index 06fabe3e6..f550e3a9d 100644 --- a/e2e/helpers/selectors.js +++ b/e2e/helpers/selectors.js @@ -29,6 +29,11 @@ export default { firstModulePinIcon: 'vn-home a:nth-child(1) vn-icon[icon="push_pin"]', firstModuleRemovePinIcon: 'vn-home a:nth-child(1) vn-icon[icon="remove_circle"]' }, + recoverPassword: { + recoverPasswordButton: 'vn-login a[ui-sref="recoverPassword"]', + email: 'vn-recover-password vn-textfield[ng-model="$ctrl.email"]', + sendEmailButton: 'vn-recover-password vn-submit', + }, accountIndex: { addAccount: 'vn-user-index button vn-icon[icon="add"]', newName: 'vn-user-create vn-textfield[ng-model="$ctrl.user.name"]', diff --git a/e2e/paths/01-salix/04_recoverPassword.spec.js b/e2e/paths/01-salix/04_recoverPassword.spec.js new file mode 100644 index 000000000..80ef32cb5 --- /dev/null +++ b/e2e/paths/01-salix/04_recoverPassword.spec.js @@ -0,0 +1,40 @@ +import selectors from '../../helpers/selectors'; +import getBrowser from '../../helpers/puppeteer'; + +describe('Login path', async() => { + let browser; + let page; + + beforeAll(async() => { + browser = await getBrowser(); + page = browser.page; + + await page.waitToClick(selectors.recoverPassword.recoverPasswordButton); + await page.waitForState('recoverPassword'); + }); + + afterAll(async() => { + await browser.close(); + }); + + it('should not throw error if not exist user', async() => { + await page.write(selectors.recoverPassword.email, 'fakeEmail@mydomain.com'); + await page.waitToClick(selectors.recoverPassword.sendEmailButton); + + const message = await page.waitForSnackbar(); + + expect(message.text).toContain('Notification sent!'); + }); + + it('should send email', async() => { + await page.waitForState('login'); + await page.waitToClick(selectors.recoverPassword.recoverPasswordButton); + + await page.write(selectors.recoverPassword.email, 'BruceWayne@mydomain.com'); + await page.waitToClick(selectors.recoverPassword.sendEmailButton); + const message = await page.waitForSnackbar(); + await page.waitForState('login'); + + expect(message.text).toContain('Notification sent!'); + }); +}); diff --git a/e2e/paths/05-ticket/20_future.spec.js b/e2e/paths/05-ticket/20_future.spec.js index 4fee9523b..6db2bf4f0 100644 --- a/e2e/paths/05-ticket/20_future.spec.js +++ b/e2e/paths/05-ticket/20_future.spec.js @@ -5,80 +5,78 @@ describe('Ticket Future path', () => { let browser; let page; - beforeAll(async () => { + beforeAll(async() => { browser = await getBrowser(); page = browser.page; await page.loginAndModule('employee', 'ticket'); await page.accessToSection('ticket.future'); }); - afterAll(async () => { + afterAll(async() => { await browser.close(); }); const now = new Date(); const tomorrow = new Date(now.getDate() + 1); - const ticket = { - originDated: now, - futureDated: now, - linesMax: '9999', - litersMax: '9999', - warehouseFk: 'Warehouse One' - }; - it('should show errors snackbar because of the required data', async () => { + it('should show errors snackbar because of the required data', async() => { await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton); await page.clearInput(selectors.ticketFuture.warehouseFk); await page.waitToClick(selectors.ticketFuture.submit); let message = await page.waitForSnackbar(); + expect(message.text).toContain('warehouseFk is a required argument'); await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton); await page.clearInput(selectors.ticketFuture.litersMax); await page.waitToClick(selectors.ticketFuture.submit); message = await page.waitForSnackbar(); + expect(message.text).toContain('litersMax is a required argument'); await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton); await page.clearInput(selectors.ticketFuture.linesMax); await page.waitToClick(selectors.ticketFuture.submit); message = await page.waitForSnackbar(); + expect(message.text).toContain('linesMax is a required argument'); await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton); await page.clearInput(selectors.ticketFuture.futureDated); await page.waitToClick(selectors.ticketFuture.submit); message = await page.waitForSnackbar(); + expect(message.text).toContain('futureDated is a required argument'); await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton); await page.clearInput(selectors.ticketFuture.originDated); await page.waitToClick(selectors.ticketFuture.submit); message = await page.waitForSnackbar(); + expect(message.text).toContain('originDated is a required argument'); }); - it('should search with the required data', async () => { + it('should search with the required data', async() => { await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton); await page.waitToClick(selectors.ticketFuture.submit); await page.waitForNumberOfElements(selectors.ticketFuture.table, 4); }); - it('should search with the origin shipped today', async () => { + it('should search with the origin shipped today', async() => { await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton); await page.pickDate(selectors.ticketFuture.shipped, now); await page.waitToClick(selectors.ticketFuture.submit); await page.waitForNumberOfElements(selectors.ticketFuture.table, 4); }); - it('should search with the origin shipped tomorrow', async () => { + it('should search with the origin shipped tomorrow', async() => { await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton); await page.pickDate(selectors.ticketFuture.shipped, tomorrow); await page.waitToClick(selectors.ticketFuture.submit); await page.waitForNumberOfElements(selectors.ticketFuture.table, 0); }); - it('should search with the destination shipped today', async () => { + it('should search with the destination shipped today', async() => { await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton); await page.clearInput(selectors.ticketFuture.shipped); await page.pickDate(selectors.ticketFuture.tfShipped, now); @@ -86,14 +84,14 @@ describe('Ticket Future path', () => { await page.waitForNumberOfElements(selectors.ticketFuture.table, 4); }); - it('should search with the destination shipped tomorrow', async () => { + it('should search with the destination shipped tomorrow', async() => { await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton); await page.pickDate(selectors.ticketFuture.tfShipped, tomorrow); await page.waitToClick(selectors.ticketFuture.submit); await page.waitForNumberOfElements(selectors.ticketFuture.table, 0); }); - it('should search with the origin IPT', async () => { + it('should search with the origin IPT', async() => { await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton); await page.clearInput(selectors.ticketFuture.shipped); @@ -108,7 +106,7 @@ describe('Ticket Future path', () => { await page.waitForNumberOfElements(selectors.ticketFuture.table, 0); }); - it('should search with the destination IPT', async () => { + it('should search with the destination IPT', async() => { await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton); await page.clearInput(selectors.ticketFuture.shipped); @@ -123,7 +121,7 @@ describe('Ticket Future path', () => { await page.waitForNumberOfElements(selectors.ticketFuture.table, 0); }); - it('should search with the origin grouped state', async () => { + it('should search with the origin grouped state', async() => { await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton); await page.clearInput(selectors.ticketFuture.shipped); @@ -138,7 +136,7 @@ describe('Ticket Future path', () => { await page.waitForNumberOfElements(selectors.ticketFuture.table, 3); }); - it('should search with the destination grouped state', async () => { + it('should search with the destination grouped state', async() => { await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton); await page.clearInput(selectors.ticketFuture.shipped); @@ -164,10 +162,10 @@ describe('Ticket Future path', () => { await page.waitForNumberOfElements(selectors.ticketFuture.table, 4); }); - it('should search in smart-table with an ID Origin', async () => { + it('should search in smart-table with an ID Origin', async() => { await page.waitToClick(selectors.ticketFuture.tableButtonSearch); - await page.write(selectors.ticketFuture.tableId, "13"); - await page.keyboard.press("Enter"); + await page.write(selectors.ticketFuture.tableId, '13'); + await page.keyboard.press('Enter'); await page.waitForNumberOfElements(selectors.ticketFuture.table, 2); await page.waitToClick(selectors.ticketFuture.tableButtonSearch); @@ -176,10 +174,10 @@ describe('Ticket Future path', () => { await page.waitForNumberOfElements(selectors.ticketFuture.table, 4); }); - it('should search in smart-table with an ID Destination', async () => { + it('should search in smart-table with an ID Destination', async() => { await page.waitToClick(selectors.ticketFuture.tableButtonSearch); - await page.write(selectors.ticketFuture.tableTfId, "12"); - await page.keyboard.press("Enter"); + await page.write(selectors.ticketFuture.tableTfId, '12'); + await page.keyboard.press('Enter'); await page.waitForNumberOfElements(selectors.ticketFuture.table, 5); await page.waitToClick(selectors.ticketFuture.tableButtonSearch); @@ -188,7 +186,7 @@ describe('Ticket Future path', () => { await page.waitForNumberOfElements(selectors.ticketFuture.table, 4); }); - it('should search in smart-table with an IPT Origin', async () => { + it('should search in smart-table with an IPT Origin', async() => { await page.waitToClick(selectors.ticketFuture.tableButtonSearch); await page.autocompleteSearch(selectors.ticketFuture.tableIpt, 'Vertical'); await page.waitForNumberOfElements(selectors.ticketFuture.table, 1); @@ -199,7 +197,7 @@ describe('Ticket Future path', () => { await page.waitForNumberOfElements(selectors.ticketFuture.table, 4); }); - it('should search in smart-table with an IPT Destination', async () => { + it('should search in smart-table with an IPT Destination', async() => { await page.waitToClick(selectors.ticketFuture.tableButtonSearch); await page.autocompleteSearch(selectors.ticketFuture.tableTfIpt, 'Vertical'); await page.waitForNumberOfElements(selectors.ticketFuture.table, 1); @@ -210,10 +208,10 @@ describe('Ticket Future path', () => { await page.waitForNumberOfElements(selectors.ticketFuture.table, 4); }); - it('should search in smart-table with especified Lines', async () => { + it('should search in smart-table with especified Lines', async() => { await page.waitToClick(selectors.ticketFuture.tableButtonSearch); - await page.write(selectors.ticketFuture.tableLines, "0"); - await page.keyboard.press("Enter"); + await page.write(selectors.ticketFuture.tableLines, '0'); + await page.keyboard.press('Enter'); await page.waitForNumberOfElements(selectors.ticketFuture.table, 1); await page.waitToClick(selectors.ticketFuture.tableButtonSearch); @@ -222,8 +220,8 @@ describe('Ticket Future path', () => { await page.waitForNumberOfElements(selectors.ticketFuture.table, 4); await page.waitToClick(selectors.ticketFuture.tableButtonSearch); - await page.write(selectors.ticketFuture.tableLines, "1"); - await page.keyboard.press("Enter"); + await page.write(selectors.ticketFuture.tableLines, '1'); + await page.keyboard.press('Enter'); await page.waitForNumberOfElements(selectors.ticketFuture.table, 5); await page.waitToClick(selectors.ticketFuture.tableButtonSearch); @@ -232,10 +230,10 @@ describe('Ticket Future path', () => { await page.waitForNumberOfElements(selectors.ticketFuture.table, 4); }); - it('should search in smart-table with especified Liters', async () => { + it('should search in smart-table with especified Liters', async() => { await page.waitToClick(selectors.ticketFuture.tableButtonSearch); - await page.write(selectors.ticketFuture.tableLiters, "0"); - await page.keyboard.press("Enter"); + await page.write(selectors.ticketFuture.tableLiters, '0'); + await page.keyboard.press('Enter'); await page.waitForNumberOfElements(selectors.ticketFuture.table, 1); await page.waitToClick(selectors.ticketFuture.tableButtonSearch); @@ -244,8 +242,8 @@ describe('Ticket Future path', () => { await page.waitForNumberOfElements(selectors.ticketFuture.table, 4); await page.waitToClick(selectors.ticketFuture.tableButtonSearch); - await page.write(selectors.ticketFuture.tableLiters, "28"); - await page.keyboard.press("Enter"); + await page.write(selectors.ticketFuture.tableLiters, '28'); + await page.keyboard.press('Enter'); await page.waitForNumberOfElements(selectors.ticketFuture.table, 5); await page.waitToClick(selectors.ticketFuture.tableButtonSearch); @@ -254,13 +252,13 @@ describe('Ticket Future path', () => { await page.waitForNumberOfElements(selectors.ticketFuture.table, 4); }); - it('should check the three last tickets and move to the future', async () => { + it('should check the three last tickets and move to the future', async() => { await page.waitToClick(selectors.ticketFuture.multiCheck); await page.waitToClick(selectors.ticketFuture.firstCheck); await page.waitToClick(selectors.ticketFuture.moveButton); await page.waitToClick(selectors.ticketFuture.acceptButton); const message = await page.waitForSnackbar(); + expect(message.text).toContain('Tickets moved successfully!'); }); - }); diff --git a/front/core/lib/component.js b/front/core/lib/component.js index f17db68a2..5695d9449 100644 --- a/front/core/lib/component.js +++ b/front/core/lib/component.js @@ -12,9 +12,10 @@ export default class Component extends EventEmitter { * @param {HTMLElement} $element The main component element * @param {$rootScope.Scope} $scope The element scope * @param {Function} $transclude The transclusion function + * @param {Function} $location The location function */ - constructor($element, $scope, $transclude) { - super(); + constructor($element, $scope, $transclude, $location) { + super($element, $scope, $transclude, $location); this.$ = $scope; if (!$element) return; @@ -164,7 +165,7 @@ export default class Component extends EventEmitter { $transclude.$$boundTransclude.$$slots[slot]; } } -Component.$inject = ['$element', '$scope']; +Component.$inject = ['$element', '$scope', '$location', '$state']; /* * Automatically adds the most used services to the prototype, so they are diff --git a/front/core/services/auth.js b/front/core/services/auth.js index a1dcfa395..04520cd0b 100644 --- a/front/core/services/auth.js +++ b/front/core/services/auth.js @@ -23,7 +23,10 @@ export default class Auth { initialize() { let criteria = { - to: state => state.name != 'login' + to: state => { + const outLayout = ['login', 'recoverPassword', 'resetPassword']; + return !outLayout.some(ol => ol == state.name); + } }; this.$transitions.onStart(criteria, transition => { if (this.loggedIn) diff --git a/front/salix/components/app/app.html b/front/salix/components/app/app.html index d32c9f68b..f14fab2dd 100644 --- a/front/salix/components/app/app.html +++ b/front/salix/components/app/app.html @@ -1,9 +1,8 @@ - - + diff --git a/front/salix/components/app/app.js b/front/salix/components/app/app.js index 1f8cdb46e..20f0ad969 100644 --- a/front/salix/components/app/app.js +++ b/front/salix/components/app/app.js @@ -9,13 +9,20 @@ import Component from 'core/lib/component'; * @property {SideMenu} rightMenu The left menu, if it's present */ export default class App extends Component { + constructor($element, $, $location, $state) { + super($element, $, $location, $state); + this.$location = $location; + this.$state = $state; + } + $postLink() { this.vnApp.logger = this; } get showLayout() { - let state = this.$state.current.name; - return state && state != 'login'; + const state = this.$state.current.name || this.$location.$$path.substring(1).replace('/', '.'); + const outLayout = ['login', 'recoverPassword', 'resetPassword']; + return state && !outLayout.some(ol => ol == state); } $onDestroy() { diff --git a/front/salix/components/index.js b/front/salix/components/index.js index ce4ad585a..dbe9fe81a 100644 --- a/front/salix/components/index.js +++ b/front/salix/components/index.js @@ -5,7 +5,10 @@ import './descriptor-popover'; import './home/home'; import './layout'; import './left-menu/left-menu'; +import './login/index'; import './login/login'; +import './login/recover-password'; +import './login/reset-password'; import './module-card'; import './module-main'; import './side-menu/side-menu'; diff --git a/front/salix/components/login/index.html b/front/salix/components/login/index.html new file mode 100644 index 000000000..186979f8c --- /dev/null +++ b/front/salix/components/login/index.html @@ -0,0 +1,6 @@ +
+ +
+ +
+
diff --git a/front/salix/components/login/index.js b/front/salix/components/login/index.js new file mode 100644 index 000000000..f0e21fa29 --- /dev/null +++ b/front/salix/components/login/index.js @@ -0,0 +1,16 @@ +import ngModule from '../../module'; +import Component from 'core/lib/component'; +import './style.scss'; + +export default class OutLayout extends Component { + constructor($element, $scope) { + super($element, $scope); + } +} + +OutLayout.$inject = ['$element', '$scope']; + +ngModule.vnComponent('vnOutLayout', { + template: require('./index.html'), + controller: OutLayout +}); diff --git a/front/salix/components/login/locale/en.yml b/front/salix/components/login/locale/en.yml index c59a6dd8e..1ddd454b7 100644 --- a/front/salix/components/login/locale/en.yml +++ b/front/salix/components/login/locale/en.yml @@ -1,4 +1,8 @@ User: User Password: Password Do not close session: Do not close session -Enter: Enter \ No newline at end of file +Enter: Enter +Password requirements: > + The password must have at least {{ length }} length characters, + {{nAlpha}} alphabetic characters, {{nUpper}} capital letters, {{nDigits}} + digits and {{nPunct}} symbols (Ex: $%&.) diff --git a/front/salix/components/login/locale/es.yml b/front/salix/components/login/locale/es.yml index 9c9ba5905..e3a5815c1 100644 --- a/front/salix/components/login/locale/es.yml +++ b/front/salix/components/login/locale/es.yml @@ -1,4 +1,16 @@ User: Usuario Password: Contraseña +Email: Correo electrónico Do not close session: No cerrar sesión -Enter: Entrar \ No newline at end of file +Enter: Entrar +I do not remember my password: No recuerdo mi contraseña +Recover password: Recuperar contraseña +We will sent you an email to recover your password: Te enviaremos un correo para restablecer tu contraseña +Notification sent!: ¡Notificación enviada! +Reset password: Restrablecer contraseña +New password: Nueva contraseña +Repeat password: Repetir contraseña +Password requirements: > + La contraseña debe tener al menos {{ length }} caracteres de longitud, + {{nAlpha}} caracteres alfabéticos, {{nUpper}} letras mayúsculas, {{nDigits}} + dígitos y {{nPunct}} símbolos (Ej: $%&.) diff --git a/front/salix/components/login/login.html b/front/salix/components/login/login.html index b15714a23..a078fa0af 100644 --- a/front/salix/components/login/login.html +++ b/front/salix/components/login/login.html @@ -1,27 +1,27 @@ -
- -
- - - - - - - -
+ + + + + + + diff --git a/front/salix/components/login/recover-password.html b/front/salix/components/login/recover-password.html new file mode 100644 index 000000000..73f5401d9 --- /dev/null +++ b/front/salix/components/login/recover-password.html @@ -0,0 +1,17 @@ +
Recover password
+ + +
+ We will sent you an email to recover your password +
+ diff --git a/front/salix/components/login/recover-password.js b/front/salix/components/login/recover-password.js new file mode 100644 index 000000000..fa9bfc459 --- /dev/null +++ b/front/salix/components/login/recover-password.js @@ -0,0 +1,37 @@ +import ngModule from '../../module'; +import './style.scss'; + +export default class Controller { + constructor($scope, $element, $http, vnApp, $translate, $state) { + Object.assign(this, { + $scope, + $element, + $http, + vnApp, + $translate, + $state + }); + } + + goToLogin() { + this.vnApp.showSuccess(this.$translate.instant('Notification sent!')); + this.$state.go('login'); + } + + submit() { + const params = { + email: this.email + }; + + this.$http.post('Accounts/recoverPassword', params) + .then(() => { + this.goToLogin(); + }); + } +} +Controller.$inject = ['$scope', '$element', '$http', 'vnApp', '$translate', '$state']; + +ngModule.vnComponent('vnRecoverPassword', { + template: require('./recover-password.html'), + controller: Controller +}); diff --git a/front/salix/components/login/reset-password.html b/front/salix/components/login/reset-password.html new file mode 100644 index 000000000..bdbdc113e --- /dev/null +++ b/front/salix/components/login/reset-password.html @@ -0,0 +1,19 @@ +
Reset password
+ + + + + diff --git a/front/salix/components/login/reset-password.js b/front/salix/components/login/reset-password.js new file mode 100644 index 000000000..9ee1fdb62 --- /dev/null +++ b/front/salix/components/login/reset-password.js @@ -0,0 +1,48 @@ +import ngModule from '../../module'; +import './style.scss'; + +export default class Controller { + constructor($scope, $element, $http, vnApp, $translate, $state, $location) { + Object.assign(this, { + $scope, + $element, + $http, + vnApp, + $translate, + $state, + $location + }); + } + + $onInit() { + this.$http.get('UserPasswords/findOne') + .then(res => { + this.passRequirements = res.data; + }); + } + + submit() { + if (!this.newPassword) + throw new UserError(`You must enter a new password`); + if (this.newPassword != this.repeatPassword) + throw new UserError(`Passwords don't match`); + + const headers = { + Authorization: this.$location.$$search.access_token + }; + + const newPassword = this.newPassword; + + this.$http.post('users/reset-password', {newPassword}, {headers}) + .then(() => { + this.vnApp.showSuccess(this.$translate.instant('Password changed!')); + this.$state.go('login'); + }); + } +} +Controller.$inject = ['$scope', '$element', '$http', 'vnApp', '$translate', '$state', '$location']; + +ngModule.vnComponent('vnResetPassword', { + template: require('./reset-password.html'), + controller: Controller +}); diff --git a/front/salix/components/login/style.scss b/front/salix/components/login/style.scss index 8ebf2a68c..8985893f2 100644 --- a/front/salix/components/login/style.scss +++ b/front/salix/components/login/style.scss @@ -1,6 +1,31 @@ @import "variables"; -vn-login { +vn-login, +vn-reset-password, +vn-recover-password{ + .footer { + margin-top: 32px; + text-align: center; + position: relative; + & > .vn-submit { + display: block; + + & > input { + display: block; + width: 100%; + } + } + & > .spinner-wrapper { + position: absolute; + width: 0; + top: 3px; + right: -8px; + overflow: visible; + } + } +} + +vn-out-layout{ position: absolute; height: 100%; width: 100%; @@ -39,28 +64,17 @@ vn-login { white-space: inherit; } } - & > .footer { - margin-top: 32px; - text-align: center; - position: relative; - - & > vn-submit { - display: block; - - & > input { - display: block; - width: 100%; - } - } - & > .spinner-wrapper { - position: absolute; - width: 0; - top: 3px; - right: -8px; - overflow: visible; - } - } } + + h5{ + color: $color-primary; + } + + .text-secondary{ + text-align: center; + padding-bottom: 16px; + } + } @media screen and (max-width: 600px) { @@ -71,4 +85,8 @@ vn-login { box-shadow: none; } } + + a{ + color: $color-primary; + } } diff --git a/front/salix/module.js b/front/salix/module.js index a8de61ae0..01df01a67 100644 --- a/front/salix/module.js +++ b/front/salix/module.js @@ -112,7 +112,7 @@ function $exceptionHandler(vnApp, $window, $state, $injector) { switch (exception.status) { case 401: - if ($state.current.name != 'login') { + if (!$state.current.name.includes('login')) { messageT = 'Session has expired'; let params = {continue: $window.location.hash}; $state.go('login', params); diff --git a/front/salix/routes.js b/front/salix/routes.js index 600907ff1..be893800f 100644 --- a/front/salix/routes.js +++ b/front/salix/routes.js @@ -9,9 +9,17 @@ function config($stateProvider, $urlRouterProvider) { .state('login', { url: '/login?continue', description: 'Login', - views: { - login: {template: ''} - } + template: '' + }) + .state('recoverPassword', { + url: '/recover-password', + description: 'Recover-password', + template: 'asd' + }) + .state('resetPassword', { + url: '/reset-password', + description: 'Reset-password', + template: '' }) .state('home', { url: '/', diff --git a/loopback/locale/en.json b/loopback/locale/en.json index ad110ec98..24a3a40a8 100644 --- a/loopback/locale/en.json +++ b/loopback/locale/en.json @@ -131,12 +131,15 @@ "Fichadas impares": "Odd signs", "Descanso diario 9h.": "Daily rest 9h.", "Descanso semanal 36h. / 72h.": "Weekly rest 36h. / 72h.", + "Verify email": "Verify email", + "Click on the following link to verify this email. If you haven't requested this email, just ignore it": "Click on the following link to verify this email. If you haven't requested this email, just ignore it", "Password does not meet requirements": "Password does not meet requirements", "You don't have privileges to change the zone": "You don't have privileges to change the zone or for these parameters there are more than one shipping options, talk to agencies", "Not enough privileges to edit a client": "Not enough privileges to edit a client", "Claim pickup order sent": "Claim pickup order sent [({{claimId}})]({{{claimUrl}}}) to client *{{clientName}}*", "You don't have grant privilege": "You don't have grant privilege", "You don't own the role and you can't assign it to another user": "You don't own the role and you can't assign it to another user", + "Email verify": "Email verify", "Ticket merged": "Ticket [{{id}}]({{{fullPath}}}) ({{{originDated}}}) merged with [{{tfId}}]({{{fullPathFuture}}}) ({{{futureDated}}})", "Sale(s) blocked, please contact production": "Sale(s) blocked, please contact production", "Receipt's bank was not found": "Receipt's bank was not found", diff --git a/loopback/locale/es.json b/loopback/locale/es.json index ecaae31b9..932dfe98f 100644 --- a/loopback/locale/es.json +++ b/loopback/locale/es.json @@ -245,6 +245,8 @@ "Already has this status": "Ya tiene este estado", "There aren't records for this week": "No existen registros para esta semana", "Empty data source": "Origen de datos vacio", + "Email verify": "Correo de verificación", + "Landing cannot be lesser than shipment": "Landing cannot be lesser than shipment", "Receipt's bank was not found": "No se encontró el banco del recibo", "This receipt was not compensated": "Este recibo no ha sido compensado", "Client's email was not found": "No se encontró el email del cliente" diff --git a/loopback/server/datasources.json b/loopback/server/datasources.json index 4db642058..00f6bf624 100644 --- a/loopback/server/datasources.json +++ b/loopback/server/datasources.json @@ -113,4 +113,4 @@ "application/x-7z-compressed" ] } -} \ No newline at end of file +} diff --git a/modules/account/back/models/user-password.json b/modules/account/back/models/user-password.json index 1b7e49edd..53909ad1f 100644 --- a/modules/account/back/models/user-password.json +++ b/modules/account/back/models/user-password.json @@ -30,5 +30,13 @@ "type": "number", "required": true } - } + }, + "acls": [ + { + "accessType": "READ", + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "ALLOW" + } + ] } diff --git a/modules/account/front/basic-data/index.js b/modules/account/front/basic-data/index.js index 342297e45..77d3eab26 100644 --- a/modules/account/front/basic-data/index.js +++ b/modules/account/front/basic-data/index.js @@ -2,6 +2,11 @@ import ngModule from '../module'; import Section from 'salix/components/section'; export default class Controller extends Section { + $onInit() { + if (this.$params.emailConfirmed) + this.vnApp.showSuccess(this.$t('Email verified successfully!')); + } + onSubmit() { this.$.watcher.submit() .then(() => this.card.reload()); diff --git a/modules/account/front/basic-data/locale/es.yml b/modules/account/front/basic-data/locale/es.yml new file mode 100644 index 000000000..2ca7bf698 --- /dev/null +++ b/modules/account/front/basic-data/locale/es.yml @@ -0,0 +1 @@ +Email verified successfully!: Correo verificado correctamente! diff --git a/modules/account/front/routes.json b/modules/account/front/routes.json index b96c931c9..a6f2f5d3f 100644 --- a/modules/account/front/routes.json +++ b/modules/account/front/routes.json @@ -74,7 +74,7 @@ } }, { - "url": "/basic-data", + "url": "/basic-data?emailConfirmed", "state": "account.card.basicData", "component": "vn-user-basic-data", "description": "Basic data", diff --git a/print/core/smtp.js b/print/core/smtp.js index a55ba448d..61b115b5a 100644 --- a/print/core/smtp.js +++ b/print/core/smtp.js @@ -8,10 +8,12 @@ module.exports = { this.transporter = nodemailer.createTransport(config.smtp); }, - send(options) { + async send(options) { options.from = `${config.app.senderName} <${config.app.senderEmail}>`; if (process.env.NODE_ENV !== 'production') { + const notProductionError = {message: 'This not production, this email not sended'}; + await this.mailLog(options, notProductionError); if (!config.smtp.auth.user) return Promise.resolve(true); @@ -24,29 +26,35 @@ module.exports = { throw err; }).finally(async() => { - const attachments = []; - if (options.attachments) { - for (let attachment of options.attachments) { - const fileName = attachment.filename; - const filePath = attachment.path; - if (fileName.includes('.png')) continue; - - if (fileName || filePath) - attachments.push(filePath ? filePath : fileName); - } - } - - const fileNames = attachments.join(',\n'); - await db.rawSql(` - INSERT INTO vn.mail (receiver, replyTo, sent, subject, body, attachment, status) - VALUES (?, ?, 1, ?, ?, ?, ?)`, [ - options.to, - options.replyTo, - options.subject, - options.text || options.html, - fileNames, - error && error.message || 'Sent' - ]); + await this.mailLog(options, error); }); + }, + + async mailLog(options, error) { + const attachments = []; + if (options.attachments) { + for (let attachment of options.attachments) { + const fileName = attachment.filename; + const filePath = attachment.path; + if (fileName.includes('.png')) continue; + + if (fileName || filePath) + attachments.push(filePath ? filePath : fileName); + } + } + + const fileNames = attachments.join(',\n'); + + await db.rawSql(` + INSERT INTO vn.mail (receiver, replyTo, sent, subject, body, attachment, status) + VALUES (?, ?, 1, ?, ?, ?, ?)`, [ + options.to, + options.replyTo, + options.subject, + options.text || options.html, + fileNames, + error && error.message || 'Sent' + ]); } + }; diff --git a/print/templates/email/email-verify/assets/css/import.js b/print/templates/email/email-verify/assets/css/import.js new file mode 100644 index 000000000..7360587f7 --- /dev/null +++ b/print/templates/email/email-verify/assets/css/import.js @@ -0,0 +1,13 @@ +const Stylesheet = require(`vn-print/core/stylesheet`); + +const path = require('path'); +const vnPrintPath = path.resolve('print'); + +module.exports = new Stylesheet([ + `${vnPrintPath}/common/css/spacing.css`, + `${vnPrintPath}/common/css/misc.css`, + `${vnPrintPath}/common/css/layout.css`, + `${vnPrintPath}/common/css/email.css`, + `${__dirname}/style.css`]) + .mergeStyles(); + diff --git a/print/templates/email/email-verify/assets/css/style.css b/print/templates/email/email-verify/assets/css/style.css new file mode 100644 index 000000000..5db85befa --- /dev/null +++ b/print/templates/email/email-verify/assets/css/style.css @@ -0,0 +1,5 @@ +.external-link { + border: 2px dashed #8dba25; + border-radius: 3px; + text-align: center +} \ No newline at end of file diff --git a/print/templates/email/email-verify/email-verify.html b/print/templates/email/email-verify/email-verify.html new file mode 100644 index 000000000..b8a6e263c --- /dev/null +++ b/print/templates/email/email-verify/email-verify.html @@ -0,0 +1,48 @@ + + + + + + {{ $t('subject') }} + + + + + + + + +
+ +
+
+
+ +
+
+ +
+
+ +
+
+

+ {{ $t(`click`) }} + {{ $t('subject') }} +

+
+
+ + +
+
+ +
+
+ +
+
+
+
+ + diff --git a/print/templates/email/email-verify/email-verify.js b/print/templates/email/email-verify/email-verify.js new file mode 100755 index 000000000..7f0b80a13 --- /dev/null +++ b/print/templates/email/email-verify/email-verify.js @@ -0,0 +1,17 @@ +const Component = require(`vn-print/core/component`); +const emailHeader = new Component('email-header'); +const emailFooter = new Component('email-footer'); + +module.exports = { + name: 'email-verify', + components: { + 'email-header': emailHeader.build(), + 'email-footer': emailFooter.build() + }, + props: { + url: { + type: [String], + required: true + } + } +}; diff --git a/print/templates/email/email-verify/locale/en.yml b/print/templates/email/email-verify/locale/en.yml new file mode 100644 index 000000000..0298f53b4 --- /dev/null +++ b/print/templates/email/email-verify/locale/en.yml @@ -0,0 +1,3 @@ +subject: Email Verify +title: Email Verify +click: Click on the following link to verify this email. If you haven't requested this email, just ignore it diff --git a/print/templates/email/email-verify/locale/es.yml b/print/templates/email/email-verify/locale/es.yml new file mode 100644 index 000000000..37bd6ef27 --- /dev/null +++ b/print/templates/email/email-verify/locale/es.yml @@ -0,0 +1,3 @@ +subject: Verificar correo +title: Verificar correo +click: Pulsa en el siguiente link para verificar este correo. Si no has pedido este correo, simplemente ignóralo diff --git a/print/templates/email/recover-password/assets/css/import.js b/print/templates/email/recover-password/assets/css/import.js new file mode 100644 index 000000000..7360587f7 --- /dev/null +++ b/print/templates/email/recover-password/assets/css/import.js @@ -0,0 +1,13 @@ +const Stylesheet = require(`vn-print/core/stylesheet`); + +const path = require('path'); +const vnPrintPath = path.resolve('print'); + +module.exports = new Stylesheet([ + `${vnPrintPath}/common/css/spacing.css`, + `${vnPrintPath}/common/css/misc.css`, + `${vnPrintPath}/common/css/layout.css`, + `${vnPrintPath}/common/css/email.css`, + `${__dirname}/style.css`]) + .mergeStyles(); + diff --git a/print/templates/email/recover-password/assets/css/style.css b/print/templates/email/recover-password/assets/css/style.css new file mode 100644 index 000000000..5db85befa --- /dev/null +++ b/print/templates/email/recover-password/assets/css/style.css @@ -0,0 +1,5 @@ +.external-link { + border: 2px dashed #8dba25; + border-radius: 3px; + text-align: center +} \ No newline at end of file diff --git a/print/templates/email/recover-password/locale/es.yml b/print/templates/email/recover-password/locale/es.yml new file mode 100644 index 000000000..c72b108ee --- /dev/null +++ b/print/templates/email/recover-password/locale/es.yml @@ -0,0 +1,3 @@ +subject: Recuperar contraseña +title: Recuperar contraseña +Click on the following link to change your password.: Pulsa en el siguiente link para cambiar tu contraseña. diff --git a/print/templates/email/recover-password/recover-password.html b/print/templates/email/recover-password/recover-password.html new file mode 100644 index 000000000..a654b3d5f --- /dev/null +++ b/print/templates/email/recover-password/recover-password.html @@ -0,0 +1,48 @@ + + + + + + {{ $t('subject') }} + + + + + + + + +
+ +
+
+
+ +
+
+ +
+
+ +
+
+

+ {{ $t('Click on the following link to change your password.') }} + {{ $t('subject') }} +

+
+
+ + +
+
+ +
+
+ +
+
+
+
+ + diff --git a/print/templates/email/recover-password/recover-password.js b/print/templates/email/recover-password/recover-password.js new file mode 100755 index 000000000..b589411a9 --- /dev/null +++ b/print/templates/email/recover-password/recover-password.js @@ -0,0 +1,17 @@ +const Component = require(`vn-print/core/component`); +const emailHeader = new Component('email-header'); +const emailFooter = new Component('email-footer'); + +module.exports = { + name: 'recover-password', + components: { + 'email-header': emailHeader.build(), + 'email-footer': emailFooter.build() + }, + props: { + url: { + type: [String], + required: true + } + } +};