4077-login_recover-password & account_verifyEmail #1063

Merged
alexm merged 52 commits from 4077-login_recover-password into dev 2022-11-28 11:34:03 +00:00
16 changed files with 149 additions and 113 deletions
Showing only changes of commit 5339bc1143 - Show all commits

View File

@ -1,7 +1,5 @@
const {Email} = require('vn-print');
module.exports = Self => { module.exports = Self => {
Self.remoteMethodCtx('recoverPassword', { Self.remoteMethod('recoverPassword', {
description: 'Send email to the user', description: 'Send email to the user',
accepts: [ accepts: [
{ {
@ -17,34 +15,13 @@ module.exports = Self => {
} }
}); });
Self.recoverPassword = async function(ctx, email) { Self.recoverPassword = async function(email) {
const models = Self.app.models; const models = Self.app.models;
const origin = ctx.req.headers.origin;
const ttl = 1209600;
const user = await models.Account.findOne({ try {
fields: ['id', 'name', 'password'], await models.user.resetPassword({email});
alexm marked this conversation as resolved Outdated
Outdated
Review

He dejado esta ruta(recoverPassword) porque si se llama directamente resetPassword y el correo que se le pasa no pertenece a un usuario, devuelve un error al frontend.

Usando una ruta con try catch, hacemos que no devuelva nunca error y asi no pueden saber si ese correo es de un usuario nuestro o no.

He dejado esta ruta(recoverPassword) porque si se llama directamente resetPassword y el correo que se le pasa no pertenece a un usuario, devuelve un error al frontend. Usando una ruta con try catch, hacemos que no devuelva nunca error y asi no pueden saber si ese correo es de un usuario nuestro o no.
Outdated
Review

Nomes deuria de ignorar el error de tipo "usuario no existe", tots els demes deuria de rellançarlos

catch(err) {
	if (err.code === 'EMAIL_NOT_FOUND')
    	console.error(err);
    else
    	throw err;
}
	
Nomes deuria de ignorar el error de tipo "usuario no existe", tots els demes deuria de rellançarlos ``` catch(err) { if (err.code === 'EMAIL_NOT_FOUND') console.error(err); else throw err; } ```
where: { } catch (e) {
email: email
}
});
if (!user)
return; return;
}
const token = await models.AccessToken.create({
ttl: ttl,
userId: user.id
});
const url = `${origin}/#!/account/${user.id}/basic-data?access_token=${token.id}`;
const params = {
recipient: email,
url: url
};
const sendEmail = new Email('recover-password', params);
return sendEmail.send();
}; };
}; };

View File

@ -1,25 +0,0 @@
const models = require('vn-loopback/server/server').models;
describe('account recoverPassword()', () => {
const ctx = {
req: {
headers: {origin: 'http://localhost:5000'}
}
};
ctx.req.__ = value => {
return value;
};
it('should update password when it passes requirements', async() => {
const user = await models.Account.findById(1107);
await models.Account.recoverPassword(ctx, user.email);
const [result] = await models.AccessToken.find({
where: {
userId: user.id
}
});
expect(result).toBeDefined();
});
});

View File

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

View File

@ -9,12 +9,15 @@ module.exports = function(Self) {
const headers = httpRequest.headers; const headers = httpRequest.headers;
const origin = headers.origin; const origin = headers.origin;
const user = await Self.app.models.Account.findById(info.user.id);
const params = { const params = {
recipient: info.email,
lang: user.lang,
url: `${origin}/#!/login/reset-password?access_token=${info.accessToken.id}` url: `${origin}/#!/login/reset-password?access_token=${info.accessToken.id}`
}; };
const sendEmail = new Email('recover-password', params); const email = new Email('recover-password', params);
return sendEmail.send(); return email.send();
}); });
}; };

View File

@ -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="login.recover-password"]',
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"]',

View File

@ -0,0 +1,40 @@
import selectors from '../../helpers/selectors';
import getBrowser from '../../helpers/puppeteer';
fdescribe('Login path', async() => {
let browser;
let page;
beforeAll(async() => {
browser = await getBrowser();
page = browser.page;
await page.waitToClick(selectors.recoverPassword.recoverPasswordButton);
await page.waitForState('login.recover-password');
});
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!');
});
});

View File

@ -3,15 +3,15 @@
</vn-layout> </vn-layout>
<ui-view <ui-view
name="login" name="login"
ng-if="!$ctrl.showLayout && !$ctrl.isRecover && !$ctrl.isRecover2"> ng-if="$ctrl.isLogin">
</ui-view> </ui-view>
<ui-view <ui-view
name="recover-password" name="recover-password"
ng-if="!$ctrl.showLayout && $ctrl.isRecover"> ng-if="$ctrl.isRecover">
</ui-view> </ui-view>
<ui-view <ui-view
name="reset-password" name="reset-password"
ng-if="!$ctrl.showLayout && $ctrl.isReset"> ng-if="$ctrl.isReset">
alexm marked this conversation as resolved Outdated
Outdated
Review

