Merge branch 'dev' into 4613-Eliminar-cálculo-portfolio
gitea/salix/pipeline/head This commit looks good
Details
gitea/salix/pipeline/head This commit looks good
Details
This commit is contained in:
commit
2a4e8cdde9
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
|
@ -1,6 +1,6 @@
|
||||||
const app = require('vn-loopback/server/server');
|
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() => {
|
it('should throw an error when password does not meet requirements', async() => {
|
||||||
let req = app.models.Account.setPassword(1, 'insecurePass');
|
let req = app.models.Account.setPassword(1, 'insecurePass');
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
|
/* eslint max-len: ["error", { "code": 150 }]*/
|
||||||
const md5 = require('md5');
|
const md5 = require('md5');
|
||||||
|
const LoopBackContext = require('loopback-context');
|
||||||
|
const {Email} = require('vn-print');
|
||||||
|
|
||||||
module.exports = Self => {
|
module.exports = Self => {
|
||||||
require('../methods/account/login')(Self);
|
require('../methods/account/login')(Self);
|
||||||
|
@ -6,6 +9,7 @@ module.exports = Self => {
|
||||||
require('../methods/account/acl')(Self);
|
require('../methods/account/acl')(Self);
|
||||||
require('../methods/account/change-password')(Self);
|
require('../methods/account/change-password')(Self);
|
||||||
require('../methods/account/set-password')(Self);
|
require('../methods/account/set-password')(Self);
|
||||||
|
require('../methods/account/recover-password')(Self);
|
||||||
require('../methods/account/validate-token')(Self);
|
require('../methods/account/validate-token')(Self);
|
||||||
require('../methods/account/privileges')(Self);
|
require('../methods/account/privileges')(Self);
|
||||||
|
|
||||||
|
@ -27,17 +31,62 @@ module.exports = Self => {
|
||||||
ctx.data.password = md5(ctx.data.password);
|
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', {
|
Self.remoteMethod('getCurrentUserData', {
|
||||||
description: 'Gets the current user data',
|
description: 'Gets the current user data',
|
||||||
accepts: [
|
accepts: [
|
||||||
{
|
{
|
||||||
arg: 'ctx',
|
arg: 'ctx',
|
||||||
type: 'Object',
|
type: 'object',
|
||||||
http: {source: 'context'}
|
http: {source: 'context'}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
returns: {
|
returns: {
|
||||||
type: 'Object',
|
type: 'object',
|
||||||
root: true
|
root: true
|
||||||
},
|
},
|
||||||
http: {
|
http: {
|
||||||
|
@ -58,7 +107,7 @@ module.exports = Self => {
|
||||||
*
|
*
|
||||||
* @param {Integer} userId The user id
|
* @param {Integer} userId The user id
|
||||||
* @param {String} name The role name
|
* @param {String} name The role name
|
||||||
* @param {Object} options Options
|
* @param {object} options Options
|
||||||
* @return {Boolean} %true if user has the role, %false otherwise
|
* @return {Boolean} %true if user has the role, %false otherwise
|
||||||
*/
|
*/
|
||||||
Self.hasRole = async function(userId, name, options) {
|
Self.hasRole = async function(userId, name, options) {
|
||||||
|
@ -70,8 +119,8 @@ module.exports = Self => {
|
||||||
* Get all user roles.
|
* Get all user roles.
|
||||||
*
|
*
|
||||||
* @param {Integer} userId The user id
|
* @param {Integer} userId The user id
|
||||||
* @param {Object} options Options
|
* @param {object} options Options
|
||||||
* @return {Object} User role list
|
* @return {object} User role list
|
||||||
*/
|
*/
|
||||||
Self.getRoles = async(userId, options) => {
|
Self.getRoles = async(userId, options) => {
|
||||||
let result = await Self.rawSql(
|
let result = await Self.rawSql(
|
||||||
|
|
|
@ -40,6 +40,9 @@
|
||||||
"email": {
|
"email": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"emailVerified": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"created": {
|
"created": {
|
||||||
"type": "date"
|
"type": "date"
|
||||||
},
|
},
|
||||||
|
@ -88,16 +91,23 @@
|
||||||
"principalType": "ROLE",
|
"principalType": "ROLE",
|
||||||
"principalId": "$everyone",
|
"principalId": "$everyone",
|
||||||
"permission": "ALLOW"
|
"permission": "ALLOW"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"property": "recoverPassword",
|
||||||
|
"accessType": "EXECUTE",
|
||||||
|
"principalType": "ROLE",
|
||||||
|
"principalId": "$everyone",
|
||||||
|
"permission": "ALLOW"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"property": "logout",
|
"property": "logout",
|
||||||
"accessType": "EXECUTE",
|
"accessType": "EXECUTE",
|
||||||
"principalType": "ROLE",
|
"principalType": "ROLE",
|
||||||
"principalId": "$authenticated",
|
"principalId": "$authenticated",
|
||||||
"permission": "ALLOW"
|
"permission": "ALLOW"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"property": "validateToken",
|
"property": "validateToken",
|
||||||
"accessType": "EXECUTE",
|
"accessType": "EXECUTE",
|
||||||
"principalType": "ROLE",
|
"principalType": "ROLE",
|
||||||
"principalId": "$authenticated",
|
"principalId": "$authenticated",
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
const app = require('vn-loopback/server/server');
|
const models = require('vn-loopback/server/server').models;
|
||||||
|
|
||||||
describe('loopback model Account', () => {
|
describe('loopback model Account', () => {
|
||||||
it('should return true if the user has the given role', async() => {
|
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();
|
expect(result).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false if the user doesnt have the given role', async() => {
|
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();
|
expect(result).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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();
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,2 @@
|
||||||
|
DELETE FROM `salix`.`ACL`
|
||||||
|
WHERE model = 'UserPassword';
|
|
@ -29,6 +29,11 @@ export default {
|
||||||
firstModulePinIcon: 'vn-home a:nth-child(1) vn-icon[icon="push_pin"]',
|
firstModulePinIcon: 'vn-home a:nth-child(1) vn-icon[icon="push_pin"]',
|
||||||
firstModuleRemovePinIcon: 'vn-home a:nth-child(1) vn-icon[icon="remove_circle"]'
|
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: {
|
accountIndex: {
|
||||||
addAccount: 'vn-user-index button vn-icon[icon="add"]',
|
addAccount: 'vn-user-index button vn-icon[icon="add"]',
|
||||||
newName: 'vn-user-create vn-textfield[ng-model="$ctrl.user.name"]',
|
newName: 'vn-user-create vn-textfield[ng-model="$ctrl.user.name"]',
|
||||||
|
|
|
@ -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!');
|
||||||
|
});
|
||||||
|
});
|
|
@ -5,80 +5,78 @@ describe('Ticket Future path', () => {
|
||||||
let browser;
|
let browser;
|
||||||
let page;
|
let page;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async() => {
|
||||||
browser = await getBrowser();
|
browser = await getBrowser();
|
||||||
page = browser.page;
|
page = browser.page;
|
||||||
await page.loginAndModule('employee', 'ticket');
|
await page.loginAndModule('employee', 'ticket');
|
||||||
await page.accessToSection('ticket.future');
|
await page.accessToSection('ticket.future');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async() => {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const tomorrow = new Date(now.getDate() + 1);
|
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.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
||||||
await page.clearInput(selectors.ticketFuture.warehouseFk);
|
await page.clearInput(selectors.ticketFuture.warehouseFk);
|
||||||
await page.waitToClick(selectors.ticketFuture.submit);
|
await page.waitToClick(selectors.ticketFuture.submit);
|
||||||
let message = await page.waitForSnackbar();
|
let message = await page.waitForSnackbar();
|
||||||
|
|
||||||
expect(message.text).toContain('warehouseFk is a required argument');
|
expect(message.text).toContain('warehouseFk is a required argument');
|
||||||
|
|
||||||
await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
||||||
await page.clearInput(selectors.ticketFuture.litersMax);
|
await page.clearInput(selectors.ticketFuture.litersMax);
|
||||||
await page.waitToClick(selectors.ticketFuture.submit);
|
await page.waitToClick(selectors.ticketFuture.submit);
|
||||||
message = await page.waitForSnackbar();
|
message = await page.waitForSnackbar();
|
||||||
|
|
||||||
expect(message.text).toContain('litersMax is a required argument');
|
expect(message.text).toContain('litersMax is a required argument');
|
||||||
|
|
||||||
await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
||||||
await page.clearInput(selectors.ticketFuture.linesMax);
|
await page.clearInput(selectors.ticketFuture.linesMax);
|
||||||
await page.waitToClick(selectors.ticketFuture.submit);
|
await page.waitToClick(selectors.ticketFuture.submit);
|
||||||
message = await page.waitForSnackbar();
|
message = await page.waitForSnackbar();
|
||||||
|
|
||||||
expect(message.text).toContain('linesMax is a required argument');
|
expect(message.text).toContain('linesMax is a required argument');
|
||||||
|
|
||||||
await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
||||||
await page.clearInput(selectors.ticketFuture.futureDated);
|
await page.clearInput(selectors.ticketFuture.futureDated);
|
||||||
await page.waitToClick(selectors.ticketFuture.submit);
|
await page.waitToClick(selectors.ticketFuture.submit);
|
||||||
message = await page.waitForSnackbar();
|
message = await page.waitForSnackbar();
|
||||||
|
|
||||||
expect(message.text).toContain('futureDated is a required argument');
|
expect(message.text).toContain('futureDated is a required argument');
|
||||||
|
|
||||||
await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
await page.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
||||||
await page.clearInput(selectors.ticketFuture.originDated);
|
await page.clearInput(selectors.ticketFuture.originDated);
|
||||||
await page.waitToClick(selectors.ticketFuture.submit);
|
await page.waitToClick(selectors.ticketFuture.submit);
|
||||||
message = await page.waitForSnackbar();
|
message = await page.waitForSnackbar();
|
||||||
|
|
||||||
expect(message.text).toContain('originDated is a required argument');
|
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.openAdvancedSearchButton);
|
||||||
await page.waitToClick(selectors.ticketFuture.submit);
|
await page.waitToClick(selectors.ticketFuture.submit);
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 4);
|
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.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
||||||
await page.pickDate(selectors.ticketFuture.shipped, now);
|
await page.pickDate(selectors.ticketFuture.shipped, now);
|
||||||
await page.waitToClick(selectors.ticketFuture.submit);
|
await page.waitToClick(selectors.ticketFuture.submit);
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 4);
|
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.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
||||||
await page.pickDate(selectors.ticketFuture.shipped, tomorrow);
|
await page.pickDate(selectors.ticketFuture.shipped, tomorrow);
|
||||||
await page.waitToClick(selectors.ticketFuture.submit);
|
await page.waitToClick(selectors.ticketFuture.submit);
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 0);
|
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.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
||||||
await page.clearInput(selectors.ticketFuture.shipped);
|
await page.clearInput(selectors.ticketFuture.shipped);
|
||||||
await page.pickDate(selectors.ticketFuture.tfShipped, now);
|
await page.pickDate(selectors.ticketFuture.tfShipped, now);
|
||||||
|
@ -86,14 +84,14 @@ describe('Ticket Future path', () => {
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 4);
|
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.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
||||||
await page.pickDate(selectors.ticketFuture.tfShipped, tomorrow);
|
await page.pickDate(selectors.ticketFuture.tfShipped, tomorrow);
|
||||||
await page.waitToClick(selectors.ticketFuture.submit);
|
await page.waitToClick(selectors.ticketFuture.submit);
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 0);
|
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.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
||||||
|
|
||||||
await page.clearInput(selectors.ticketFuture.shipped);
|
await page.clearInput(selectors.ticketFuture.shipped);
|
||||||
|
@ -108,7 +106,7 @@ describe('Ticket Future path', () => {
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 0);
|
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.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
||||||
|
|
||||||
await page.clearInput(selectors.ticketFuture.shipped);
|
await page.clearInput(selectors.ticketFuture.shipped);
|
||||||
|
@ -123,7 +121,7 @@ describe('Ticket Future path', () => {
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 0);
|
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.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
||||||
|
|
||||||
await page.clearInput(selectors.ticketFuture.shipped);
|
await page.clearInput(selectors.ticketFuture.shipped);
|
||||||
|
@ -138,7 +136,7 @@ describe('Ticket Future path', () => {
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 3);
|
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.waitToClick(selectors.ticketFuture.openAdvancedSearchButton);
|
||||||
|
|
||||||
await page.clearInput(selectors.ticketFuture.shipped);
|
await page.clearInput(selectors.ticketFuture.shipped);
|
||||||
|
@ -164,10 +162,10 @@ describe('Ticket Future path', () => {
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 4);
|
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.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
||||||
await page.write(selectors.ticketFuture.tableId, "13");
|
await page.write(selectors.ticketFuture.tableId, '13');
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press('Enter');
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 2);
|
await page.waitForNumberOfElements(selectors.ticketFuture.table, 2);
|
||||||
|
|
||||||
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
||||||
|
@ -176,10 +174,10 @@ describe('Ticket Future path', () => {
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 4);
|
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.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
||||||
await page.write(selectors.ticketFuture.tableTfId, "12");
|
await page.write(selectors.ticketFuture.tableTfId, '12');
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press('Enter');
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 5);
|
await page.waitForNumberOfElements(selectors.ticketFuture.table, 5);
|
||||||
|
|
||||||
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
||||||
|
@ -188,7 +186,7 @@ describe('Ticket Future path', () => {
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 4);
|
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.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
||||||
await page.autocompleteSearch(selectors.ticketFuture.tableIpt, 'Vertical');
|
await page.autocompleteSearch(selectors.ticketFuture.tableIpt, 'Vertical');
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 1);
|
await page.waitForNumberOfElements(selectors.ticketFuture.table, 1);
|
||||||
|
@ -199,7 +197,7 @@ describe('Ticket Future path', () => {
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 4);
|
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.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
||||||
await page.autocompleteSearch(selectors.ticketFuture.tableTfIpt, 'Vertical');
|
await page.autocompleteSearch(selectors.ticketFuture.tableTfIpt, 'Vertical');
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 1);
|
await page.waitForNumberOfElements(selectors.ticketFuture.table, 1);
|
||||||
|
@ -210,10 +208,10 @@ describe('Ticket Future path', () => {
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 4);
|
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.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
||||||
await page.write(selectors.ticketFuture.tableLines, "0");
|
await page.write(selectors.ticketFuture.tableLines, '0');
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press('Enter');
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 1);
|
await page.waitForNumberOfElements(selectors.ticketFuture.table, 1);
|
||||||
|
|
||||||
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
||||||
|
@ -222,8 +220,8 @@ describe('Ticket Future path', () => {
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 4);
|
await page.waitForNumberOfElements(selectors.ticketFuture.table, 4);
|
||||||
|
|
||||||
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
||||||
await page.write(selectors.ticketFuture.tableLines, "1");
|
await page.write(selectors.ticketFuture.tableLines, '1');
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press('Enter');
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 5);
|
await page.waitForNumberOfElements(selectors.ticketFuture.table, 5);
|
||||||
|
|
||||||
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
||||||
|
@ -232,10 +230,10 @@ describe('Ticket Future path', () => {
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 4);
|
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.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
||||||
await page.write(selectors.ticketFuture.tableLiters, "0");
|
await page.write(selectors.ticketFuture.tableLiters, '0');
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press('Enter');
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 1);
|
await page.waitForNumberOfElements(selectors.ticketFuture.table, 1);
|
||||||
|
|
||||||
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
||||||
|
@ -244,8 +242,8 @@ describe('Ticket Future path', () => {
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 4);
|
await page.waitForNumberOfElements(selectors.ticketFuture.table, 4);
|
||||||
|
|
||||||
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
||||||
await page.write(selectors.ticketFuture.tableLiters, "28");
|
await page.write(selectors.ticketFuture.tableLiters, '28');
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press('Enter');
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 5);
|
await page.waitForNumberOfElements(selectors.ticketFuture.table, 5);
|
||||||
|
|
||||||
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
await page.waitToClick(selectors.ticketFuture.tableButtonSearch);
|
||||||
|
@ -254,13 +252,13 @@ describe('Ticket Future path', () => {
|
||||||
await page.waitForNumberOfElements(selectors.ticketFuture.table, 4);
|
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.multiCheck);
|
||||||
await page.waitToClick(selectors.ticketFuture.firstCheck);
|
await page.waitToClick(selectors.ticketFuture.firstCheck);
|
||||||
await page.waitToClick(selectors.ticketFuture.moveButton);
|
await page.waitToClick(selectors.ticketFuture.moveButton);
|
||||||
await page.waitToClick(selectors.ticketFuture.acceptButton);
|
await page.waitToClick(selectors.ticketFuture.acceptButton);
|
||||||
const message = await page.waitForSnackbar();
|
const message = await page.waitForSnackbar();
|
||||||
|
|
||||||
expect(message.text).toContain('Tickets moved successfully!');
|
expect(message.text).toContain('Tickets moved successfully!');
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,9 +12,10 @@ export default class Component extends EventEmitter {
|
||||||
* @param {HTMLElement} $element The main component element
|
* @param {HTMLElement} $element The main component element
|
||||||
* @param {$rootScope.Scope} $scope The element scope
|
* @param {$rootScope.Scope} $scope The element scope
|
||||||
* @param {Function} $transclude The transclusion function
|
* @param {Function} $transclude The transclusion function
|
||||||
|
* @param {Function} $location The location function
|
||||||
*/
|
*/
|
||||||
constructor($element, $scope, $transclude) {
|
constructor($element, $scope, $transclude, $location) {
|
||||||
super();
|
super($element, $scope, $transclude, $location);
|
||||||
this.$ = $scope;
|
this.$ = $scope;
|
||||||
|
|
||||||
if (!$element) return;
|
if (!$element) return;
|
||||||
|
@ -164,7 +165,7 @@ export default class Component extends EventEmitter {
|
||||||
$transclude.$$boundTransclude.$$slots[slot];
|
$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
|
* Automatically adds the most used services to the prototype, so they are
|
||||||
|
|
|
@ -23,7 +23,10 @@ export default class Auth {
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
let criteria = {
|
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 => {
|
this.$transitions.onStart(criteria, transition => {
|
||||||
if (this.loggedIn)
|
if (this.loggedIn)
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
<vn-layout
|
<vn-layout
|
||||||
ng-if="$ctrl.showLayout">
|
ng-if="$ctrl.showLayout">
|
||||||
</vn-layout>
|
</vn-layout>
|
||||||
<ui-view
|
<vn-out-layout
|
||||||
name="login"
|
|
||||||
ng-if="!$ctrl.showLayout">
|
ng-if="!$ctrl.showLayout">
|
||||||
</ui-view>
|
</vn-out-layout>
|
||||||
<vn-snackbar vn-id="snackbar"></vn-snackbar>
|
<vn-snackbar vn-id="snackbar"></vn-snackbar>
|
||||||
<vn-debug-info></vn-debug-info>
|
<vn-debug-info></vn-debug-info>
|
||||||
|
|
|
@ -9,13 +9,20 @@ import Component from 'core/lib/component';
|
||||||
* @property {SideMenu} rightMenu The left menu, if it's present
|
* @property {SideMenu} rightMenu The left menu, if it's present
|
||||||
*/
|
*/
|
||||||
export default class App extends Component {
|
export default class App extends Component {
|
||||||
|
constructor($element, $, $location, $state) {
|
||||||
|
super($element, $, $location, $state);
|
||||||
|
this.$location = $location;
|
||||||
|
this.$state = $state;
|
||||||
|
}
|
||||||
|
|
||||||
$postLink() {
|
$postLink() {
|
||||||
this.vnApp.logger = this;
|
this.vnApp.logger = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
get showLayout() {
|
get showLayout() {
|
||||||
let state = this.$state.current.name;
|
const state = this.$state.current.name || this.$location.$$path.substring(1).replace('/', '.');
|
||||||
return state && state != 'login';
|
const outLayout = ['login', 'recoverPassword', 'resetPassword'];
|
||||||
|
return state && !outLayout.some(ol => ol == state);
|
||||||
}
|
}
|
||||||
|
|
||||||
$onDestroy() {
|
$onDestroy() {
|
||||||
|
|
|
@ -5,7 +5,10 @@ import './descriptor-popover';
|
||||||
import './home/home';
|
import './home/home';
|
||||||
import './layout';
|
import './layout';
|
||||||
import './left-menu/left-menu';
|
import './left-menu/left-menu';
|
||||||
|
import './login/index';
|
||||||
import './login/login';
|
import './login/login';
|
||||||
|
import './login/recover-password';
|
||||||
|
import './login/reset-password';
|
||||||
import './module-card';
|
import './module-card';
|
||||||
import './module-main';
|
import './module-main';
|
||||||
import './side-menu/side-menu';
|
import './side-menu/side-menu';
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
<div class="box">
|
||||||
|
<img src="./logo.svg"/>
|
||||||
|
<form name="form">
|
||||||
|
<ui-view></ui-view>
|
||||||
|
</form>
|
||||||
|
</div>
|
|
@ -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
|
||||||
|
});
|
|
@ -2,3 +2,7 @@ User: User
|
||||||
Password: Password
|
Password: Password
|
||||||
Do not close session: Do not close session
|
Do not close session: Do not close session
|
||||||
Enter: Enter
|
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: $%&.)
|
||||||
|
|
|
@ -1,4 +1,16 @@
|
||||||
User: Usuario
|
User: Usuario
|
||||||
Password: Contraseña
|
Password: Contraseña
|
||||||
|
Email: Correo electrónico
|
||||||
Do not close session: No cerrar sesión
|
Do not close session: No cerrar sesión
|
||||||
Enter: Entrar
|
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: $%&.)
|
||||||
|
|
|
@ -1,27 +1,27 @@
|
||||||
<div class="box">
|
<vn-textfield
|
||||||
<img src="./logo.svg"/>
|
label="User"
|
||||||
<form name="form" ng-submit="$ctrl.submit()">
|
ng-model="$ctrl.user"
|
||||||
<vn-textfield
|
vn-id="userField"
|
||||||
label="User"
|
vn-focus>
|
||||||
ng-model="$ctrl.user"
|
</vn-textfield>
|
||||||
vn-id="userField"
|
<vn-textfield
|
||||||
vn-focus>
|
label="Password"
|
||||||
</vn-textfield>
|
ng-model="$ctrl.password"
|
||||||
<vn-textfield
|
type="password">
|
||||||
label="Password"
|
</vn-textfield>
|
||||||
ng-model="$ctrl.password"
|
<vn-check
|
||||||
type="password">
|
label="Do not close session"
|
||||||
</vn-textfield>
|
ng-model="$ctrl.remember"
|
||||||
<vn-check
|
name="remember">
|
||||||
label="Do not close session"
|
</vn-check>
|
||||||
ng-model="$ctrl.remember"
|
<div class="footer">
|
||||||
name="remember">
|
<vn-submit label="Enter" ng-click="$ctrl.submit()"></vn-submit>
|
||||||
</vn-check>
|
<div class="spinner-wrapper">
|
||||||
<div class="footer">
|
<vn-spinner enable="$ctrl.loading"></vn-spinner>
|
||||||
<vn-submit label="Enter"></vn-submit>
|
</div>
|
||||||
<div class="spinner-wrapper">
|
<div class="vn-pt-lg">
|
||||||
<vn-spinner enable="$ctrl.loading"></vn-spinner>
|
<a ui-sref="recoverPassword" translate>
|
||||||
</div>
|
I do not remember my password
|
||||||
</div>
|
</a>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
<h5 class="vn-mb-md vn-mt-lg" translate>Recover password</h5>
|
||||||
|
<vn-textfield
|
||||||
|
label="Email"
|
||||||
|
ng-model="$ctrl.email"
|
||||||
|
vn-focus>
|
||||||
|
</vn-textfield>
|
||||||
|
<div
|
||||||
|
class="text-secondary"
|
||||||
|
translate>
|
||||||
|
We will sent you an email to recover your password
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<vn-submit label="Recover password" ng-click="$ctrl.submit()"></vn-submit>
|
||||||
|
<div class="spinner-wrapper">
|
||||||
|
<vn-spinner enable="$ctrl.loading"></vn-spinner>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -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
|
||||||
|
});
|
|
@ -0,0 +1,19 @@
|
||||||
|
<h5 class="vn-mb-md vn-mt-lg" translate>Reset password</h5>
|
||||||
|
<vn-textfield
|
||||||
|
label="New password"
|
||||||
|
ng-model="$ctrl.newPassword"
|
||||||
|
type="password"
|
||||||
|
info="{{'Password requirements' | translate:$ctrl.passRequirements}}"
|
||||||
|
vn-focus>
|
||||||
|
</vn-textfield>
|
||||||
|
<vn-textfield
|
||||||
|
label="Repeat password"
|
||||||
|
ng-model="$ctrl.repeatPassword"
|
||||||
|
type="password">
|
||||||
|
</vn-textfield>
|
||||||
|
<div class="footer">
|
||||||
|
<vn-submit label="Reset password" ng-click="$ctrl.submit()"></vn-submit>
|
||||||
|
<div class="spinner-wrapper">
|
||||||
|
<vn-spinner enable="$ctrl.loading"></vn-spinner>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -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
|
||||||
|
});
|
|
@ -1,6 +1,31 @@
|
||||||
@import "variables";
|
@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;
|
position: absolute;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -39,28 +64,17 @@ vn-login {
|
||||||
white-space: inherit;
|
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) {
|
@media screen and (max-width: 600px) {
|
||||||
|
@ -71,4 +85,8 @@ vn-login {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a{
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -112,7 +112,7 @@ function $exceptionHandler(vnApp, $window, $state, $injector) {
|
||||||
|
|
||||||
switch (exception.status) {
|
switch (exception.status) {
|
||||||
case 401:
|
case 401:
|
||||||
if ($state.current.name != 'login') {
|
if (!$state.current.name.includes('login')) {
|
||||||
messageT = 'Session has expired';
|
messageT = 'Session has expired';
|
||||||
let params = {continue: $window.location.hash};
|
let params = {continue: $window.location.hash};
|
||||||
$state.go('login', params);
|
$state.go('login', params);
|
||||||
|
|
|
@ -9,9 +9,17 @@ function config($stateProvider, $urlRouterProvider) {
|
||||||
.state('login', {
|
.state('login', {
|
||||||
url: '/login?continue',
|
url: '/login?continue',
|
||||||
description: 'Login',
|
description: 'Login',
|
||||||
views: {
|
template: '<vn-login></vn-login>'
|
||||||
login: {template: '<vn-login></vn-login>'}
|
})
|
||||||
}
|
.state('recoverPassword', {
|
||||||
|
url: '/recover-password',
|
||||||
|
description: 'Recover-password',
|
||||||
|
template: '<vn-recover-password>asd</vn-recover-password>'
|
||||||
|
})
|
||||||
|
.state('resetPassword', {
|
||||||
|
url: '/reset-password',
|
||||||
|
description: 'Reset-password',
|
||||||
|
template: '<vn-reset-password></vn-reset-password>'
|
||||||
})
|
})
|
||||||
.state('home', {
|
.state('home', {
|
||||||
url: '/',
|
url: '/',
|
||||||
|
|
|
@ -131,12 +131,15 @@
|
||||||
"Fichadas impares": "Odd signs",
|
"Fichadas impares": "Odd signs",
|
||||||
"Descanso diario 9h.": "Daily rest 9h.",
|
"Descanso diario 9h.": "Daily rest 9h.",
|
||||||
"Descanso semanal 36h. / 72h.": "Weekly rest 36h. / 72h.",
|
"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",
|
"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",
|
"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",
|
"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}}*",
|
"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 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",
|
"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}}})",
|
"Ticket merged": "Ticket [{{id}}]({{{fullPath}}}) ({{{originDated}}}) merged with [{{tfId}}]({{{fullPathFuture}}}) ({{{futureDated}}})",
|
||||||
"Sale(s) blocked, please contact production": "Sale(s) blocked, please contact production",
|
"Sale(s) blocked, please contact production": "Sale(s) blocked, please contact production",
|
||||||
"Receipt's bank was not found": "Receipt's bank was not found",
|
"Receipt's bank was not found": "Receipt's bank was not found",
|
||||||
|
|
|
@ -245,6 +245,8 @@
|
||||||
"Already has this status": "Ya tiene este estado",
|
"Already has this status": "Ya tiene este estado",
|
||||||
"There aren't records for this week": "No existen registros para esta semana",
|
"There aren't records for this week": "No existen registros para esta semana",
|
||||||
"Empty data source": "Origen de datos vacio",
|
"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",
|
"Receipt's bank was not found": "No se encontró el banco del recibo",
|
||||||
"This receipt was not compensated": "Este recibo no ha sido compensado",
|
"This receipt was not compensated": "Este recibo no ha sido compensado",
|
||||||
"Client's email was not found": "No se encontró el email del cliente"
|
"Client's email was not found": "No se encontró el email del cliente"
|
||||||
|
|
|
@ -30,5 +30,13 @@
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"required": true
|
"required": true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"accessType": "READ",
|
||||||
|
"principalType": "ROLE",
|
||||||
|
"principalId": "$everyone",
|
||||||
|
"permission": "ALLOW"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,11 @@ import ngModule from '../module';
|
||||||
import Section from 'salix/components/section';
|
import Section from 'salix/components/section';
|
||||||
|
|
||||||
export default class Controller extends Section {
|
export default class Controller extends Section {
|
||||||
|
$onInit() {
|
||||||
|
if (this.$params.emailConfirmed)
|
||||||
|
this.vnApp.showSuccess(this.$t('Email verified successfully!'));
|
||||||
|
}
|
||||||
|
|
||||||
onSubmit() {
|
onSubmit() {
|
||||||
this.$.watcher.submit()
|
this.$.watcher.submit()
|
||||||
.then(() => this.card.reload());
|
.then(() => this.card.reload());
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Email verified successfully!: Correo verificado correctamente!
|
|
@ -74,7 +74,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"url": "/basic-data",
|
"url": "/basic-data?emailConfirmed",
|
||||||
"state": "account.card.basicData",
|
"state": "account.card.basicData",
|
||||||
"component": "vn-user-basic-data",
|
"component": "vn-user-basic-data",
|
||||||
"description": "Basic data",
|
"description": "Basic data",
|
||||||
|
|
|
@ -8,10 +8,12 @@ module.exports = {
|
||||||
this.transporter = nodemailer.createTransport(config.smtp);
|
this.transporter = nodemailer.createTransport(config.smtp);
|
||||||
},
|
},
|
||||||
|
|
||||||
send(options) {
|
async send(options) {
|
||||||
options.from = `${config.app.senderName} <${config.app.senderEmail}>`;
|
options.from = `${config.app.senderName} <${config.app.senderEmail}>`;
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
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)
|
if (!config.smtp.auth.user)
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
|
|
||||||
|
@ -24,29 +26,35 @@ module.exports = {
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
}).finally(async() => {
|
}).finally(async() => {
|
||||||
const attachments = [];
|
await this.mailLog(options, error);
|
||||||
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'
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
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'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
.external-link {
|
||||||
|
border: 2px dashed #8dba25;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-align: center
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html v-bind:lang="$i18n.locale">
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
|
<title>{{ $t('subject') }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<table class="grid">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<!-- Empty block -->
|
||||||
|
<div class="grid-row">
|
||||||
|
<div class="grid-block empty"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Header block -->
|
||||||
|
<div class="grid-row">
|
||||||
|
<div class="grid-block">
|
||||||
|
<email-header></email-header>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Block -->
|
||||||
|
<div class="grid-row">
|
||||||
|
<div class="grid-block vn-pa-ml">
|
||||||
|
<p>
|
||||||
|
{{ $t(`click`) }}
|
||||||
|
<a :href="url">{{ $t('subject') }}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer block -->
|
||||||
|
<div class="grid-row">
|
||||||
|
<div class="grid-block">
|
||||||
|
<email-footer></email-footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Empty block -->
|
||||||
|
<div class="grid-row">
|
||||||
|
<div class="grid-block empty"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
|
@ -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
|
|
@ -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
|
|
@ -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();
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
.external-link {
|
||||||
|
border: 2px dashed #8dba25;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-align: center
|
||||||
|
}
|
|
@ -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.
|
|
@ -0,0 +1,48 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html v-bind:lang="$i18n.locale">
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
|
<title>{{ $t('subject') }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<table class="grid">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<!-- Empty block -->
|
||||||
|
<div class="grid-row">
|
||||||
|
<div class="grid-block empty"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Header block -->
|
||||||
|
<div class="grid-row">
|
||||||
|
<div class="grid-block">
|
||||||
|
<email-header></email-header>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Block -->
|
||||||
|
<div class="grid-row">
|
||||||
|
<div class="grid-block vn-pa-ml">
|
||||||
|
<p>
|
||||||
|
{{ $t('Click on the following link to change your password.') }}
|
||||||
|
<a :href="url">{{ $t('subject') }}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer block -->
|
||||||
|
<div class="grid-row">
|
||||||
|
<div class="grid-block">
|
||||||
|
<email-footer></email-footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Empty block -->
|
||||||
|
<div class="grid-row">
|
||||||
|
<div class="grid-block empty"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
Loading…
Reference in New Issue