Merge branch 'dev' of https://gitea.verdnatura.es/verdnatura/salix into 4614-ticket_weight

This commit is contained in:
Alex Moreno 2023-07-06 15:15:36 +02:00
commit be0120728d
85 changed files with 1620 additions and 320 deletions

View File

@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2328.01] - 2023-07-13
## [2330.01] - 2023-07-27
### Added
@ -14,6 +14,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
## [2328.01] - 2023-07-13
### Added
- (Clientes -> Morosos) Añadida columna "es trabajador"
- (Trabajadores -> Departamentos) Nueva sección
- (Trabajadores -> Departamentos) Añadido listado de Trabajadores por departamento
- (Trabajadores -> Departamentos) Añadido características de departamento e información
### Changed
### Fixed
- (Trabajadores -> Departamentos) Arreglado búscador
## [2326.01] - 2023-06-29
### Added

View File

@ -0,0 +1,102 @@
const ForbiddenError = require('vn-loopback/util/forbiddenError');
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => {
Self.remoteMethodCtx('signIn', {
description: 'Login a user with username/email and password',
accepts: [
{
arg: 'user',
type: 'String',
description: 'The user name or email',
required: true
}, {
arg: 'password',
type: 'String',
description: 'The password'
}
],
returns: {
type: 'object',
root: true
},
http: {
path: `/sign-in`,
verb: 'POST'
}
});
Self.signIn = async function(ctx, user, password, options) {
const myOptions = {};
if (typeof options == 'object')
Object.assign(myOptions, options);
const where = Self.userUses(user);
const vnUser = await Self.findOne({
fields: ['id', 'name', 'password', 'active', 'email', 'passExpired', 'twoFactor'],
where
}, myOptions);
const validCredentials = vnUser
&& await vnUser.hasPassword(password);
if (validCredentials) {
if (!vnUser.active)
throw new UserError('User disabled');
await Self.sendTwoFactor(ctx, vnUser, myOptions);
await Self.passExpired(vnUser, myOptions);
if (vnUser.twoFactor)
throw new ForbiddenError(null, 'REQUIRES_2FA');
}
return Self.validateLogin(user, password);
};
Self.passExpired = async(vnUser, myOptions) => {
const today = Date.vnNew();
today.setHours(0, 0, 0, 0);
if (vnUser.passExpired && vnUser.passExpired.getTime() <= today.getTime()) {
const $ = Self.app.models;
const changePasswordToken = await $.AccessToken.create({
scopes: ['changePassword'],
userId: vnUser.id
}, myOptions);
const err = new UserError('Pass expired', 'passExpired');
changePasswordToken.twoFactor = vnUser.twoFactor ? true : false;
err.details = {token: changePasswordToken};
throw err;
}
};
Self.sendTwoFactor = async(ctx, vnUser, myOptions) => {
if (vnUser.twoFactor === 'email') {
const $ = Self.app.models;
const code = String(Math.floor(Math.random() * 999999));
const maxTTL = ((60 * 1000) * 5); // 5 min
await $.AuthCode.upsertWithWhere({userFk: vnUser.id}, {
userFk: vnUser.id,
code: code,
expires: Date.vnNow() + maxTTL
}, myOptions);
const headers = ctx.req.headers;
const platform = headers['sec-ch-ua-platform']?.replace(/['"=]+/g, '');
const browser = headers['sec-ch-ua']?.replace(/['"=]+/g, '');
const params = {
args: {
recipientId: vnUser.id,
recipient: vnUser.email,
code: code,
ip: ctx.req?.connection?.remoteAddress,
device: platform && browser ? platform + ', ' + browser : headers['user-agent'],
},
req: {getLocale: ctx.req.getLocale},
};
await Self.sendTemplate(params, 'auth-code', true);
}
};
};

View File

@ -1,81 +0,0 @@
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => {
Self.remoteMethod('signIn', {
description: 'Login a user with username/email and password',
accepts: [
{
arg: 'user',
type: 'String',
description: 'The user name or email',
http: {source: 'form'},
required: true
}, {
arg: 'password',
type: 'String',
description: 'The password'
}
],
returns: {
type: 'object',
root: true
},
http: {
path: `/signIn`,
verb: 'POST'
}
});
Self.signIn = async function(user, password) {
const models = Self.app.models;
const usesEmail = user.indexOf('@') !== -1;
let token;
const userInfo = usesEmail
? {email: user}
: {username: user};
const instance = await Self.findOne({
fields: ['username', 'password'],
where: userInfo
});
const where = usesEmail
? {email: user}
: {name: user};
const vnUser = await Self.findOne({
fields: ['id', 'active', 'passExpired'],
where
});
const today = Date.vnNew();
today.setHours(0, 0, 0, 0);
const validCredentials = instance
&& await instance.hasPassword(password);
if (validCredentials) {
if (!vnUser.active)
throw new UserError('User disabled');
if (vnUser.passExpired && vnUser.passExpired.getTime() <= today.getTime()) {
const changePasswordToken = await models.AccessToken.create({
scopes: ['change-password'],
userId: vnUser.id
});
const err = new UserError('Pass expired', 'passExpired');
err.details = {token: changePasswordToken};
throw err;
}
try {
await models.Account.sync(instance.username, password);
} catch (err) {
console.warn(err);
}
}
let loginInfo = Object.assign({password}, userInfo);
token = await Self.login(loginInfo, 'user');
return {token: token.id, ttl: token.ttl};
};
};

View File

@ -0,0 +1,101 @@
const {models} = require('vn-loopback/server/server');
describe('VnUser Sign-in()', () => {
const employeeId = 1;
const unauthCtx = {
req: {
headers: {},
connection: {
remoteAddress: '127.0.0.1'
},
getLocale: () => 'en'
},
args: {}
};
const {VnUser, AccessToken} = models;
describe('when credentials are correct', () => {
it('should return the token', async() => {
let login = await VnUser.signIn(unauthCtx, 'salesAssistant', 'nightmare');
let accessToken = await AccessToken.findById(login.token);
let ctx = {req: {accessToken: accessToken}};
expect(login.token).toBeDefined();
await VnUser.logout(ctx.req.accessToken.id);
});
it('should return the token if the user doesnt exist but the client does', async() => {
let login = await VnUser.signIn(unauthCtx, 'PetterParker', 'nightmare');
let accessToken = await AccessToken.findById(login.token);
let ctx = {req: {accessToken: accessToken}};
expect(login.token).toBeDefined();
await VnUser.logout(ctx.req.accessToken.id);
});
});
describe('when credentials are incorrect', () => {
it('should throw a 401 error', async() => {
let error;
try {
await VnUser.signIn(unauthCtx, 'IDontExist', 'TotallyWrongPassword');
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.statusCode).toBe(401);
expect(error.code).toBe('LOGIN_FAILED');
});
});
describe('when two-factor auth is required', () => {
it('should throw a 403 error', async() => {
const employee = await VnUser.findById(employeeId);
const tx = await VnUser.beginTransaction({});
let error;
try {
const options = {transaction: tx};
await employee.updateAttribute('twoFactor', 'email', options);
await VnUser.signIn(unauthCtx, 'employee', 'nightmare', options);
await tx.rollback();
} catch (e) {
await tx.rollback();
error = e;
}
expect(error).toBeDefined();
expect(error.statusCode).toBe(403);
expect(error.code).toBe('REQUIRES_2FA');
});
});
describe('when passExpired', () => {
it('should throw a passExpired error', async() => {
const tx = await VnUser.beginTransaction({});
const employee = await VnUser.findById(employeeId);
const yesterday = Date.vnNew();
yesterday.setDate(yesterday.getDate() - 1);
let error;
try {
const options = {transaction: tx};
await employee.updateAttribute('passExpired', yesterday, options);
await VnUser.signIn(unauthCtx, 'employee', 'nightmare', options);
await tx.rollback();
} catch (e) {
await tx.rollback();
error = e;
}
expect(error).toBeDefined();
expect(error.statusCode).toBe(400);
expect(error.message).toBe('Pass expired');
});
});
});

View File

@ -1,41 +0,0 @@
const {models} = require('vn-loopback/server/server');
describe('VnUser signIn()', () => {
describe('when credentials are correct', () => {
it('should return the token', async() => {
let login = await models.VnUser.signIn('salesAssistant', 'nightmare');
let accessToken = await models.AccessToken.findById(login.token);
let ctx = {req: {accessToken: accessToken}};
expect(login.token).toBeDefined();
await models.VnUser.logout(ctx.req.accessToken.id);
});
it('should return the token if the user doesnt exist but the client does', async() => {
let login = await models.VnUser.signIn('PetterParker', 'nightmare');
let accessToken = await models.AccessToken.findById(login.token);
let ctx = {req: {accessToken: accessToken}};
expect(login.token).toBeDefined();
await models.VnUser.logout(ctx.req.accessToken.id);
});
});
describe('when credentials are incorrect', () => {
it('should throw a 401 error', async() => {
let error;
try {
await models.VnUser.signIn('IDontExist', 'TotallyWrongPassword');
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.statusCode).toBe(401);
expect(error.code).toBe('LOGIN_FAILED');
});
});
});

View File

@ -0,0 +1,52 @@
const {models} = require('vn-loopback/server/server');
describe('VnUser validate-auth()', () => {
describe('validateAuth', () => {
it('should signin if data is correct', async() => {
await models.AuthCode.create({
userFk: 9,
code: '555555',
expires: Date.vnNow() + (60 * 1000)
});
const token = await models.VnUser.validateAuth('developer', 'nightmare', '555555');
expect(token.token).toBeDefined();
});
});
describe('validateCode', () => {
it('should throw an error for a non existent code', async() => {
let error;
try {
await models.VnUser.validateCode('developer', '123456');
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.statusCode).toBe(400);
expect(error.message).toEqual('Invalid or expired verification code');
});
it('should throw an error when a code doesn`t match the login username', async() => {
let error;
let authCode;
try {
authCode = await models.AuthCode.create({
userFk: 1,
code: '555555',
expires: Date.vnNow() + (60 * 1000)
});
await models.VnUser.validateCode('developer', '555555');
} catch (e) {
authCode && await authCode.destroy();
error = e;
}
expect(error).toBeDefined();
expect(error.statusCode).toBe(400);
expect(error.message).toEqual('Authentication failed');
});
});
});

View File

@ -0,0 +1,66 @@
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => {
Self.remoteMethod('validateAuth', {
description: 'Login a user with username/email and password',
accepts: [
{
arg: 'user',
type: 'String',
description: 'The user name or email',
required: true
},
{
arg: 'password',
type: 'String',
description: 'The password'
},
{
arg: 'code',
type: 'String',
description: 'The auth code'
}
],
returns: {
type: 'object',
root: true
},
http: {
path: `/validate-auth`,
verb: 'POST'
}
});
Self.validateAuth = async(username, password, code, options) => {
const myOptions = {};
if (typeof options == 'object')
Object.assign(myOptions, options);
const token = Self.validateLogin(username, password);
await Self.validateCode(username, code, myOptions);
return token;
};
Self.validateCode = async(username, code, myOptions) => {
const {AuthCode} = Self.app.models;
const authCode = await AuthCode.findOne({
where: {
code: code
}
}, myOptions);
const expired = authCode && Date.vnNow() > authCode.expires;
if (!authCode || expired)
throw new UserError('Invalid or expired verification code');
const user = await Self.findById(authCode.userFk, {
fields: ['name', 'twoFactor']
}, myOptions);
if (user.name !== username)
throw new UserError('Authentication failed');
await authCode.destroy(myOptions);
};
};

View File

@ -1,7 +1,4 @@
{
"AccountingType": {
"dataSource": "vn"
},
"AccessTokenConfig": {
"dataSource": "vn",
"options": {
@ -10,6 +7,12 @@
}
}
},
"AccountingType": {
"dataSource": "vn"
},
"AuthCode": {
"dataSource": "vn"
},
"Bank": {
"dataSource": "vn"
},

View File

@ -0,0 +1,31 @@
{
"name": "AuthCode",
"base": "VnModel",
"options": {
"mysql": {
"table": "salix.authCode"
}
},
"properties": {
"userFk": {
"type": "number",
"required": true,
"id": true
},
"code": {
"type": "string",
"required": true
},
"expires": {
"type": "number",
"required": true
}
},
"relations": {
"user": {
"type": "belongsTo",
"model": "Account",
"foreignKey": "userFk"
}
}
}

View File

@ -5,11 +5,12 @@ const {Email} = require('vn-print');
module.exports = function(Self) {
vnModel(Self);
require('../methods/vn-user/signIn')(Self);
require('../methods/vn-user/sign-in')(Self);
require('../methods/vn-user/acl')(Self);
require('../methods/vn-user/recover-password')(Self);
require('../methods/vn-user/validate-token')(Self);
require('../methods/vn-user/privileges')(Self);
require('../methods/vn-user/validate-auth')(Self);
require('../methods/vn-user/renew-token')(Self);
Self.definition.settings.acls = Self.definition.settings.acls.filter(acl => acl.property !== 'create');
@ -111,6 +112,18 @@ module.exports = function(Self) {
return email.send();
});
Self.validateLogin = async function(user, password) {
let loginInfo = Object.assign({password}, Self.userUses(user));
token = await Self.login(loginInfo, 'user');
return {token: token.id, ttl: token.ttl};
};
Self.userUses = function(user) {
return user.indexOf('@') !== -1
? {email: user}
: {username: user};
};
const _setPassword = Self.prototype.setPassword;
Self.prototype.setPassword = async function(newPassword, options, cb) {
if (cb === undefined && typeof options === 'function') {
@ -143,8 +156,9 @@ module.exports = function(Self) {
}
};
Self.sharedClass._methods.find(method => method.name == 'changePassword')
.accessScopes = ['change-password'];
Self.sharedClass._methods.find(method => method.name == 'changePassword').ctor.settings.acls =
Self.sharedClass._methods.find(method => method.name == 'changePassword').ctor.settings.acls
.filter(acl => acl.property != 'changePassword');
// FIXME: https://redmine.verdnatura.es/issues/5761
// Self.afterRemote('prototype.patchAttributes', async(ctx, instance) => {

View File

@ -59,7 +59,10 @@
},
"passExpired": {
"type": "date"
}
},
"twoFactor": {
"type": "string"
}
},
"relations": {
"role": {
@ -111,6 +114,13 @@
"principalId": "$authenticated",
"permission": "ALLOW"
},
{
"property": "validateAuth",
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW"
},
{
"property": "privileges",
"accessType": "*",

View File

@ -0,0 +1,13 @@
create table `salix`.`authCode`
(
userFk int UNSIGNED not null,
code int not null,
expires bigint not null,
constraint authCode_pk
primary key (userFk),
constraint authCode_unique
unique (code),
constraint authCode_user_id_fk
foreign key (userFk) references `account`.`user` (id)
on update cascade on delete cascade
);

View File

@ -1,11 +1,11 @@
DROP PROCEDURE IF EXISTS vn.clientCreate;
DROP PROCEDURE IF EXISTS `vn`.`clientCreate`;
DELIMITER $$
CREATE OR REPLACE DEFINER=`root`@`localhost` PROCEDURE `vn`.`client_create`(
vFirstname VARCHAR(50),
vSurnames VARCHAR(50),
vFi VARCHAR(9),
vAddress TEXT,
vSurnames VARCHAR(50),
vFi VARCHAR(9),
vAddress TEXT,
vPostcode CHAR(5),
vCity VARCHAR(25),
vProvinceFk SMALLINT(5),
@ -61,7 +61,7 @@ BEGIN
provinceFk = vProvinceFk,
city = vCity,
postcode = vPostcode,
socialName = CONCAT(vSurnames, ' ', vFirstname),
socialName = CONCAT(vSurnames, ' ', vFirstname),
payMethodFk = vPayMethodFk,
dueDay = vDueDay,
credit = vDefaultCredit,
@ -75,14 +75,14 @@ BEGIN
isTaxDataChecked = vIsTaxDataChecked,
hasCoreVnl = vHasCoreVnl,
isActive = TRUE;
INSERT INTO mandate (clientFk, companyFk, mandateTypeFk)
SELECT vUserFk, vCompanyFk, vMandateTypeFk
WHERE NOT EXISTS (
SELECT id
FROM mandate
WHERE clientFk = vUserFk
AND companyFk = vCompanyFk
SELECT id
FROM mandate
WHERE clientFk = vUserFk
AND companyFk = vCompanyFk
AND mandateTypeFk = vMandateTypeFk
);
END$$

View File

@ -1,17 +1,17 @@
ALTER TABLE vn.clientConfig ADD defaultPayMethodFk tinyint(3) unsigned NULL;
ALTER TABLE vn.clientConfig ADD defaultDueDay int unsigned NULL;
ALTER TABLE vn.clientConfig ADD defaultCredit decimal(10, 2) NULL;
ALTER TABLE vn.clientConfig ADD defaultIsTaxDataChecked tinyint(1) NULL;
ALTER TABLE vn.clientConfig ADD defaultHasCoreVnl boolean NULL;
ALTER TABLE vn.clientConfig ADD defaultMandateTypeFk smallint(5) NULL;
ALTER TABLE vn.clientConfig ADD CONSTRAINT clientNewConfigPayMethod_FK FOREIGN KEY (dafaultPayMethodFk) REFERENCES vn.payMethod(id);
ALTER TABLE vn.clientConfig ADD CONSTRAINT clientNewConfigMandateType_FK FOREIGN KEY (defaultMandateTypeFk) REFERENCES vn.mandateType(id);
ALTER TABLE `vn`.`clientConfig` ADD defaultPayMethodFk tinyint(3) unsigned NULL;
ALTER TABLE `vn`.`clientConfig` ADD defaultDueDay int unsigned NULL;
ALTER TABLE `vn`.`clientConfig` ADD defaultCredit decimal(10, 2) NULL;
ALTER TABLE `vn`.`clientConfig` ADD defaultIsTaxDataChecked tinyint(1) NULL;
ALTER TABLE `vn`.`clientConfig` ADD defaultHasCoreVnl boolean NULL;
ALTER TABLE `vn`.`clientConfig` ADD defaultMandateTypeFk smallint(5) NULL;
ALTER TABLE `vn`.`clientConfig` ADD CONSTRAINT clientNewConfigPayMethod_FK FOREIGN KEY (defaultPayMethodFk) REFERENCES vn.payMethod(id);
ALTER TABLE `vn`.`clientConfig` ADD CONSTRAINT clientNewConfigMandateType_FK FOREIGN KEY (defaultMandateTypeFk) REFERENCES vn.mandateType(id);
UPDATE vn.clientConfig
UPDATE `vn`.`clientConfig`
SET defaultPayMethodFk = 4,
defaultDueDay = 5,
defaultCredit = 300.0,
defaultIsTaxDataChecked = 1,
defaultHasCoreVnl = 1,
defaultMandateTypeFk = 2
WHERE id = 1;
WHERE id = 1;

View File

@ -0,0 +1,24 @@
alter table `vn`.`department`
add `twoFactor` ENUM ('email') null comment 'Default user two-factor auth type';
drop trigger `vn`.`department_afterUpdate`;
DELIMITER $$
$$
create definer = root@localhost trigger `vn`.`department_afterUpdate`
after update
on department
for each row
BEGIN
IF !(OLD.parentFk <=> NEW.parentFk) THEN
UPDATE vn.department_recalc SET isChanged = TRUE;
END IF;
IF !(OLD.twoFactor <=> NEW.twoFactor) THEN
UPDATE account.user u
JOIN vn.workerDepartment wd ON wd.workerFk = u.id
SET u.twoFactor = NEW.twoFactor
WHERE wd.departmentFk = NEW.id;
END IF;
END;$$
DELIMITER ;

View File

@ -0,0 +1,5 @@
alter table `account`.`user`
add `twoFactor` ENUM ('email') null comment 'Two-factor auth type';
DELETE FROM `salix`.`ACL`
WHERE model = 'VnUser' AND property = 'changePassword';

View File

View File

@ -77,7 +77,10 @@ INSERT INTO `account`.`user`(`id`,`name`, `nickname`, `role`,`active`,`email`, `
ORDER BY id;
INSERT INTO `account`.`account`(`id`)
SELECT id FROM `account`.`user`;
SELECT `u`.`id`
FROM `account`.`user` `u`
JOIN `account`.`role` `r` ON `u`.`role` = `r`.`id`
WHERE `r`.`name` <> 'customer';
INSERT INTO `vn`.`educationLevel` (`id`, `name`)
VALUES
@ -382,6 +385,12 @@ INSERT INTO `vn`.`clientManaCache`(`clientFk`, `mana`, `dated`)
(1103, 0, DATE_ADD(util.VN_CURDATE(), INTERVAL -1 MONTH)),
(1104, -30, DATE_ADD(util.VN_CURDATE(), INTERVAL -1 MONTH));
INSERT INTO `vn`.`mandateType`(`id`, `name`)
VALUES
(1, 'B2B'),
(2, 'CORE'),
(3, 'LCR');
INSERT INTO `vn`.`clientConfig`(`id`, `riskTolerance`, `maxCreditRows`, `maxPriceIncreasingRatio`, `riskScope`, `defaultPayMethodFk`, `defaultDueDay`, `defaultCredit`, `defaultIsTaxDataChecked`, `defaultHasCoreVnl`, `defaultMandateTypeFk`)
VALUES
(1, 200, 10, 0.25, 2, 4, 5, 300.00, 1, 1, 2);
@ -825,12 +834,6 @@ INSERT INTO `vn`.`greuge`(`id`, `clientFk`, `description`, `amount`, `shipped`,
(11, 1101, 'some heritage charges', -15.99, DATE_ADD(util.VN_CURDATE(), INTERVAL 1 MONTH), util.VN_CURDATE(), 5, 1),
(12, 1101, 'some miscellaneous charges', 58.00, DATE_ADD(util.VN_CURDATE(), INTERVAL 1 MONTH), util.VN_CURDATE(), 6, 1);
INSERT INTO `vn`.`mandateType`(`id`, `name`)
VALUES
(1, 'B2B'),
(2, 'CORE'),
(3, 'LCR');
INSERT INTO `vn`.`mandate`(`id`, `clientFk`, `companyFk`, `code`, `created`, `mandateTypeFk`)
VALUES
(1, 1102, 442, '1-1', util.VN_CURDATE(), 2);
@ -2849,8 +2852,8 @@ INSERT INTO `vn`.`profileType` (`id`, `name`)
INSERT INTO `salix`.`url` (`appName`, `environment`, `url`)
VALUES
('lilium', 'dev', 'http://localhost:9000/#/'),
('salix', 'dev', 'http://localhost:5000/#!/');
('lilium', 'development', 'http://localhost:9000/#/'),
('salix', 'development', 'http://localhost:5000/#!/');
INSERT INTO `vn`.`report` (`id`, `name`, `paperSizeFk`, `method`)
VALUES

View File

@ -894,6 +894,18 @@ export default {
extension: 'vn-worker-summary vn-one:nth-child(2) > vn-label-value:nth-child(5) > section > span',
},
department: {
firstDepartment: 'vn-worker-department-index vn-card > vn-treeview vn-treeview-childs vn-treeview-childs vn-treeview-childs a'
},
departmentSummary: {
header: 'vn-worker-department-summary h5',
name: 'vn-worker-department-summary vn-horizontal > vn-one > vn-vertical > vn-label-value:nth-child(1) > section > span',
code: 'vn-worker-department-summary vn-horizontal > vn-one > vn-vertical > vn-label-value:nth-child(2) > section > span',
chat: 'vn-worker-department-summary vn-horizontal > vn-one > vn-vertical > vn-label-value:nth-child(3) > section > span',
bossDepartment: 'vn-worker-department-summary vn-horizontal > vn-one > vn-vertical > vn-label-value:nth-child(4) > section > span',
email: 'vn-worker-department-summary vn-horizontal > vn-one > vn-vertical > vn-label-value:nth-child(5) > section > span',
clientFk: 'vn-worker-department-summary vn-horizontal > vn-one > vn-vertical > vn-label-value:nth-child(6) > section > span',
},
workerBasicData: {
name: 'vn-worker-basic-data vn-textfield[ng-model="$ctrl.worker.firstName"]',
surname: 'vn-worker-basic-data vn-textfield[ng-model="$ctrl.worker.lastName"]',
@ -901,6 +913,13 @@ export default {
locker: 'vn-worker-basic-data vn-input-number[ng-model="$ctrl.worker.locker"]',
saveButton: 'vn-worker-basic-data button[type=submit]'
},
departmentBasicData: {
Name: 'vn-worker-department-basic-data vn-textfield[ng-model="$ctrl.department.name"]',
Code: 'vn-worker-department-basic-data vn-textfield[ng-model="$ctrl.department.code"]',
Chat: 'vn-worker-department-basic-data vn-textfield[ng-model="$ctrl.department.chat"]',
Email: 'vn-worker-department-basic-data vn-textfield[ng-model="$ctrl.department.notificationEmail"]',
saveButton: 'vn-worker-department-basic-data button[type=submit]'
},
workerNotes: {
addNoteFloatButton: 'vn-worker-note vn-icon[icon="add"]',
note: 'vn-note-worker-create vn-textarea[ng-model="$ctrl.note.text"]',

View File

@ -16,6 +16,7 @@ describe('ChangePassword path', async() => {
await browser.close();
});
const badPassword = 'badpass';
const oldPassword = 'nightmare';
const newPassword = 'newPass.1234';
describe('Bad login', async() => {
@ -37,13 +38,22 @@ describe('ChangePassword path', async() => {
expect(message.text).toContain('Invalid current password');
// Bad attempt: password not meet requirements
message = await page.sendForm($.form, {
oldPassword: oldPassword,
newPassword: badPassword,
repeatPassword: badPassword
});
expect(message.text).toContain('Password does not meet requirements');
// Bad attempt: same password
message = await page.sendForm($.form, {
oldPassword: oldPassword,
newPassword: oldPassword,
repeatPassword: oldPassword
});
expect(message.text).toContain('Password does not meet requirements');
expect(message.text).toContain('You can not use the same password');
// Correct attempt: change password
message = await page.sendForm($.form, {

View File

@ -0,0 +1,29 @@
import selectors from '../../../helpers/selectors.js';
import getBrowser from '../../../helpers/puppeteer';
describe('department summary path', () => {
let browser;
let page;
beforeAll(async() => {
browser = await getBrowser();
page = browser.page;
await page.loginAndModule('hr', 'worker');
await page.accessToSection('worker.department');
await page.doSearch('INFORMATICA');
await page.click(selectors.department.firstDepartment);
});
afterAll(async() => {
await browser.close();
});
it('should reach the employee summary section and check all properties', async() => {
expect(await page.waitToGetProperty(selectors.departmentSummary.header, 'innerText')).toEqual('INFORMATICA');
expect(await page.getProperty(selectors.departmentSummary.name, 'innerText')).toEqual('INFORMATICA');
expect(await page.getProperty(selectors.departmentSummary.code, 'innerText')).toEqual('it');
expect(await page.getProperty(selectors.departmentSummary.chat, 'innerText')).toEqual('informatica-cau');
expect(await page.getProperty(selectors.departmentSummary.bossDepartment, 'innerText')).toEqual('');
expect(await page.getProperty(selectors.departmentSummary.email, 'innerText')).toEqual('-');
expect(await page.getProperty(selectors.departmentSummary.clientFk, 'innerText')).toEqual('-');
});
});

View File

@ -0,0 +1,43 @@
import getBrowser from '../../../helpers/puppeteer';
import selectors from '../../../helpers/selectors.js';
const $ = {
form: 'vn-worker-department-basic-data form',
};
describe('department summary path', () => {
let browser;
let page;
beforeAll(async() => {
browser = await getBrowser();
page = browser.page;
await page.loginAndModule('hr', 'worker');
await page.accessToSection('worker.department');
await page.doSearch('INFORMATICA');
await page.click(selectors.department.firstDepartment);
});
beforeEach(async() => {
await page.accessToSection('worker.department.card.basicData');
});
afterAll(async() => {
await browser.close();
});
it(`should edit the department basic data and confirm the department data was edited`, async() => {
const values = {
Name: 'Informatica',
Code: 'IT',
Chat: 'informatica-cau',
Email: 'it@verdnatura.es',
};
await page.fillForm($.form, values);
const formValues = await page.fetchForm($.form, Object.keys(values));
const message = await page.sendForm($.form, values);
expect(message.isSuccess).toBeTrue();
expect(formValues).toEqual(values);
});
});

View File

@ -24,7 +24,7 @@ export default class Auth {
initialize() {
let criteria = {
to: state => {
const outLayout = ['login', 'recover-password', 'reset-password', 'change-password'];
const outLayout = ['login', 'recover-password', 'reset-password', 'change-password', 'validate-email'];
return !outLayout.some(ol => ol == state.name);
}
};
@ -60,7 +60,25 @@ export default class Auth {
};
const now = new Date();
return this.$http.post('VnUsers/signIn', params)
return this.$http.post('VnUsers/sign-in', params).then(
json => this.onLoginOk(json, now, remember));
}
validateCode(user, password, code, remember) {
if (!user) {
let err = new UserError('Please enter your username');
err.code = 'EmptyLogin';
return this.$q.reject(err);
}
let params = {
user: user,
password: password || undefined,
code: code
};
const now = new Date();
return this.$http.post('VnUsers/validate-auth', params)
.then(json => this.onLoginOk(json, now, remember));
}

View File

@ -34,7 +34,6 @@ export default class Token {
remember
});
this.vnInterceptor.setToken(token);
try {
if (remember)
this.setStorage(localStorage, token, created, ttl);

View File

@ -21,6 +21,14 @@
type="password"
autocomplete="false">
</vn-textfield>
<vn-textfield
ng-if="$ctrl.$state.params.twoFactor == 'true'"
label="Verification code"
ng-model="$ctrl.code"
vn-name="code"
autocomplete="false"
class="vn-mt-md">
</vn-textfield>
<div class="footer">
<vn-submit label="Change password" ng-click="$ctrl.submit()"></vn-submit>
<div class="spinner-wrapper">

View File

@ -26,11 +26,13 @@ export default class Controller {
submit() {
const userId = this.$state.params.userId;
const newPassword = this.newPassword;
const oldPassword = this.oldPassword;
const newPassword = this.newPassword;
const repeatPassword = this.repeatPassword;
const code = this.code;
if (!newPassword)
throw new UserError(`You must enter a new password`);
if (!oldPassword || !newPassword || !repeatPassword)
throw new UserError(`You must fill all the fields`);
if (newPassword != this.repeatPassword)
throw new UserError(`Passwords don't match`);
@ -38,11 +40,12 @@ export default class Controller {
Authorization: this.$state.params.id
};
this.$http.post('VnUsers/change-password',
this.$http.patch('Accounts/change-password',
{
id: userId,
oldPassword,
newPassword
newPassword,
code
},
{headers}
).then(() => {

View File

@ -2,6 +2,10 @@ Change password: Cambiar contraseña
Old password: Antigua contraseña
New password: Nueva contraseña
Repeat password: Repetir contraseña
Passwords don't match: Las contraseñas no coinciden
You must fill all the fields: Debes rellenar todos los campos
You can not use the same password: No puedes usar la misma contraseña
Verification code: Código de verificación
Password updated!: ¡Contraseña actualizada!
Password requirements: >
La contraseña debe tener al menos {{ length }} caracteres de longitud,

View File

@ -9,6 +9,7 @@ import './login';
import './outLayout';
import './recover-password';
import './reset-password';
import './validate-email';
import './change-password';
import './module-card';
import './module-main';

View File

@ -24,13 +24,23 @@ export default class Controller {
this.loading = false;
})
.catch(req => {
this.loading = false;
this.password = '';
this.focusUser();
if (req?.data?.error?.code === 'REQUIRES_2FA') {
this.outLayout.login = {
user: this.user,
password: this.password,
remember: this.remember
};
this.$state.go('validate-email');
return;
}
const err = req.data?.error;
if (err?.code == 'passExpired')
this.$state.go('change-password', err.details.token);
this.loading = false;
this.password = '';
this.focusUser();
throw req;
});
}
@ -44,5 +54,8 @@ Controller.$inject = ['$scope', '$element', '$state', 'vnAuth'];
ngModule.vnComponent('vnLogin', {
template: require('./index.html'),
controller: Controller
controller: Controller,
require: {
outLayout: '^vnOutLayout'
}
});

View File

@ -0,0 +1,10 @@
<h5 class="vn-mb-md vn-mt-lg" translate>Enter verification code</h5>
<span translate>Please enter the verification code that we have sent to your email address within 5 minutes</span>
<vn-textfield label="Code" ng-model="$ctrl.code" type="text" vn-focus>
</vn-textfield>
<div class="footer">
<vn-submit label="Validate" ng-click="$ctrl.submit()"></vn-submit>
<div class="spinner-wrapper">
<vn-spinner enable="$ctrl.loading"></vn-spinner>
</div>
</div>

View File

@ -0,0 +1,43 @@
import ngModule from '../../module';
import './style.scss';
export default class Controller {
constructor($scope, $element, vnAuth, $state) {
Object.assign(this, {
$scope,
$element,
vnAuth,
user: localStorage.getItem('lastUser'),
remember: true,
$state
});
}
$onInit() {
this.loginData = this.outLayout.login;
if (!this.loginData)
this.$state.go('login');
}
submit() {
this.loading = true;
this.vnAuth.validateCode(this.loginData.user, this.loginData.password, this.code, this.loginData.remember)
.then(() => {
localStorage.setItem('lastUser', this.user);
this.loading = false;
})
.catch(error => {
this.loading = false;
throw error;
});
}
}
Controller.$inject = ['$scope', '$element', 'vnAuth', '$state'];
ngModule.vnComponent('vnValidateEmail', {
template: require('./index.html'),
controller: Controller,
require: {
outLayout: '^vnOutLayout'
}
});

View File

@ -0,0 +1,5 @@
Validate email auth: Autenticar email
Enter verification code: Introduce código de verificación
Code: Código
Please enter the verification code that we have sent to your email address within 5 minutes: Por favor, introduce el código de verificación que te hemos enviado a tu email en los próximos 5 minutos
Validate: Validar

View File

@ -0,0 +1,24 @@
@import "variables";
vn-validate-email {
.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;
}
}
}

View File

@ -37,9 +37,15 @@ function config($stateProvider, $urlRouterProvider) {
description: 'Reset password',
template: '<vn-reset-password></vn-reset-password>'
})
.state('validate-email', {
parent: 'outLayout',
url: '/validate-email',
description: 'Validate email auth',
template: '<vn-validate-email></vn-validate-email>'
})
.state('change-password', {
parent: 'outLayout',
url: '/change-password?id&userId',
url: '/change-password?id&userId&twoFactor',
description: 'Change password',
template: '<vn-change-password></vn-change-password>'
})

View File

@ -39,7 +39,7 @@ module.exports = Self => {
return [html, 'text/html', `filename=${fileName}.pdf"`];
};
Self.sendTemplate = async function(ctx, templateName) {
Self.sendTemplate = async function(ctx, templateName, force) {
const args = Object.assign({}, ctx.args);
const params = {
recipient: args.recipient,
@ -52,6 +52,6 @@ module.exports = Self => {
const email = new Email(templateName, params);
return email.send();
return email.send({force: force});
};
};

View File

@ -147,14 +147,12 @@
"Receipt's bank was not found": "Receipt's bank was not found",
"This receipt was not compensated": "This receipt was not compensated",
"Client's email was not found": "Client's email was not found",
"Tickets with associated refunds": "Tickets with associated refunds can't be deleted. This ticket is associated with refund Nº {{id}}",
"Tickets with associated refunds": "Tickets with associated refunds can't be deleted. This ticket is associated with refund Nº %d",
"It is not possible to modify tracked sales": "It is not possible to modify tracked sales",
"It is not possible to modify sales that their articles are from Floramondo": "It is not possible to modify sales that their articles are from Floramondo",
"It is not possible to modify cloned sales": "It is not possible to modify cloned sales",
"Valid priorities: 1,2,3": "Valid priorities: 1,2,3",
"Warehouse inventory not set": "Almacén inventario no está establecido",
"Component cost not set": "Componente coste no está estabecido",
"Tickets with associated refunds can't be deleted. This ticket is associated with refund Nº 2": "Tickets with associated refunds can't be deleted. This ticket is associated with refund Nº 2",
"Description cannot be blank": "Description cannot be blank",
"company": "Company",
"country": "Country",
@ -177,5 +175,7 @@
"Invalid quantity": "Invalid quantity",
"Failed to upload delivery note": "Error to upload delivery note {{id}}",
"Mail not sent": "There has been an error sending the invoice to the client [{{clientId}}]({{{clientUrl}}}), please check the email address",
"The renew period has not been exceeded": "The renew period has not been exceeded"
"The renew period has not been exceeded": "The renew period has not been exceeded",
"You can not use the same password": "You can not use the same password",
"Valid priorities": "Valid priorities: %d"
}

View File

@ -268,7 +268,7 @@
"Invoice date can't be less than max date": "La fecha de factura no puede ser inferior a la fecha límite",
"Warehouse inventory not set": "El almacén inventario no está establecido",
"This locker has already been assigned": "Esta taquilla ya ha sido asignada",
"Tickets with associated refunds": "No se pueden borrar tickets con abonos asociados. Este ticket está asociado al abono Nº {{id}}",
"Tickets with associated refunds": "No se pueden borrar tickets con abonos asociados. Este ticket está asociado al abono Nº %d",
"Not exist this branch": "La rama no existe",
"This ticket cannot be signed because it has not been boxed": "Este ticket no puede firmarse porque no ha sido encajado",
"Collection does not exist": "La colección no existe",
@ -276,6 +276,8 @@
"Insert a date range": "Inserte un rango de fechas",
"Added observation": "{{user}} añadió esta observacion: {{text}}",
"Comment added to client": "Observación añadida al cliente {{clientFk}}",
"Invalid auth code": "Código de verificación incorrecto",
"Invalid or expired verification code": "Código de verificación incorrecto o expirado",
"Cannot create a new claimBeginning from a different ticket": "No se puede crear una línea de reclamación de un ticket diferente al origen",
"company": "Compañía",
"country": "País",
@ -293,10 +295,13 @@
"Invalid NIF for VIES": "Invalid NIF for VIES",
"Ticket does not exist": "Este ticket no existe",
"Ticket is already signed": "Este ticket ya ha sido firmado",
"Authentication failed": "Autenticación fallida",
"You can't use the same password": "No puedes usar la misma contraseña",
"You can only add negative amounts in refund tickets": "Solo se puede añadir cantidades negativas en tickets abono",
"Fecha fuera de rango": "Fecha fuera de rango",
"Error while generating PDF": "Error al generar PDF",
"Error when sending mail to client": "Error al enviar el correo al cliente",
"Mail not sent": "Se ha producido un fallo al enviar la factura al cliente [{{clientId}}]({{{clientUrl}}}), por favor revisa la dirección de correo electrónico",
"The renew period has not been exceeded": "El periodo de renovación no ha sido superado"
"Mail not sent": "Se ha producido un fallo al enviar la factura al cliente [{{clientId}}]({{{clientUrl}}}), por favor revisa la dirección de correo electrónico",
"The renew period has not been exceeded": "El periodo de renovación no ha sido superado",
"Valid priorities": "Prioridades válidas: %d"
}

View File

@ -0,0 +1,9 @@
module.exports = class ForbiddenError extends Error {
constructor(message, code, ...translateArgs) {
super(message);
this.name = 'ForbiddenError';
this.statusCode = 403;
this.code = code;
this.translateArgs = translateArgs;
}
};

View File

@ -1,15 +1,12 @@
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => {
Self.remoteMethod('changePassword', {
Self.remoteMethodCtx('changePassword', {
description: 'Changes the user password',
accessType: 'WRITE',
accessScopes: ['changePassword'],
accepts: [
{
arg: 'id',
type: 'number',
description: 'The user id',
http: {source: 'path'}
}, {
arg: 'oldPassword',
type: 'string',
description: 'The old password',
@ -19,15 +16,35 @@ module.exports = Self => {
type: 'string',
description: 'The new password',
required: true
}, {
arg: 'code',
type: 'string',
description: 'The 2FA code'
}
],
http: {
path: `/:id/changePassword`,
path: `/change-password`,
verb: 'PATCH'
}
});
Self.changePassword = async function(id, oldPassword, newPassword) {
await Self.app.models.VnUser.changePassword(id, oldPassword, newPassword);
Self.changePassword = async function(ctx, oldPassword, newPassword, code, options) {
const userId = ctx.req.accessToken.userId;
const myOptions = {};
if (typeof options == 'object')
Object.assign(myOptions, options);
const {VnUser} = Self.app.models;
const user = await VnUser.findById(userId, {fields: ['name', 'twoFactor']}, myOptions);
await user.hasPassword(oldPassword);
if (oldPassword == newPassword)
throw new UserError(`You can not use the same password`);
if (user.twoFactor)
await VnUser.validateCode(user.name, code, myOptions);
await VnUser.changePassword(userId, oldPassword, newPassword, myOptions);
};
};

View File

@ -1,5 +1,5 @@
module.exports = Self => {
Self.remoteMethod('login', {
Self.remoteMethodCtx('login', {
description: 'Login a user with username/email and password',
accepts: [
{
@ -23,5 +23,5 @@ module.exports = Self => {
}
});
Self.login = async(user, password) => Self.app.models.VnUser.signIn(user, password);
Self.login = async(ctx, user, password, options) => Self.app.models.VnUser.signIn(ctx, user, password, options);
};

View File

@ -21,7 +21,8 @@ module.exports = Self => {
}
});
Self.setPassword = async function(id, newPassword) {
await Self.app.models.VnUser.setPassword(id, newPassword);
Self.setPassword = async function(id, newPassword, options) {
options = typeof options == 'object' ? options : {};
await Self.app.models.VnUser.setPassword(id, newPassword, options);
};
};

View File

@ -1,22 +1,99 @@
const {models} = require('vn-loopback/server/server');
describe('account changePassword()', () => {
it('should throw an error when old password is wrong', async() => {
let error;
try {
await models.Account.changePassword(1, 'wrongPassword', 'nightmare.9999');
} catch (e) {
error = e.message;
}
const ctx = {req: {accessToken: {userId: 70}}};
const unauthCtx = {
req: {
headers: {},
connection: {
remoteAddress: '127.0.0.1'
},
getLocale: () => 'en'
},
args: {}
};
describe('Without 2FA', () => {
it('should throw an error when old password is wrong', async() => {
const tx = await models.Account.beginTransaction({});
expect(error).toContain('Invalid current password');
let error;
try {
const options = {transaction: tx};
await models.Account.changePassword(ctx, 'wrongPassword', 'nightmare.9999', null, options);
await tx.rollback();
} catch (e) {
await tx.rollback();
error = e.message;
}
expect(error).toContain('Invalid current password');
});
it('should throw an error when old and new password are the same', async() => {
const tx = await models.Account.beginTransaction({});
let error;
try {
const options = {transaction: tx};
await models.Account.changePassword(ctx, 'nightmare', 'nightmare.9999', null, options);
await models.Account.changePassword(ctx, 'nightmare.9999', 'nightmare.9999', null, options);
await tx.rollback();
} catch (e) {
await tx.rollback();
error = e.message;
}
expect(error).toContain('You can not use the same password');
});
it('should change password', async() => {
const tx = await models.Account.beginTransaction({});
try {
const options = {transaction: tx};
await models.Account.changePassword(ctx, 'nightmare', 'nightmare.9999', null, options);
await tx.rollback();
} catch (e) {
await tx.rollback();
expect(e).toBeUndefined();
}
});
});
it('should change password', async() => {
try {
await models.Account.changePassword(70, 'nightmare', 'nightmare.9999');
} catch (e) {
expect(e).toBeUndefined();
}
describe('With 2FA', () => {
it('should change password when code is correct', async() => {
const tx = await models.Account.beginTransaction({});
const yesterday = Date.vnNew();
yesterday.setDate(yesterday.getDate() - 1);
const options = {transaction: tx};
try {
await models.VnUser.updateAll(
{id: 70},
{
twoFactor: 'email',
passExpired: yesterday
}
, options);
await models.VnUser.signIn(unauthCtx, 'trainee', 'nightmare', options);
} catch (e) {
if (e.message != 'Pass expired')
throw e;
}
try {
const authCode = await models.AuthCode.findOne({where: {userFk: 70}}, options);
await models.Account.changePassword(ctx, 'nightmare', 'nightmare.9999', authCode.code, options);
await tx.rollback();
} catch (e) {
await tx.rollback();
expect(e).toBeUndefined();
}
});
});
});

View File

@ -8,8 +8,18 @@ describe('Account setPassword()', () => {
});
it('should update password when it passes requirements', async() => {
let req = models.Account.setPassword(1, 'Very$ecurePa22.');
const tx = await models.Account.beginTransaction({});
await expectAsync(req).toBeResolved();
let error;
try {
const options = {transaction: tx};
await models.Account.setPassword(1, 'Very$ecurePa22.', options);
await tx.rollback();
} catch (e) {
await tx.rollback();
error = e;
}
expect(error).not.toBeDefined();
});
});

View File

@ -37,6 +37,13 @@
"principalType": "ROLE",
"principalId": "$authenticated",
"permission": "ALLOW"
},
{
"property": "changePassword",
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW"
}
]
}

View File

@ -28,7 +28,7 @@ module.exports = Self => {
const isAccount = await models.Account.findById(id);
if (isClient && !isAccount)
await models.Account.setPassword(id, newPassword);
await models.VnUser.setPassword(id, newPassword);
else
throw new UserError(`Modifiable password only via recovery or by an administrator`);
};

View File

@ -1,4 +1,4 @@
let UserError = require('vn-loopback/util/user-error');
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => {
Self.remoteMethodCtx('new', {
@ -49,7 +49,7 @@ module.exports = Self => {
try {
const itemConfig = await models.ItemConfig.findOne({fields: ['validPriorities']}, myOptions);
if (!itemConfig.validPriorities.includes(params.priority))
throw new UserError(`Valid priorities: ${[...itemConfig.validPriorities]}`);
throw new UserError('Valid priorities', 'VALID_PRIORITIES', [...itemConfig.validPriorities]);
const provisionalName = params.provisionalName;
delete params.provisionalName;

View File

@ -50,7 +50,7 @@ module.exports = Self => {
fields: ['id']}
, myOptions);
if (ticketRefunds.length > 0)
throw new UserError($t('Tickets with associated refunds', {id: ticketRefunds[0].id}));
throw new UserError('Tickets with associated refunds', 'TICKET_REFUND', ticketRefunds[0].id);
// Check if has sales with shelving
const canDeleteTicketWithPartPrepared =

View File

@ -33,7 +33,6 @@ module.exports = Self => {
map.set(node.parentFk, []);
map.get(node.parentFk).push(node);
}
function setLeaves(nodes) {
if (!nodes) return;
for (let node of nodes) {
@ -43,6 +42,7 @@ module.exports = Self => {
}
let leaves = map.get(parentId);
setLeaves(leaves);
return leaves || [];

View File

@ -0,0 +1,10 @@
const models = require('vn-loopback/server/server').models;
describe('department getLeaves()', () => {
const ctx = {req: {accessToken: {userId: 9}}};
it('should return the department and the childs containing the search value', async() => {
let result = await models.Department.getLeaves(ctx, null, 'INFORMATICA');
expect(result.length).toEqual(1);
});
});

View File

@ -8,8 +8,9 @@
},
"properties": {
"id": {
"type": "number",
"id": true,
"type": "number"
"description": "Identifier"
},
"code": {
"type": "string"
@ -37,6 +38,30 @@
},
"hasToMistake": {
"type": "number"
},
"isTeleworking": {
"type": "boolean"
},
"hasToRefill": {
"type": "boolean"
},
"hasToSendMail": {
"type": "boolean"
},
"isProduction": {
"type": "boolean"
}
},
"relations": {
"client": {
"type": "belongsTo",
"model": "Client",
"foreignKey": "clientFk"
},
"worker": {
"type": "belongsTo",
"model": "Worker",
"foreignKey": "workerFk"
}
}
}

View File

@ -60,7 +60,7 @@
"user": {
"type": "belongsTo",
"model": "VnUser",
"foreignKey": "userFk"
"foreignKey": "id"
},
"boss": {
"type": "belongsTo",

View File

@ -0,0 +1,102 @@
<vn-watcher
vn-id="watcher"
data="$ctrl.department"
form="form"
url="Departments">
</vn-watcher>
<vn-portal slot="topbar">
<vn-searchbar
vn-focus
info="Search departments by id, name or code"
on-search="$ctrl.onSearch($params)"
base-state="worker.department">
</vn-searchbar>
</vn-portal>
<form name="form" ng-submit="watcher.submit()" class="vn-w-md">
<vn-card class="vn-pa-lg">
<vn-vertical>
<vn-horizontal>
<vn-textfield
vn-one
label="Name"
ng-model="$ctrl.department.name"
vn-name="Name">
</vn-textfield>
<vn-textfield
vn-one
label="Code"
ng-model="$ctrl.department.code"
vn-name="Code">
</vn-textfield>
</vn-horizontal>
<vn-horizontal>
<vn-textfield
vn-one
label="Chat"
ng-model="$ctrl.department.chatName"
vn-name="Chat">
</vn-textfield>
<vn-textfield type="Email"
vn-one
label="Email"
ng-model="$ctrl.department.notificationEmail"
vn-name="Email">
</vn-textfield>
</vn-horizontal>
<vn-horizontal>
<vn-autocomplete
ng-model="$ctrl.department.workerFk"
url="Workers/activeWithInheritedRole"
show-field="nickname"
search-function="{firstName: $search}"
label="Boss department">
</vn-autocomplete>
<vn-autocomplete
ng-model="$ctrl.department.clientFk"
url="Clients/"
show-field="name"
search-function="{firstName: $search}"
label="Self-consumption customer">
</vn-autocomplete>
</vn-horizontal>
<vn-horizontal>
<vn-check
label="Telework"
ng-model="$ctrl.department.isTeleworking">
</vn-check>
<vn-check
label="Notify on errors"
ng-model="$ctrl.department.hasToMistake">
</vn-check>
</vn-horizontal>
<vn-horizontal>
<vn-check
label="worksInProduction"
ng-model="$ctrl.department.isProduction">
</vn-check>
<vn-check
label="Fill in days without physical check-ins"
ng-model="$ctrl.department.hasToRefill">
</vn-check>
</vn-horizontal>
<vn-horizontal>
<vn-check
label="Send check-ins by email"
ng-model="$ctrl.department.hasToSendMail">
</vn-check>
</vn-horizontal>
</vn-vertical>
</vn-card>
<vn-button-bar>
<vn-submit
disabled="!watcher.dataChanged()"
label="Save">
</vn-submit>
<vn-button
class="cancel"
label="Undo changes"
disabled="!watcher.dataChanged()"
ng-click="watcher.loadOriginalData()">
</vn-button>
</vn-button-bar>
</form>

View File

@ -0,0 +1,10 @@
import ngModule from '../../module';
import Section from 'salix/components/section';
ngModule.vnComponent('vnWorkerDepartmentBasicData', {
template: require('./index.html'),
controller: Section,
bindings: {
department: '<'
}
});

View File

@ -0,0 +1,13 @@
Name: Nombre
Code: Código
Chat: Chat
Email: Email
Boss department: Jefe del departamento
Self-consumption customer: Cliente autoconsumo
Telecommutes: Teletrabaja
Notify on errors: Notificar errores
worksInProduction: Pertenece a producción
Fill in days without physical check-ins: Completar días sin registros físicos
Send check-ins by email: Enviar fichadas por email
Save: Guardar
Undo changes: Deshacer cambios

View File

@ -0,0 +1,5 @@
<vn-portal slot="menu">
<vn-worker-department-descriptor department="$ctrl.department"></vn-worker-department-descriptor>
<vn-left-menu source="department"></vn-left-menu>
</vn-portal>
<ui-view></ui-view>

View File

@ -0,0 +1,39 @@
import ngModule from '../../module';
import ModuleCard from 'salix/components/module-card';
class Controller extends ModuleCard {
reload() {
const filter = {
fields: ['id', 'name', 'code', 'workerFk', 'isProduction', 'chatName',
'isTeleworking', 'notificationEmail', 'hasToRefill', 'hasToSendMail', 'hasToMistake', 'clientFk'],
include: [
{
relation: 'client',
scope: {
fields: ['id', 'name']
}
}, {
relation: 'worker',
scope: {
fields: ['id', 'firstName', 'lastName'],
include: {
relation: 'user',
scope: {
fields: ['name']
}
}
}
}
]
};
this.$http.get(`Departments/${this.$params.id}`, {filter})
.then(res => this.department = res.data);
}
}
ngModule.vnComponent('vnWorkerDepartmentCard', {
template: require('./index.html'),
controller: Controller
});

View File

@ -0,0 +1,4 @@
<slot-descriptor>
<vn-worker-department-descriptor>
</vn-worker-department-descriptor>
</slot-descriptor>

View File

@ -0,0 +1,9 @@
import ngModule from '../../module';
import DescriptorPopover from 'salix/components/descriptor-popover';
class Controller extends DescriptorPopover {}
ngModule.vnComponent('vnWorkerDepartmentDescriptorPopover', {
slotTemplate: require('./index.html'),
controller: Controller
});

View File

@ -0,0 +1,49 @@
<vn-descriptor-content
module="worker"
description="$ctrl.department.name"
base-state="worker.department">
<slot-menu>
<vn-item
ng-click="deleteConfirmation.show()"
name="deleteDepartment"
translate>
Delete
</vn-item>
</slot-menu>
<slot-body>
<div class="attributes">
<vn-label-value
label="Chat"
value="{{$ctrl.department.chatName}}">
</vn-label-value>
<vn-label-value
label="Email"
value="{{$ctrl.department.emailNotification}}">
</vn-label-value>
<vn-label-value
label="Self-consumption customer"
value="{{$ctrl.department.client.name}}">
</vn-label-value>
<vn-label-value
label="Boss department"
value="{{$ctrl.department.worker.firstName}}">
</vn-label-value>
</div>
<div class="quicklinks">
<div ng-transclude="btnOne">
<vn-quick-link vn-anchor="{state: 'worker.index', params: {q: {departmentFk: $ctrl.$params.id} } }"
vn-tooltip="Department workers"
icon="icon-worker">
</vn-quick-link>
</div>
</div>
</slot-body>
</vn-descriptor-content>
<vn-popup vn-id="summary">
<vn-worker-summary worker="$ctrl.worker"></vn-worker-summary>
</vn-popup>
<vn-confirm
vn-id="deleteConfirmation"
on-accept="$ctrl.deleteDepartment()"
question="Are you sure you want to delete this department?">
</vn-confirm>

View File

@ -0,0 +1,45 @@
import ngModule from '../../module';
import Descriptor from 'salix/components/descriptor';
class Controller extends Descriptor {
constructor($element, $, $rootScope) {
super($element, $);
this.$rootScope = $rootScope;
}
get department() {
return this.entity;
}
set department(value) {
this.entity = value;
}
filterDepartments(department, event) {
if (event.defaultPrevented) return;
event.preventDefault();
event.stopPropagation();
this.$state.go(`worker.index`,
{q: JSON.stringify({departmentFk: department.id})});
}
deleteDepartment() {
return this.$http.delete(`Departments/${this.id}`)
.then(() => {
this.$state.go('worker.department');
this.vnApp.showSuccess(this.$t('Department deleted.'));
});
}
}
Controller.$inject = ['$element', '$scope', '$rootScope'];
ngModule.vnComponent('vnWorkerDepartmentDescriptor', {
template: require('./index.html'),
controller: Controller,
bindings: {
department: '<'
}
});

View File

@ -1,83 +1,7 @@
import ngModule from '../module';
import Section from 'salix/components/section';
class Controller extends Section {
$postLink() {
this.$.treeview.fetch();
}
onFetch(item) {
const params = item ? {parentId: item.id} : null;
return this.$.model.applyFilter({}, params).then(() => {
return this.$.model.data;
});
}
onSort(a, b) {
return a.name.localeCompare(b.name);
}
onDrop(dropped, dragged) {
const params = dropped ? {parentId: dropped.id} : null;
const query = `departments/${dragged.id}/moveChild`;
this.$http.post(query, params).then(() => {
this.$.treeview.move(dragged, dropped);
});
}
onCreate(parent) {
this.newChild = {
parent: parent,
name: ''
};
this.$.createNode.show();
}
onCreateDialogOpen() {
this.newChild.name = '';
}
onCreateResponse() {
try {
if (!this.newChild.name)
throw new Error(`Name can't be empty`);
const params = {name: this.newChild.name};
const parent = this.newChild.parent;
if (parent && parent.id)
params.parentId = parent.id;
const query = `departments/createChild`;
this.$http.post(query, params).then(res => {
const item = res.data;
item.parent = parent;
this.$.treeview.create(item);
});
} catch (e) {
this.vnApp.showError(this.$t(e.message));
return false;
}
return true;
}
onRemove(item) {
this.removedChild = item;
this.$.deleteNode.show();
}
onRemoveResponse() {
const childId = this.removedChild.id;
const path = `departments/${childId}/removeChild`;
this.$http.post(path).then(() => {
this.$.treeview.remove(this.removedChild);
});
}
}
ngModule.vnComponent('vnWorkerDepartment', {
template: require('./index.html'),
controller: Controller
});
import './main';
import './index/';
import './summary';
import './card';
import './descriptor';
import './basic-data';
import './descriptor-popover';

View File

@ -3,6 +3,14 @@
url="departments/getLeaves"
auto-load="false">
</vn-crud-model>
<vn-portal slot="topbar">
<vn-searchbar
vn-focus
info="Search departments by id, name or code"
on-search="$ctrl.onSearch($params)"
base-state="worker.department">
</vn-searchbar>
</vn-portal>
<form name="form" class="vn-w-md">
<vn-card class="vn-pa-md">
<vn-treeview vn-id="treeview" root-label="Departments" read-only="false"
@ -13,7 +21,12 @@
on-drop="$ctrl.onDrop($dropped, $dragged)"
on-drag-start="$ctrl.onDragStart(item)"
on-drag-end="$ctrl.onDragEnd(item)">
{{::item.name}}
<a
style="display: block; color: inherit;"
ui-sref="worker.department.card.summary({id: item.id})"
translate>
{{::item.name}}
</a>
</vn-treeview>
</vn-card>
</form>

View File

@ -0,0 +1,94 @@
import ngModule from '../../module';
import Section from 'salix/components/section';
class Controller extends Section {
$postLink() {
this.$.treeview.fetch();
if (this.$params.q) {
const search = JSON.parse(this.$params.q);
this.onSearch(search);
}
}
onSearch(params) {
this.$.model.applyFilter({}, params).then(() => {
const data = this.$.model.data;
this.$.treeview.data = data;
});
}
onFetch(item) {
const params = item ? {parentId: item.id} : null;
return this.$.model.applyFilter({}, params).then(() => {
return this.$.model.data;
});
}
onSort(a, b) {
return a.name.localeCompare(b.name);
}
onDrop(dropped, dragged) {
const params = dropped ? {parentId: dropped.id} : null;
const query = `departments/${dragged.id}/moveChild`;
this.$http.post(query, params).then(() => {
this.$.treeview.move(dragged, dropped);
});
}
onCreate(parent) {
this.newChild = {
parent: parent,
name: ''
};
this.$.createNode.show();
}
onCreateDialogOpen() {
this.newChild.name = '';
}
onCreateResponse() {
try {
if (!this.newChild.name)
throw new Error(`Name can't be empty`);
const params = {name: this.newChild.name};
const parent = this.newChild.parent;
if (parent && parent.id)
params.parentId = parent.id;
const query = `departments/createChild`;
this.$http.post(query, params).then(res => {
const item = res.data;
item.parent = parent;
this.$.treeview.create(item);
});
} catch (e) {
this.vnApp.showError(this.$t(e.message));
return false;
}
return true;
}
onRemove(item) {
this.removedChild = item;
this.$.deleteNode.show();
}
onRemoveResponse() {
const childId = this.removedChild.id;
const path = `departments/${childId}/removeChild`;
this.$http.post(path).then(() => {
this.$.treeview.remove(this.removedChild);
});
}
}
ngModule.vnComponent('vnWorkerDepartmentIndex', {
template: require('./index.html'),
controller: Controller
});

View File

@ -1,4 +1,5 @@
New department: Nuevo departamento
Delete department: Eliminar departamento
Are you sure you want to delete it?: ¿Seguro que quieres eliminarlo?
Name can't be empty: El nombre esta vacio
Name can't be empty: El nombre esta vacio
Department workers: Trabajadores del departamento

View File

@ -0,0 +1,9 @@
<vn-crud-model
vn-id="model"
url="Departments/filter"
limit="20"
order="id">
</vn-crud-model>
<ui-view>
<vn-worker-department-index></vn-worker-department-index>
</ui-view>

View File

@ -0,0 +1,7 @@
import ngModule from '../../module';
import ModuleMain from 'salix/components/module-main';
ngModule.vnComponent('vnWorkerDepartment', {
controller: ModuleMain,
template: require('./index.html')
});

View File

@ -0,0 +1,91 @@
<vn-portal slot="topbar">
<vn-searchbar
vn-focus
info="Search departments by id, name or code"
on-search="$ctrl.onSearch($params)"
base-state="worker.department">
</vn-searchbar>
</vn-portal>
<vn-card class="summary">
<h5>
<span>{{summary.name}}</span>
</h5>
<vn-horizontal class="vn-pa-md">
<vn-one class="expand">
<h4 ng-show="$ctrl.isHr">
<a
ui-sref="worker.department.card.basicData({id:summary.id})">
<span translate vn-tooltip="Go to">Basic data</span>
</a>
</h4>
<h4
translates
ng-show="!$ctrl.isHr">
Basic data
</h4>
</vn-one>
<vn-one>
<vn-vertical>
<vn-label-value label="Nombre"
value="{{summary.name}}">
</vn-label-value>
<vn-label-value label="Code"
value="{{summary.code}}">
</vn-label-value>
<vn-label-value label="Chat"
value="{{summary.chatName}}">
</vn-label-value>
<vn-label-value
label="Boss department">
<span
ng-click="workerDescriptor.show($event, summary.worker.id)"
class="link">
{{summary.worker.user.name}}
</span>
</vn-label-value>
<vn-label-value
label="Email"
value="{{summary.notificationEmail}}">
</vn-label-value>
<vn-label-value label="Self-consumption customer"
value="{{summary.client.name}}">
</vn-label-value>
</vn-vertical>
</vn-one>
<vn-one>
<vn-vertical>
<vn-check
label="Telework"
ng-model="summary.isTeleworking"
disabled="true">
</vn-check>
<vn-check
label="Notify on errors"
ng-model="summary.hasToMistake"
disabled="true">
</vn-check>
<vn-check
label="worksInProduction"
ng-model="summary.isProduction"
disabled="true">
</vn-check>
<vn-check
label="Fill in days without physical check-ins"
ng-model="summary.hasToRefill"
disabled="true">
</vn-check>
<vn-check
label="Send check-ins by email"
ng-model="summary.hasToSendMail"
disabled="true">
</vn-check>
</vn-vertical>
</vn-one>
</vn-horizontal>
</vn-card>
<vn-worker-descriptor-popover
vn-id="workerDescriptor">
</vn-worker-descriptor-popover>
<vn-worker-department-descriptor-popover
vn-id="workerDepartmentDescriptor">
</vn-worker-department-descriptor-popover>

View File

@ -0,0 +1,27 @@
import ngModule from '../../module';
import Component from 'core/lib/component';
import './style.scss';
class Controller extends Component {
set department(value) {
this._department = value;
this.$.summary = null;
if (!value) return;
this.$.summary = this.department;
}
get department() {
return this._department;
}
get isHr() {
return this.aclService.hasAny(['hr']);
}
}
ngModule.component('vnWorkerDepartmentSummary', {
template: require('./index.html'),
controller: Controller,
bindings: {
department: '<'
}
});

View File

@ -0,0 +1,12 @@
Name: Nombre
Code: Código
Chat: Chat
Boss department: Jefe de departamento
Email: Email
Self-consumption customer: Cliente autoconsumo
Telework: Teletrabaja
Notify on errors: Notificar errores
worksInProduction: Pertenece a producción
Fill in days without physical check-ins: Completar días sin registros físicos
Send check-ins by email: Enviar fichadas por mail
Are you sure you want to delete this department?: ¿Estás seguro de que quieres eliminar este departamento?

View File

@ -0,0 +1,8 @@
@import "./variables";
vn-worker-department-summary {
.expand {
min-width: 100%;
}
}

View File

@ -24,6 +24,9 @@
"description": "Wikipedia"
},
{"state": "worker.card.workerLog", "icon": "history"}
],
"department": [
{"state": "worker.department.card.basicData", "icon": "settings"}
]
},
"keybindings": [
@ -118,12 +121,35 @@
"worker": "$ctrl.worker"
}
}, {
"url" : "/department",
"url": "/department?q",
"state": "worker.department",
"component": "vn-worker-department",
"description": "Departments",
"acl": ["hr"]
"description":"Departments"
}, {
"url": "/:id",
"state": "worker.department.card",
"component": "vn-worker-department-card",
"abstract": true,
"description": "Detail"
}, {
"url" : "/summary",
"state": "worker.department.card.summary",
"component": "vn-worker-department-summary",
"description": "Summary",
"params": {
"department": "$ctrl.department"
}
},
{
"url": "/basic-data",
"state": "worker.department.card.basicData",
"component": "vn-worker-department-basic-data",
"description": "Basic data",
"params": {
"department": "$ctrl.department"
}
},
{
"url": "/dms",
"state": "worker.card.dms",
"abstract": true,

View File

@ -27,15 +27,19 @@
<vn-label-value label="Email" no-ellipsize
value="{{worker.user.emailUser.email}}">
</vn-label-value>
<vn-label-value label="Department"
value="{{worker.department.department.name}}">
<vn-label-value label="Department">
<span
ng-click="workerDepartmentDescriptor.show($event, worker.department.department.id)"
class="link">
{{worker.department.department.name}}
</span>
</vn-label-value>
<vn-label-value
label="Boss">
<span
ng-click="workerDescriptor.show($event, worker.boss.id)"
class="link">
{{::worker.boss.nickname}}
{{::worker.boss.name}}
</span>
</vn-label-value>
<vn-label-value label="Mobile extension"
@ -71,3 +75,6 @@
<vn-worker-descriptor-popover
vn-id="workerDescriptor">
</vn-worker-descriptor-popover>
<vn-worker-department-descriptor-popover
vn-id="workerDepartmentDescriptor">
</vn-worker-department-descriptor-popover>

View File

@ -38,7 +38,7 @@ class Controller extends Summary {
},
{
relation: 'boss',
scope: {fields: ['id', 'nickname']}
scope: {fields: ['id', 'name']}
},
{
relation: 'sip',
@ -48,7 +48,8 @@ class Controller extends Summary {
relation: 'department',
scope: {
include: {
relation: 'department'
relation: 'department',
scope: {fields: ['id', 'code', 'name']}
}
}
}

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "salix-back",
"version": "23.28.01",
"version": "23.30.01",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "salix-back",
"version": "23.24.01",
"version": "23.26.01",
"license": "GPL-3.0",
"dependencies": {
"axios": "^1.2.2",

View File

@ -1,6 +1,6 @@
{
"name": "salix-back",
"version": "23.28.01",
"version": "23.30.01",
"author": "Verdnatura Levante SL",
"description": "Salix backend",
"license": "GPL-3.0",

View File

@ -84,7 +84,8 @@ class Email extends Component {
replyTo: this.args.replyTo || '',
subject: localeSubject,
html: rendered,
attachments: attachments
attachments: attachments,
force: options.force
};
return smtp.send(mailOptions);

View File

@ -10,16 +10,17 @@ module.exports = {
async send(options) {
options.from = `${config.app.senderName} <${config.app.senderEmail}>`;
if (!process.env.NODE_ENV)
options.to = config.app.senderEmail;
if (process.env.NODE_ENV !== 'production') {
if (process.env.NODE_ENV !== 'production' && !options.force) {
const notProductionError = {message: 'This not production, this email not sended'};
await this.mailLog(options, notProductionError);
if (!config.smtp.auth.user)
return Promise.resolve(true);
options.to = config.app.senderEmail;
}
if (!config.smtp.auth.user)
return Promise.resolve(true);
let res;
let error;
try {

View File

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

View File

@ -0,0 +1,5 @@
.code {
border: 2px dashed #8dba25;
border-radius: 3px;
text-align: center
}

View File

@ -0,0 +1,23 @@
<email-body v-bind="$props">
<div class="grid-row">
<div class="grid-block vn-pa-ml">
<h1>{{ $t('title') }}</h1>
<p>{{ $t('description') }}</p>
<p>
{{ $t('device') }}: <strong>{{ device }}</strong>
</p>
<p>
{{$t('ip')}}: <strong>{{ ip }}</strong>
</p>
</div>
</div>
<div class="grid-row">
<div class="grid-block vn-pa-ml">
<p>{{ $t('Enter the following code to continue to your account') }}</p>
<div class="code vn-pa-sm vn-m-md">
{{ code }}
</div>
<p>{{ $t('It expires in 5 minutes') }}</p>
</div>
</div>
</email-body>

View File

@ -0,0 +1,21 @@
const Component = require(`vn-print/core/component`);
const emailBody = new Component('email-body');
module.exports = {
name: 'auth-code',
components: {
'email-body': emailBody.build(),
},
props: {
code: {
type: String,
required: true
},
device: {
type: String
},
ip: {
type: String
}
}
};

View File

@ -0,0 +1,7 @@
subject: Verification code
title: Verification code
description: Somebody did request a verification code for login. If you didn't request it, please ignore this email.
device: 'Device'
ip: 'IP'
Enter the following code to continue to your account: Enter the following code to continue to your account
It expires in 5 minutes: It expires in 5 minutes

View File

@ -0,0 +1,7 @@
subject: Código de verificación
title: Código de verificación
description: Alguien ha solicitado un código de verificación para poder iniciar sesión. Si no lo has solicitado tu, ignora este email.
device: 'Dispositivo'
ip: 'IP'
Enter the following code to continue to your account: Introduce el siguiente código para poder continuar con tu cuenta
It expires in 5 minutes: Expira en 5 minutos

View File

@ -0,0 +1,7 @@
subject: Code de vérification
title: Code de vérification
description: Quelqu'un a demandé un code de vérification pour se connecter. Si ce n'était pas toi, ignore cet email.
device: 'Appareil'
ip: 'IP'
Enter the following code to continue to your account: Entrez le code suivant pour continuer avec votre compte
It expires in 5 minutes: Il expire dans 5 minutes.

View File

@ -0,0 +1,7 @@
subject: Código de verificação
title: Código de verificação
description: Alguém solicitou um código de verificação para entrar. Se você não fez essa solicitação, ignore este e-mail.
device: 'Dispositivo'
ip: 'IP'
Enter the following code to continue to your account: Insira o seguinte código para continuar com sua conta.
It expires in 5 minutes: Expira em 5 minutos.