No estas fent us de les rutes anidades

No estas fent us de les rutes anidades
</ui-view> </ui-view>
<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>

View File

@ -15,7 +15,11 @@ export default class App extends Component {
get showLayout() { get showLayout() {
let state = this.$state.current.name; let state = this.$state.current.name;
return state && state != 'login' && !this.isRecover && !this.isReset; return state && !this.isLogin && !this.isRecover && !this.isReset;
alexm marked this conversation as resolved Outdated
Outdated
Review

Açò funciona en tots els casos? Que passa si un estat conte login?

Açò funciona en **tots** els casos? Que passa si un estat conte login?
}
get isLogin() {
return this.$state.current.name == 'login';
} }
get isRecover() { get isRecover() {

View File

@ -1,4 +1,8 @@
User: User 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: $%&.)

View File

@ -7,3 +7,10 @@ I do not remember my password: No recuerdo mi contraseña
Recover password: Recuperar 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 We will sent you an email to recover your password: Te enviaremos un correo para restablecer tu contraseña
Notification sent!: ¡Notificación enviada! 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: $%&.)

View File

@ -13,15 +13,19 @@ export default class Controller {
}); });
} }
goToLogin() {
this.vnApp.showSuccess(this.$translate.instant('Notification sent!'));
this.$state.go('login');
}
submit() { submit() {
const params = { const params = {
email: this.email email: this.email
}; };
this.$http.post('users/reset', params) this.$http.post('Accounts/recoverPassword', params)
.then(() => { .then(() => {
this.vnApp.showSuccess(this.$translate.instant('Notification sent!')); this.goToLogin();
this.$state.go('login');
}); });
} }
} }

View File

@ -14,7 +14,7 @@
type="password"> type="password">
</vn-textfield> </vn-textfield>
<div class="footer"> <div class="footer">
<vn-submit label="Change password"></vn-submit> <vn-submit label="Reset password"></vn-submit>
<div class="spinner-wrapper"> <div class="spinner-wrapper">
<vn-spinner enable="$ctrl.loading"></vn-spinner> <vn-spinner enable="$ctrl.loading"></vn-spinner>
</div> </div>

View File

@ -3,11 +3,6 @@ import Descriptor from 'salix/components/descriptor';
import UserError from 'core/lib/user-error'; import UserError from 'core/lib/user-error';
class Controller extends Descriptor { class Controller extends Descriptor {
constructor($element, $scope, $location) {
super($element, $scope);
this.$location = $location;
}
get user() { get user() {
return this.entity; return this.entity;
} }
@ -29,11 +24,6 @@ class Controller extends Descriptor {
.then(res => this.hasAccount = res.data.exists); .then(res => this.hasAccount = res.data.exists);
} }
$onInit() {
if (this.$params.access_token)
this.onChangePassClick(false);
}
onDelete() { onDelete() {
return this.$http.delete(`Accounts/${this.id}`) return this.$http.delete(`Accounts/${this.id}`)
.then(() => this.$state.go('account.index')) .then(() => this.$state.go('account.index'))
@ -110,8 +100,6 @@ class Controller extends Descriptor {
} }
} }
Controller.$inject = ['$element', '$scope', '$location'];
ngModule.component('vnUserDescriptor', { ngModule.component('vnUserDescriptor', {
template: require('./index.html'), template: require('./index.html'),
controller: Controller, controller: Controller,

View File

@ -94,15 +94,4 @@ describe('component vnUserDescriptor', () => {
expect(controller.emit).toHaveBeenCalledWith('change'); expect(controller.emit).toHaveBeenCalledWith('change');
}); });
}); });
describe('onInit()', () => {
it('should open onChangePassClick popup', () => {
controller.$params = {access_token: 'RANDOM_TOKEN'};
jest.spyOn(controller, 'onChangePassClick');
controller.$onInit();
expect(controller.onChangePassClick).toHaveBeenCalled();
});
});
}); });

View File

@ -74,7 +74,7 @@
} }
}, },
{ {
"url": "/basic-data?access_token&emailConfirmed", "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",

View File

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

He modificado esta parte porque como esta diseñado print. Solo inserta en mail(a modo de log) cuando realmente se envia el correo (producción). Haciendo este cambio podemos ver los logs tanto en local como en test.

Aunque al final no he usado esta funcionalidad para los test creo que es util para hacer pruebas en test

He modificado esta parte porque como esta diseñado print. Solo inserta en mail(a modo de log) cuando realmente se envia el correo (producción). Haciendo este cambio podemos ver los logs tanto en local como en test. Aunque al final no he usado esta funcionalidad para los test creo que es util para hacer pruebas en test
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'
]);
} }
}; };