This commit is contained in:
Carlos Jimenez Ruiz 2019-01-24 11:07:23 +01:00
commit 366527d87e
73 changed files with 1449 additions and 1044 deletions

View File

@ -0,0 +1,45 @@
module.exports = Self => {
Self.remoteMethod('acl', {
description: 'Get the user information and permissions',
accepts: [
{
arg: 'ctx',
type: 'Object',
http: {source: 'context'}
}
],
returns: {
type: 'Object',
root: true
},
http: {
path: `/acl`,
verb: 'GET'
}
});
Self.acl = async function(ctx) {
let userId = ctx.req.accessToken.userId;
let models = Self.app.models;
let user = await models.Account.findById(userId, {
fields: ['id', 'name', 'nickname', 'email']
});
let roles = await models.RoleMapping.find({
fields: ['roleId'],
where: {
principalId: userId,
principalType: 'USER'
},
include: [{
relation: 'role',
scope: {
fields: ['name']
}
}]
});
return {roles, user};
};
};

View File

@ -1,4 +1,3 @@
const url = require('url');
const md5 = require('md5'); const md5 = require('md5');
module.exports = Self => { module.exports = Self => {
@ -13,12 +12,7 @@ module.exports = Self => {
}, { }, {
arg: 'password', arg: 'password',
type: 'String', type: 'String',
description: 'The user name or email', description: 'The user name or email'
required: true
}, {
arg: 'location',
type: 'String',
description: 'Location to redirect after login'
} }
], ],
returns: { returns: {
@ -31,7 +25,7 @@ module.exports = Self => {
} }
}); });
Self.login = async function(user, password, location) { Self.login = async function(user, password) {
let token; let token;
let usesEmail = user.indexOf('@') !== -1; let usesEmail = user.indexOf('@') !== -1;
let User = Self.app.models.User; let User = Self.app.models.User;
@ -68,25 +62,6 @@ module.exports = Self => {
token = await User.login(loginInfo, 'user'); token = await User.login(loginInfo, 'user');
} }
let apiKey; return {token: token.id};
let continueUrl;
try {
let query = url.parse(location, true).query;
apiKey = query.apiKey;
continueUrl = query.continue;
} catch (e) {
continueUrl = null;
}
let applications = Self.app.get('applications');
if (!apiKey) apiKey = 'default';
let loginUrl = applications[apiKey] || '/login';
return {
token: token.id,
continue: continueUrl,
loginUrl: loginUrl
};
}; };
}; };

View File

@ -0,0 +1,33 @@
const app = require(`${serviceRoot}/server/server`);
describe('account login()', () => {
describe('when credentials are correct', () => {
it('should return the token', async() => {
let response = await app.models.Account.login('employee', 'nightmare');
expect(response.token).toBeDefined();
});
it('should return the token if the user doesnt exist but the client does', async() => {
let response = await app.models.Account.login('PetterParker', 'nightmare');
expect(response.token).toBeDefined();
});
});
describe('when credentials are incorrect', () => {
it('should throw a 401 error', async() => {
let error;
try {
await app.models.Account.login('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,42 @@
const app = require(`${serviceRoot}/server/server`);
describe('account logout()', () => {
it('should logout and remove token after valid login', async() => {
let loginResponse = await app.models.Account.login('employee', 'nightmare');
let accessToken = await app.models.AccessToken.findById(loginResponse.token);
let ctx = {req: {accessToken: accessToken}};
let response = await app.models.Account.logout(ctx);
let afterToken = await app.models.AccessToken.findById(loginResponse.token);
expect(response).toBeTruthy();
expect(afterToken).toBeNull();
});
it('should throw a 401 error when token is invalid', async() => {
let error;
let ctx = {req: {accessToken: {id: 'invalidToken'}}};
try {
response = await app.models.Account.logout(ctx);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.statusCode).toBe(401);
});
it('should throw an error when no token is passed', async() => {
let error;
let ctx = {req: {accessToken: null}};
try {
response = await app.models.Account.logout(ctx);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
});
});

View File

@ -0,0 +1,31 @@
module.exports = Self => {
Self.remoteMethod('validateToken', {
description: 'Get the user information and permissions',
accepts: [
{
arg: 'token',
type: 'String',
description: 'The token to validate',
required: true
}
],
returns: {
type: 'Boolean',
root: true
},
http: {
path: `/validateToken`,
verb: 'GET'
}
});
Self.validateToken = function(tokenId, cb) {
Self.app.models.AccessToken.findById(tokenId, (err, token) => {
if (err) return cb(err);
if (token)
token.validate((_, isValid) => cb(null, isValid === true));
else
cb(null, false);
});
};
};

View File

@ -3,6 +3,8 @@ const md5 = require('md5');
module.exports = Self => { module.exports = Self => {
require('../methods/account/login')(Self); require('../methods/account/login')(Self);
require('../methods/account/logout')(Self); require('../methods/account/logout')(Self);
require('../methods/account/acl')(Self);
require('../methods/account/validate-token')(Self);
// Validations // Validations

View File

@ -15,6 +15,9 @@
"type": "string", "type": "string",
"required": true "required": true
}, },
"nickname": {
"type": "string"
},
"password": { "password": {
"type": "string", "type": "string",
"required": true "required": true
@ -45,6 +48,12 @@
"principalType": "ROLE", "principalType": "ROLE",
"principalId": "$authenticated", "principalId": "$authenticated",
"permission": "ALLOW" "permission": "ALLOW"
}, {
"property": "validateToken",
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW"
} }
] ]
} }

View File

@ -1,3 +1,3 @@
export default { export default {
url: 'http://localhost:5000/' url: 'http://localhost:5000'
}; };

View File

@ -25,13 +25,13 @@ Nightmare.asyncAction('clearInput', async function(selector) {
let actions = { let actions = {
login: function(userName, done) { login: function(userName, done) {
this.goto(`${config.url}auth/?apiKey=salix`) this.goto(`${config.url}#!/login`)
.wait(`vn-login input[name=user]`) .wait(`vn-login input[name=user]`)
.write(`vn-login input[name=user]`, userName) .write(`vn-login input[name=user]`, userName)
.write(`vn-login input[name=password]`, 'nightmare') .write(`vn-login input[name=password]`, 'nightmare')
.click(`vn-login input[type=submit]`) .click(`vn-login input[type=submit]`)
// FIXME: Wait for dom to be ready: https://github.com/segmentio/nightmare/issues/481 // FIXME: Wait for dom to be ready: https://github.com/segmentio/nightmare/issues/481
.wait(1000) // .wait(1000)
.then(() => { .then(() => {
currentUser = userName; currentUser = userName;
done(); done();

View File

@ -2,7 +2,6 @@ import components from './components_selectors.js';
export default { export default {
globalItems: { globalItems: {
logOutButton: `#logout`,
applicationsMenuButton: `#apps`, applicationsMenuButton: `#apps`,
applicationsMenuVisible: `vn-main-menu [vn-id="apps-menu"] ul`, applicationsMenuVisible: `vn-main-menu [vn-id="apps-menu"] ul`,
clientsButton: `vn-main-menu [vn-id="apps-menu"] ul > li[ui-sref="client.index"]`, clientsButton: `vn-main-menu [vn-id="apps-menu"] ul > li[ui-sref="client.index"]`,

View File

@ -89,7 +89,6 @@ describe('Client Edit basicData path', () => {
describe('as salesAssistant', () => { describe('as salesAssistant', () => {
beforeAll(() => { beforeAll(() => {
nightmare nightmare
.waitToClick(selectors.globalItems.logOutButton)
.loginAndModule('salesASsistant', 'client') .loginAndModule('salesASsistant', 'client')
.accessToSearchResult('Ptonomy Wallace') .accessToSearchResult('Ptonomy Wallace')
.accessToSection('client.card.basicData'); .accessToSection('client.card.basicData');

View File

@ -48,7 +48,6 @@ describe('Client lock verified data path', () => {
describe('as administrative', () => { describe('as administrative', () => {
beforeAll(() => { beforeAll(() => {
nightmare nightmare
.waitToClick(selectors.globalItems.logOutButton)
.waitForLogin('administrative'); .waitForLogin('administrative');
}); });
@ -150,7 +149,6 @@ describe('Client lock verified data path', () => {
describe('as salesPerson second run', () => { describe('as salesPerson second run', () => {
beforeAll(() => { beforeAll(() => {
nightmare nightmare
.waitToClick(selectors.globalItems.logOutButton)
.waitForLogin('salesPerson'); .waitForLogin('salesPerson');
}); });
@ -219,7 +217,6 @@ describe('Client lock verified data path', () => {
describe('as salesAssistant', () => { describe('as salesAssistant', () => {
beforeAll(() => { beforeAll(() => {
nightmare nightmare
.waitToClick(selectors.globalItems.logOutButton)
.waitForLogin('salesAssistant'); .waitForLogin('salesAssistant');
}); });
@ -298,7 +295,6 @@ describe('Client lock verified data path', () => {
describe('as salesPerson third run', () => { describe('as salesPerson third run', () => {
beforeAll(() => { beforeAll(() => {
nightmare nightmare
.waitToClick(selectors.globalItems.logOutButton)
.waitForLogin('salesPerson'); .waitForLogin('salesPerson');
}); });

View File

@ -475,7 +475,6 @@ describe('Ticket Edit sale path', () => {
it('should log in as Production role and go to the ticket index', async() => { it('should log in as Production role and go to the ticket index', async() => {
const url = await nightmare const url = await nightmare
.waitToClick(selectors.globalItems.logOutButton)
.waitForLogin('production') .waitForLogin('production')
.waitToClick(selectors.globalItems.applicationsMenuButton) .waitToClick(selectors.globalItems.applicationsMenuButton)
.wait(selectors.globalItems.applicationsMenuVisible) .wait(selectors.globalItems.applicationsMenuVisible)
@ -562,7 +561,6 @@ describe('Ticket Edit sale path', () => {
it('should log in as salesPerson role and go to the ticket index', async() => { it('should log in as salesPerson role and go to the ticket index', async() => {
const url = await nightmare const url = await nightmare
.waitToClick(selectors.globalItems.logOutButton)
.waitForLogin('salesPerson') .waitForLogin('salesPerson')
.waitToClick(selectors.globalItems.applicationsMenuButton) .waitToClick(selectors.globalItems.applicationsMenuButton)
.wait(selectors.globalItems.applicationsMenuVisible) .wait(selectors.globalItems.applicationsMenuVisible)

View File

@ -1,11 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Salix</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body ng-app="vnAuth">
<vn-login></vn-login>
</body>
</html>

View File

@ -1,2 +0,0 @@
import './module';
import './login/login';

View File

@ -1,29 +0,0 @@
<div>
<div class="box-wrapper">
<div class="box">
<img src="./logo.svg"/>
<form name="form" ng-submit="$ctrl.submit()">
<vn-textfield
label="User"
model="$ctrl.user"
name="user"
vn-id="userField"
vn-focus>
</vn-textfield>
<vn-textfield
label="Password"
model="$ctrl.password"
name="password"
type="password">
</vn-textfield>
<div class="footer">
<vn-submit label="Enter"></vn-submit>
<div class="spinner-wrapper">
<vn-spinner enable="$ctrl.loading"></vn-spinner>
</div>
</div>
</form>
</div>
</div>
<vn-snackbar vn-id="snackbar"></vn-snackbar>
</div>

View File

@ -1,86 +0,0 @@
import ngModule from '../module';
import './style.scss';
/**
* A simple login form.
*/
export default class Controller {
constructor($element, $scope, $window, $http) {
this.$element = $element;
this.$ = $scope;
this.$window = $window;
this.$http = $http;
}
submit() {
if (!this.user) {
this.focusUser();
this.showError('Please insert your user and password');
return;
}
this.loading = true;
let params = {
user: this.user,
password: this.password,
location: this.$window.location.href
};
this.$http.post('/auth/login', params).then(
json => this.onLoginOk(json),
json => this.onLoginErr(json)
);
}
onLoginOk(json) {
this.loading = false;
let data = json.data;
let params = {
token: data.token,
continue: data.continue
};
let loginUrl = data.loginUrl || '';
this.$window.location = `${loginUrl}?${this.encodeUri(params)}`;
}
encodeUri(object) {
let uri = '';
for (let key in object) {
if (object[key] !== undefined) {
if (uri.length > 0)
uri += '&';
uri += encodeURIComponent(key) + '=' + encodeURIComponent(object[key]);
}
}
return uri;
}
onLoginErr(json) {
this.loading = false;
this.password = '';
let message;
switch (json.status) {
case 401:
message = 'Invalid credentials';
break;
case -1:
message = 'Can\'t contact with server';
break;
default:
message = 'Something went wrong';
}
this.showError(message);
this.focusUser();
}
focusUser() {
this.$.userField.select();
this.$.userField.focus();
}
showError(message) {
this.$.snackbar.showError({message: message});
}
}
Controller.$inject = ['$element', '$scope', '$window', '$http'];
ngModule.component('vnLogin', {
template: require('./login.html'),
controller: Controller
});

View File

@ -1,51 +0,0 @@
@import "colors";
vn-login > div {
position: absolute;
height: 100%;
width: 100%;
margin: 0;
padding: 0;
color: $main-font-color;
font-size: 1.1em;
font-weight: normal;
background-color: #3c393b;
.box-wrapper {
position: relative;
max-width: 19em;
margin: auto;
height: inherit;
}
.box {
box-sizing: border-box;
position: absolute;
top: 50%;
width: 100%;
margin-top: -13.5em;
padding: 3em;
background-color: white;
box-shadow: 0 0 1em 0 rgba(1,1,1,.6);
border-radius: .5em;
}
img {
width: 100%;
padding-bottom: 1em;
}
.footer {
margin-top: 1em;
text-align: center;
position: relative;
}
.spinner-wrapper {
position: absolute;
width: 0;
top: .3em;
right: 3em;
overflow: visible;
}
}

View File

@ -1,14 +0,0 @@
import {ng} from 'core/vendor';
import 'core';
let ngModule = ng.module('vnAuth', ['vnCore']);
export default ngModule;
config.$inject = ['$translatePartialLoaderProvider', '$httpProvider'];
export function config($translatePartialLoaderProvider, $httpProvider) {
$translatePartialLoaderProvider.addPart('auth');
$httpProvider.defaults.useXDomain = true;
delete $httpProvider.defaults.headers.common['X-Requested-With'];
}
ngModule.config(config);

View File

@ -16,12 +16,11 @@ function vnAcl(aclService, $timeout) {
let find = input.className.match(/mdl-[\w]+input/g); let find = input.className.match(/mdl-[\w]+input/g);
if (find && find.length && find[0]) { if (find && find.length && find[0]) {
let type = getMaterialType(find[0]); let type = getMaterialType(find[0]);
if (type && input.parentNode[`Material${type}`] && input.parentNode[`Material${type}`].updateClasses_) { if (type && input.parentNode[`Material${type}`] && input.parentNode[`Material${type}`].updateClasses_)
input.parentNode[`Material${type}`].updateClasses_(); input.parentNode[`Material${type}`].updateClasses_();
} }
} }
} }
}
function getDynamicConditions($attrs) { function getDynamicConditions($attrs) {
let atributes = $attrs.$attr; let atributes = $attrs.$attr;
let conditions = {}; let conditions = {};
@ -38,7 +37,7 @@ function vnAcl(aclService, $timeout) {
} }
function permissionElement($element, action) { function permissionElement($element, action) {
if (!aclService.aclPermission(acls)) { if (!aclService.hasAny(acls)) {
if (action === 'disabled') { if (action === 'disabled') {
let input = $element[0]; let input = $element[0];
let selector = 'input, textarea, button, submit'; let selector = 'input, textarea, button, submit';
@ -48,32 +47,31 @@ function vnAcl(aclService, $timeout) {
if (input) { if (input) {
$timeout(() => { $timeout(() => {
input.setAttribute("disabled", "true"); input.setAttribute('disabled', 'true');
updateMaterial(input); updateMaterial(input);
}); });
$element[0].querySelectorAll('vn-drop-down').forEach(element => { $element[0].querySelectorAll('vn-drop-down').forEach(element => {
element.parentNode.removeChild(element); element.parentNode.removeChild(element);
}); });
} }
} else { } else
$element.remove(); $element.remove();
} }
} }
}
function updateAcls(role, toAdd) { function updateAcls(role, toAdd) {
let position = acls.indexOf(role); let position = acls.indexOf(role);
if (!toAdd && position > -1) { if (!toAdd && position > -1)
acls.splice(position, 1); acls.splice(position, 1);
} // todo: add acl and enabled element if previusly was disabled // todo: add acl and enabled element if previusly was disabled
} }
return { return {
restrict: 'A', restrict: 'A',
priority: -1, priority: -1,
link: function($scope, $element, $attrs) { link: function($scope, $element, $attrs) {
acls = $attrs.vnAcl.split(',').map(element => element.trim().toLowerCase()); acls = $attrs.vnAcl.split(',').map(i => i.trim());
let action = $attrs.vnAclAction || 'disabled'; let action = $attrs.vnAclAction || 'disabled';
let conditions = getDynamicConditions($attrs); let conditions = getDynamicConditions($attrs);
permissionElement($element, action); permissionElement($element, action);

View File

@ -8,7 +8,7 @@ describe('Directive acl', () => {
compile = (hasPermissions, _element) => { compile = (hasPermissions, _element) => {
inject(($compile, $rootScope, aclService, _$timeout_) => { inject(($compile, $rootScope, aclService, _$timeout_) => {
spyOn(aclService, 'aclPermission').and.returnValue(hasPermissions); spyOn(aclService, 'hasAny').and.returnValue(hasPermissions);
scope = $rootScope.$new(); scope = $rootScope.$new();
$timeout = _$timeout_; $timeout = _$timeout_;
element = angular.element(_element); element = angular.element(_element);

View File

@ -6,10 +6,9 @@ function vnVisibleBy(aclService) {
priority: -1, priority: -1,
link: function($scope, $element, $attrs) { link: function($scope, $element, $attrs) {
let acls = $attrs.vnVisibleBy.split(','); let acls = $attrs.vnVisibleBy.split(',');
if (!aclService.aclPermission(acls)) { if (!aclService.hasAny(acls))
$element[0].style.visibility = 'hidden'; $element[0].style.visibility = 'hidden';
} }
}
}; };
} }
vnVisibleBy.$inject = ['aclService']; vnVisibleBy.$inject = ['aclService'];

View File

@ -4,4 +4,5 @@ export * from './module';
export * from './directives'; export * from './directives';
export * from './filters'; export * from './filters';
export * from './lib'; export * from './lib';
export * from './services';
export * from './components'; export * from './components';

View File

@ -1,43 +1,34 @@
import ngModule from '../module'; import ngModule from '../module';
var acl = window.salix ? window.salix.acl : {}; class AclService {
ngModule.constant('aclConstant', acl); constructor($http) {
this.$http = $http;
aclService.$inject = ['aclConstant']; }
function aclService(aclConstant) { reset() {
if (aclConstant.roles) { this.user = null;
this.roles = null;
}
load() {
return this.$http.get('/api/Accounts/acl').then(res => {
this.user = res.data.user;
this.roles = {}; this.roles = {};
Object.keys(aclConstant.roles).forEach(role => {
this.roles[role.toLowerCase()] = aclConstant.roles[role]; for (let role of res.data.roles) {
if (role.role)
this.roles[role.role.name] = true;
}
}); });
} else {
this.roles = undefined;
} }
hasAny(roles) {
if (this.roles) {
for (let role of roles) {
if (this.roles[role])
return true;
}
}
return false;
}
}
AclService.$inject = ['$http'];
this.routeHasPermission = function(route) { ngModule.service('aclService', AclService);
let hasPermission;
if (!this.roles)
hasPermission = false;
else if (!route.acl)
hasPermission = true;
else if (!this.roles || !Object.keys(this.roles).length)
hasPermission = false;
else
hasPermission = this.aclPermission(route.acl);
return hasPermission;
};
this.aclPermission = function(aclCollection) {
let hasPermission = false;
let total = aclCollection.length;
for (let i = 0; i < total; i++) {
let role = aclCollection[i].trim().toLowerCase();
if (this.roles[role]) {
hasPermission = true;
break;
}
}
return hasPermission;
};
}
ngModule.service('aclService', aclService);

View File

@ -0,0 +1,8 @@
export default function getMainRoute(routes) {
for (let route of routes) {
if (!route.abstract)
return route;
}
return null;
}

View File

@ -1,7 +1,6 @@
import './module-loader'; import './module-loader';
import './crud'; import './crud';
import './app'; import './app';
import './interceptor';
import './acl-service'; import './acl-service';
import './storage-services'; import './storage-services';
import './template'; import './template';
@ -13,3 +12,4 @@ import './modified';
import './key-codes'; import './key-codes';
import './http-error'; import './http-error';
import './user-error'; import './user-error';
import './get-main-route';

View File

@ -22,13 +22,13 @@ export function factory($http, $window, $ocLazyLoad, $translatePartialLoader, $t
let deps = splitingRegister.getDependencies(moduleName); let deps = splitingRegister.getDependencies(moduleName);
let depPromises = []; let depPromises = [];
if (deps) if (deps) {
for (let dep of deps) for (let dep of deps)
depPromises.push(this.load(dep, validations)); depPromises.push(this.load(dep, validations));
}
loaded[moduleName] = new Promise((resolve, reject) => { loaded[moduleName] = new Promise((resolve, reject) => {
Promise.all(depPromises) Promise.all(depPromises).then(() => {
.then(() => {
let promises = []; let promises = [];
$translatePartialLoader.addPart(moduleName); $translatePartialLoader.addPart(moduleName);
@ -39,26 +39,24 @@ export function factory($http, $window, $ocLazyLoad, $translatePartialLoader, $t
); );
})); }));
if (validations) if (validations) {
promises.push(new Promise(resolve => { promises.push(new Promise(resolve => {
$http.get(`/${moduleName}/validations`).then( $http.get(`/${moduleName}/validations`).then(
json => this.onValidationsReady(json, resolve), json => this.onValidationsReady(json, resolve),
() => resolve() () => resolve()
); );
})); }));
}
promises.push(new Promise(resolve => { promises.push(new Promise(resolve => {
splitingRegister.modules[moduleName](resolve); splitingRegister.modules[moduleName](resolve);
})); }));
Promise.all(promises) Promise.all(promises).then(() => {
.then(() => {
loaded[moduleName] = true; loaded[moduleName] = true;
resolve($ocLazyLoad.load({name: moduleName})); resolve($ocLazyLoad.load({name: moduleName}));
}) }).catch(reject);
.catch(reject); }).catch(reject);
})
.catch(reject);
}); });
return loaded[moduleName]; return loaded[moduleName];

View File

@ -3,46 +3,54 @@ describe('Service acl', () => {
beforeEach(ngModule('vnCore')); beforeEach(ngModule('vnCore'));
beforeEach(ngModule($provide => { beforeEach(inject((_aclService_, $httpBackend) => {
$provide.value('aclConstant', {}); $httpBackend.when('GET', `/api/Accounts/acl`).respond({
})); roles: [
{role: {name: 'foo'}},
beforeEach(inject(_aclService_ => { {role: {name: 'bar'}},
{role: {name: 'baz'}}
]
});
aclService = _aclService_; aclService = _aclService_;
aclService.load();
$httpBackend.flush();
})); }));
it('should return false as the service doesn\'t have roles', () => { describe('load()', () => {
expect(aclService.routeHasPermission('http://www.verdnatura.es')).toBeFalsy(); it('should load roles from backend', () => {
}); expect(aclService.roles).toEqual({
foo: true,
it('should return true as the service has roles but the route has no acl', () => { bar: true,
aclService.roles = {customer: true}; baz: true
});
expect(aclService.routeHasPermission('http://www.verdnatura.es')).toBeTruthy(); });
}); });
it('should return false as the service roles have no length', () => { describe('hasAny()', () => {
aclService.roles = {}; it('should return true when user has any of the passed roles', () => {
let route = {url: 'http://www.verdnatura.es', acl: []}; let hasAny = aclService.hasAny(['foo', 'nonExistent']);
expect(aclService.routeHasPermission(route)).toBeFalsy(); expect(hasAny).toBeTruthy();
}); });
it('should call the service aclPermission() function and return false as the service has roles and the rote has acl without length', () => { it('should return true when user has all the passed roles', () => {
aclService.roles = {customer: true, employee: true}; let hasAny = aclService.hasAny(['bar', 'baz']);
let route = {url: 'http://www.verdnatura.es', acl: []};
spyOn(aclService, 'aclPermission').and.callThrough(); expect(hasAny).toBeTruthy();
});
expect(aclService.routeHasPermission(route)).toBeFalsy();
expect(aclService.aclPermission).toHaveBeenCalledWith(route.acl); it('should return true when user has not any of the passed roles', () => {
}); let hasAny = aclService.hasAny(['inventedRole', 'nonExistent']);
it('should call the service aclPermission() function to return true as the service has roles matching with the ones in acl', () => { expect(hasAny).toBeFalsy();
aclService.roles = {customer: true, employee: true}; });
let route = {url: 'http://www.verdnatura.es', acl: ['customer']}; });
spyOn(aclService, 'aclPermission').and.callThrough();
describe('reset()', () => {
expect(aclService.routeHasPermission(route)).toBeTruthy(); it('should reset the roles', () => {
expect(aclService.aclPermission).toHaveBeenCalledWith(route.acl); aclService.reset();
expect(aclService.roles).toBeNull();
});
}); });
}); });

105
front/core/services/auth.js Normal file
View File

@ -0,0 +1,105 @@
import ngModule from '../module';
import UserError from 'core/lib/user-error';
/**
* Authentication service.
*
* @property {Boolean} loggedIn Whether the user is currently logged
*/
export default class Auth {
constructor($http, $state, $transitions, $window, vnToken, vnModules, aclService) {
Object.assign(this, {
$http,
$state,
$transitions,
$window,
vnToken,
vnModules,
aclService,
token: null,
loggedIn: false,
dataLoaded: false
});
}
initialize() {
this.loggedIn = this.vnToken.token != null;
let criteria = {
to: state => state.name != 'login'
};
this.$transitions.onStart(criteria, transition => {
if (!this.loggedIn) {
let params = {continue: this.$window.location.hash};
return transition.router.stateService.target('login', params);
}
if (!this.dataLoaded) {
this.resetData();
this.dataLoaded = true;
return this.aclService.load();
}
return true;
});
}
login(user, password, remember) {
if (!user)
throw new UserError('Please insert your user and password');
let params = {
user,
password: password || undefined
};
return this.$http.post('/api/Accounts/login', params).then(
json => this.onLoginOk(json, remember),
json => this.onLoginErr(json)
);
}
onLoginOk(json, remember) {
this.resetData();
this.vnToken.set(json.data.token, remember);
this.loggedIn = true;
let continueHash = this.$state.params.continue;
if (continueHash)
this.$window.location = continueHash;
else
this.$state.go('home');
}
onLoginErr(json) {
let message;
switch (json.status) {
case 401:
message = 'Invalid credentials';
break;
case -1:
message = 'Can\'t contact with server';
break;
default:
message = 'Something went wrong';
}
throw new UserError(message);
}
logout() {
let promise = this.$http.post('/api/Accounts/logout', null, {
headers: {Authorization: this.vnToken.token}
});
this.vnToken.unset();
this.loggedIn = false;
this.resetData();
this.$state.go('login');
return promise;
}
resetData() {
this.aclService.reset();
this.vnModules.reset();
this.dataLoaded = false;
}
}
Auth.$inject = ['$http', '$state', '$transitions', '$window', 'vnToken', 'vnModules', 'aclService'];
ngModule.service('vnAuth', Auth);

View File

@ -0,0 +1,4 @@
import './auth';
import './token';
import './modules';
import './interceptor';

View File

@ -1,16 +1,14 @@
import ngModule from '../module'; import ngModule from '../module';
import HttpError from './http-error'; import HttpError from 'core/lib/http-error';
interceptor.$inject = ['$q', 'vnApp', '$cookies', '$translate']; interceptor.$inject = ['$q', 'vnApp', 'vnToken', '$translate'];
function interceptor($q, vnApp, $cookies, $translate) { function interceptor($q, vnApp, vnToken, $translate) {
return { return {
request: function(config) { request: function(config) {
vnApp.pushLoader(); vnApp.pushLoader();
let token = $cookies.get('vnToken');
if (token)
config.headers.Authorization = token;
if (vnToken.token)
config.headers.Authorization = vnToken.token;
if ($translate.use()) if ($translate.use())
config.headers['Accept-Language'] = $translate.use(); config.headers['Accept-Language'] = $translate.use();

View File

@ -0,0 +1,45 @@
import ngModule from '../module';
import getMainRoute from '../lib/get-main-route';
export default class Modules {
constructor(aclService, $window) {
Object.assign(this, {
aclService,
$window
});
}
reset() {
this.modules = null;
}
get() {
if (this.modules)
return this.modules;
this.modules = [];
for (let mod of this.$window.routes) {
if (!mod || !mod.routes) continue;
let route = getMainRoute(mod.routes);
if (!route || (route.acl && !this.aclService.hasAny(route.acl)))
continue;
let keyBind;
if (mod.keybindings) {
let res = mod.keybindings.find(i => i.state == route.state);
if (res) keyBind = res.key.toUpperCase();
}
this.modules.push({
name: mod.name || mod.module,
icon: mod.icon || null,
route,
keyBind
});
}
return this.modules;
}
}
Modules.$inject = ['aclService', '$window'];
ngModule.service('vnModules', Modules);

View File

@ -0,0 +1,51 @@
import ngModule from '../module';
/**
* Saves and loads the token for the current logged in user.
*
* @property {String} token The current login token or %null
*/
export default class Token {
constructor($cookies) {
this.$cookies = $cookies;
try {
this.token = sessionStorage.getItem('vnToken');
if (!this.token)
this.token = localStorage.getItem('vnToken');
} catch (e) {}
if (!this.token)
this.token = this.$cookies.get('vnToken');
}
set(value, remember) {
this.unset();
try {
if (remember)
localStorage.setItem('vnToken', value);
else
sessionStorage.setItem('vnToken', value);
} catch (e) {
let options = {};
if (location.protocol == 'https:')
options.secure = true;
if (remember) {
let now = new Date().getTime();
options.expires = new Date(now + 7 * 86400000);
}
this.$cookies.put('vnToken', value, options);
}
this.token = value;
}
unset() {
localStorage.removeItem('vnToken');
sessionStorage.removeItem('vnToken');
this.$cookies.remove('vnToken');
this.token = null;
}
}
Token.$inject = ['$cookies'];
ngModule.service('vnToken', Token);

View File

@ -1,11 +1,23 @@
<vn-topbar vn-auto> <vn-topbar ng-if="$ctrl.inApp">
<a ui-sref="home" title="{{'Home' | translate}}"> <a class="logo" ui-sref="home" title="{{'Home' | translate}}">
<img class="logo" src="./logo.svg" alt="Logo"></img> <img src="./logo.svg" alt="Logo"></img>
</a> </a>
<vn-icon
class="show-menu"
icon="menu"
ng-click="$ctrl.showMenu()">
</vn-icon>
<vn-spinner enable="$ctrl.vnApp.loading"></vn-spinner> <vn-spinner enable="$ctrl.vnApp.loading"></vn-spinner>
<vn-main-menu></vn-main-menu> <vn-main-menu></vn-main-menu>
</vn-topbar> </vn-topbar>
<div ui-view class="main-view"> <div ui-view
class="main-view"
ng-class="{'padding': $ctrl.inApp}">
<vn-home></vn-home> <vn-home></vn-home>
</div> </div>
<div
class="background"
ng-class="{shown: $ctrl.menuShown}"
ng-click="$ctrl.hideMenu()">
</div>
<vn-snackbar vn-id="snackbar"></vn-snackbar> <vn-snackbar vn-id="snackbar"></vn-snackbar>

View File

@ -2,15 +2,42 @@ import ngModule from '../../module';
import './style.scss'; import './style.scss';
export default class App { export default class App {
constructor($scope, vnApp) { constructor($element, $scope, vnApp, $state, $transitions) {
this.$element = $element;
this.$ = $scope; this.$ = $scope;
this.vnApp = vnApp; this.vnApp = vnApp;
this.$state = $state;
$transitions.onStart({}, () => {
if (this.menuShown) this.hideMenu();
});
} }
$postLink() { $postLink() {
this.background = this.$element[0].querySelector('.background');
this.vnApp.snackbar = this.$.snackbar; this.vnApp.snackbar = this.$.snackbar;
} }
// TODO: Temporary fix to hide the topbar when login is displayed
get inApp() {
let state = this.$state.current.name;
return state && state != 'login';
} }
App.$inject = ['$scope', 'vnApp']; get leftBlock() {
return this.$element[0].querySelector('.left-block');
}
showMenu() {
let leftBlock = this.leftBlock;
if (!leftBlock) return;
leftBlock.classList.add('shown');
this.menuShown = true;
}
hideMenu() {
this.menuShown = false;
let leftBlock = this.leftBlock;
if (!leftBlock) return;
leftBlock.classList.remove('shown');
}
}
App.$inject = ['$element', '$scope', 'vnApp', '$state', '$transitions'];
ngModule.component('vnApp', { ngModule.component('vnApp', {
template: require('./app.html'), template: require('./app.html'),

View File

@ -1,5 +1,9 @@
@import "background"; @import "background";
$menu-width: 16em;
$topbar-height: 4em;
$mobile-width: 800px;
body { body {
@extend .bg-content; @extend .bg-content;
overflow: auto; overflow: auto;
@ -7,49 +11,104 @@ body {
vn-app { vn-app {
display: block; display: block;
vn-topbar { & > vn-topbar {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
z-index: 5; z-index: 2;
box-shadow: 0 .1em .2em rgba(1, 1, 1, .2); box-shadow: 0 .1em .2em rgba(1, 1, 1, .2);
height: $topbar-height;
padding: .6em;
.logo { & > header {
float: left; & > .logo > img {
height: 1.8em; height: 1.8em;
padding: 1em; display: block;
} }
vn-spinner { & > .show-menu {
float: left; display: none;
padding: 1em .4em; font-size: 2.3em;
} cursor: pointer;
}
.main-view {
padding-top: 4em;
&:hover {
color: $main-01;
}
}
& > vn-spinner {
padding: 0 .4em;
}
& > vn-main-menu {
flex: 1;
}
}
}
& > .main-view {
&.padding {
padding-top: $topbar-height;
}
vn-main-block { vn-main-block {
display: block; display: block;
margin: 0 auto; margin: 0 auto;
padding-left: 16em; padding-left: $menu-width;
.left-block { .left-block {
position: fixed; position: fixed;
z-index: 5; z-index: 5;
top: 4em; top: $topbar-height;
left: 0; left: 0;
bottom: 0; bottom: 0;
width: 16em; width: $menu-width;
min-width: 16em; min-width: $menu-width;
background-color: white; background-color: white;
box-shadow: 0 .1em .2em rgba(1, 1, 1, .2); box-shadow: 0 .1em .2em rgba(1, 1, 1, .2);
overflow: auto; overflow: auto;
} }
.right-block { .right-block {
width: 16em; width: $menu-width;
min-width: 16em; min-width: $menu-width;
padding-left: 1em; padding-left: 1em;
} }
}
}
& > .background {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: black;
z-index: 4;
opacity: 0;
transition: opacity 200ms ease-out;
}
@media screen and (max-width: $mobile-width) {
& > vn-topbar > header {
& > .logo {
display: none;
}
& > .show-menu {
display: block;
}
}
& > .main-view vn-main-block {
padding-left: 0;
.left-block {
top: 0;
transform: translateZ(0) translateX(-$menu-width);
transition: transform 200ms ease-out;
&.shown {
transform: translateZ(0) translateX(0);
}
}
}
& > .background.shown {
display: block;
opacity: .3;
} }
} }
} }

View File

@ -1,31 +1,19 @@
import ngModule from '../../module'; import ngModule from '../../module';
import './style.scss'; import './style.scss';
import keybindings from '../../global-keybindings.yml';
export default class Controller { export default class Controller {
constructor(modulesFactory, $state, $translate, $sce) { constructor(vnModules, $state, $translate, $sce) {
this.modules = modulesFactory.getModules(); this.modules = vnModules.get();
this.$state = $state; this.$state = $state;
this._ = $translate; this._ = $translate;
this.$sce = $sce; this.$sce = $sce;
this.keybindings = keybindings;
}
$onInit() {
this.modules.map(mod => {
let keyBind = this.keybindings.find(keyBind => {
return keyBind.sref == mod.route.state;
});
if (keyBind)
mod.keyBind = keyBind.key.toUpperCase();
});
} }
getModuleName(mod) { getModuleName(mod) {
let getName = mod => { let getName = mod => {
let name = this._.instant(mod.name); let name = this._.instant(mod.name);
let lower = name.toUpperCase(); let upper = name.toUpperCase();
if (!mod.keyBind) return name; if (!mod.keyBind) return name;
let index = lower.indexOf(mod.keyBind); let index = upper.indexOf(mod.keyBind);
if (index === -1) return name; if (index === -1) return name;
let newName = name.substr(0, index); let newName = name.substr(0, index);
@ -38,7 +26,7 @@ export default class Controller {
} }
} }
Controller.$inject = ['modulesFactory', '$state', '$translate', '$sce']; Controller.$inject = ['vnModules', '$state', '$translate', '$sce'];
ngModule.component('vnHome', { ngModule.component('vnHome', {
template: require('./home.html'), template: require('./home.html'),

View File

@ -1,7 +1,8 @@
@import "effects"; @import "effects";
vn-home { vn-home {
padding: 2em; display: block;
padding: .5em;
& > div { & > div {
& > h6 { & > h6 {

View File

@ -1,6 +1,7 @@
import './app/app'; import './app/app';
import './login/login';
import './home/home'; import './home/home';
import './main-menu/main-menu'; import './main-menu/main-menu';
import './left-menu/left-menu'; import './left-menu/left-menu';
import './topbar/topbar'; import './topbar/topbar';
import './user-configuration-popover' import './user-configuration-popover';

View File

@ -0,0 +1,29 @@
<div class="box">
<img src="./logo.svg"/>
<form name="form" ng-submit="$ctrl.submit()">
<vn-textfield
label="User"
model="$ctrl.user"
name="user"
vn-id="userField"
vn-focus>
</vn-textfield>
<vn-textfield
label="Password"
model="$ctrl.password"
name="password"
type="password">
</vn-textfield>
<vn-check
label="Do not close session"
field="$ctrl.remember"
name="remember">
</vn-check>
<div class="footer">
<vn-submit label="Enter"></vn-submit>
<div class="spinner-wrapper">
<vn-spinner enable="$ctrl.loading"></vn-spinner>
</div>
</div>
</form>
</div>

View File

@ -0,0 +1,38 @@
import ngModule from '../../module';
import './style.scss';
/**
* A simple login form.
*/
export default class Controller {
constructor($element, $scope, vnAuth) {
this.$element = $element;
this.$ = $scope;
this.vnAuth = vnAuth;
this.user = localStorage.getItem('lastUser');
}
submit() {
this.loading = true;
this.vnAuth.login(this.user, this.password, this.remember)
.then(() => {
localStorage.setItem('lastUser', this.user);
this.loading = false;
})
.catch(error => {
this.loading = false;
this.password = '';
this.focusUser();
throw error;
});
}
focusUser() {
this.$.userField.select();
this.$.userField.focus();
}
}
Controller.$inject = ['$element', '$scope', 'vnAuth'];
ngModule.component('vnLogin', {
template: require('./login.html'),
controller: Controller
});

View File

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -0,0 +1,61 @@
@import "colors";
vn-login {
position: absolute;
height: 100%;
width: 100%;
margin: 0;
padding: 0;
color: $main-font-color;
font-size: 1.1em;
font-weight: normal;
background-color: #3c393b;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
& > .box {
box-sizing: border-box;
position: absolute;
max-width: 19em;
min-width: 15em;
padding: 3em;
background-color: white;
box-shadow: 0 0 1em 0 rgba(1,1,1,.6);
border-radius: .5em;
& > img {
width: 100%;
padding-bottom: 1em;
}
& > form {
& > vn-textfield {
width: 100%;
margin: 1em 0;
}
& > .footer {
margin-top: 1em;
text-align: center;
position: relative;
& > .spinner-wrapper {
position: absolute;
width: 0;
top: .3em;
right: 3em;
overflow: visible;
}
}
}
}
@media screen and (max-width: 600px) {
background-color: white;
& > .box {
box-shadow: none;
background-color: white;
}
}
}

View File

@ -1,11 +1,11 @@
<div style="position: fixed; top: 0; right: 0; padding: .8em 1.5em; z-index: 10;"> <div>
<div <div
ng-click="$ctrl.openUserConfiguration($event)" ng-click="$ctrl.openUserConfiguration($event)"
id="user" id="user"
class="unselectable"> class="unselectable">
<h6>{{currentUserName}}</h6> {{currentUserName}}
</div> </div>
<a href="/salix/version-notes.html" <a href="version-notes.html"
target="version-notes"> target="version-notes">
<vn-icon <vn-icon
id="version-notes" id="version-notes"
@ -49,16 +49,8 @@
translate-attr="{title: 'Logout'}" translate-attr="{title: 'Logout'}"
ng-click="$ctrl.onLogoutClick()"> ng-click="$ctrl.onLogoutClick()">
</vn-icon> </vn-icon>
<!--
TODO: Keep it commented until they are functional
<vn-icon icon="notifications" translate-attr="{title: 'Notifications'}"></vn-icon>
<vn-icon icon="account_circle" translate-attr="{title: 'Profile'}"></vn-icon>
-->
</div> </div>
<vn-popover vn-id="popover"> <vn-popover vn-id="popover">
<vn-user-configuration-popover> <vn-user-configuration-popover>
</vn-user-configuration-popover> </vn-user-configuration-popover>
</vn-popover> </vn-popover>

View File

@ -12,13 +12,14 @@ let languages = {
}; };
export default class MainMenu { export default class MainMenu {
constructor($translate, $scope, $http, $window, modulesFactory) { constructor($translate, $scope, $http, $window, vnModules, vnAuth) {
this.$ = $scope; this.$ = $scope;
this.$http = $http; this.$http = $http;
this.$translate = $translate; this.$translate = $translate;
this.$window = $window; this.$window = $window;
this.modules = modulesFactory.getModules(); this.modules = vnModules.get();
this.langs = []; this.langs = [];
this.vnAuth = vnAuth;
for (let code of $translate.getAvailableLanguageKeys()) { for (let code of $translate.getAvailableLanguageKeys()) {
this.langs.push({ this.langs.push({
@ -41,7 +42,7 @@ export default class MainMenu {
} }
onLogoutClick() { onLogoutClick() {
this.$window.location = '/logout'; this.vnAuth.logout();
} }
onChangeLangClick(lang) { onChangeLangClick(lang) {
@ -52,7 +53,7 @@ export default class MainMenu {
this.getCurrentUserName(); this.getCurrentUserName();
} }
} }
MainMenu.$inject = ['$translate', '$scope', '$http', '$window', 'modulesFactory']; MainMenu.$inject = ['$translate', '$scope', '$http', '$window', 'vnModules', 'vnAuth'];
ngModule.component('vnMainMenu', { ngModule.component('vnMainMenu', {
template: require('./main-menu.html'), template: require('./main-menu.html'),

View File

@ -7,9 +7,9 @@ describe('Component vnMainMenu', () => {
beforeEach(ngModule('salix')); beforeEach(ngModule('salix'));
beforeEach(angular.mock.inject(($componentController, _$httpBackend_) => { beforeEach(angular.mock.inject(($componentController, _$httpBackend_) => {
let modulesFactory = {getModules: () => {}}; let vnModules = {get: () => {}};
$httpBackend = _$httpBackend_; $httpBackend = _$httpBackend_;
controller = $componentController('vnMainMenu', {modulesFactory}); controller = $componentController('vnMainMenu', {vnModules});
})); }));
describe('getCurrentUserName()', () => { describe('getCurrentUserName()', () => {

View File

@ -1,31 +1,40 @@
@import "effects"; @import "effects";
vn-main-menu { vn-main-menu {
#user { display: flex;
display: inline-block; align-items: center;
padding-right: 0.2em; justify-content: flex-end;
margin-bottom: 8px;
height: 2.5em; & > div {
vertical-align: middle; display: flex;
} align-items: center;
#user h6{ box-sizing: border-box;
color: white;
} & > * {
& > div > vn-icon, & > div > a > vn-icon {
font-size: 2.2em;
cursor: pointer; cursor: pointer;
padding-left: .1em;
&:hover { &:hover {
color: $main-01; color: $main-01;
} }
} }
& > #user {
vertical-align: middle;
font-weight: bold;
padding-right: .6em;
}
& > vn-icon,
& > a > vn-icon {
display: block;
font-size: 2.2em;
}
}
vn-menu.vn-popover > div > div.content > ul { vn-menu.vn-popover > div > div.content > ul {
list-style-type: none; list-style-type: none;
margin: 0; margin: 0;
color: white; color: white;
li { & > li {
@extend %clickable-light; @extend %clickable-light;
background-color: $main-01; background-color: $main-01;
margin-bottom: .6em; margin-bottom: .6em;
@ -33,13 +42,13 @@ vn-main-menu {
border-radius: .1em; border-radius: .1em;
min-width: 8em; min-width: 8em;
&:last-child {
margin-bottom: 0;
}
& > vn-icon { & > vn-icon {
padding-right: .3em; padding-right: .3em;
vertical-align: middle; vertical-align: middle;
} }
&:last-child {
margin-bottom: 0;
}
} }
} }
} }

View File

@ -1,6 +1,16 @@
header { @import "background";
vn-topbar {
display: flex; display: flex;
flex-direction: row; color: white;
flex: 1; box-sizing: border-box;
color: #fff; background-color: $bg-dark-bar;
align-items: center;
& > header {
height: inherit;
width: inherit;
display: flex;
align-items: center;
}
} }

View File

@ -1,2 +1,2 @@
<header class="bg-dark-bar" ng-transclude> <header ng-transclude>
</header> </header>

View File

@ -1,78 +0,0 @@
import ngModule from './module';
import deps from 'modules.yml';
import modules from 'spliting';
import splitingRegister from 'core/lib/spliting-register';
function loader(moduleName, validations) {
load.$inject = ['vnModuleLoader'];
function load(moduleLoader) {
return moduleLoader.load(moduleName, validations);
}
return load;
}
config.$inject = ['$stateProvider', '$urlRouterProvider', 'aclServiceProvider', 'modulesFactoryProvider'];
function config($stateProvider, $urlRouterProvider, aclServiceProvider, modulesFactory) {
splitingRegister.graph = deps;
splitingRegister.modules = modules;
let aclService = aclServiceProvider.$get();
function getParams(route) {
let params = '';
let temporalParams = [];
if (!route.params)
return params;
Object.keys(route.params).forEach(key => {
temporalParams.push(`${key} = "${route.params[key]}"`);
});
return temporalParams.join(' ');
}
$urlRouterProvider.otherwise('/');
$stateProvider.state('home', {
url: '/',
template: '<vn-home></vn-home>',
description: 'Salix'
});
for (let file in window.routes) {
let fileRoutes = window.routes[file].routes;
let moduleName = window.routes[file].module;
let validations = window.routes[file].validations || false;
let mainModule = modulesFactory.$get().getMainRoute(fileRoutes);
if (mainModule) {
let count = fileRoutes.length;
for (let i = 0; i < count; i++) {
let route = fileRoutes[i];
if (aclService.routeHasPermission(route)) {
let configRoute = {
url: route.url,
template: `<${route.component} ${getParams(route)}></${route.component}>`,
description: route.description,
reloadOnSearch: false,
resolve: {
loader: loader(moduleName, validations)
},
data: {
moduleIndex: file
}
};
if (route.abstract)
configRoute.abstract = true;
if (route.routeParams)
configRoute.params = route.routeParams;
$stateProvider.state(route.state, configRoute);
} else if (route.state === mainModule.state)
break;
}
}
}
}
ngModule.config(config);

View File

@ -1,6 +0,0 @@
[
{key: r, sref: claim.index},
{key: c, sref: client.index},
{key: a, sref: item.index},
{key: t, sref: ticket.index},
]

View File

@ -1,5 +1,4 @@
import './module'; import './module';
import './config-routes'; import './routes';
import './components/index'; import './components';
import './styles/index'; import './styles';
import './modules-factory';

View File

@ -1,17 +1,18 @@
import {ng} from 'core/vendor'; import {ng} from 'core/vendor';
import 'core'; import 'core';
import keybindings from './global-keybindings.yml';
export const appName = 'salix'; export const appName = 'salix';
const ngModule = ng.module('salix', ['vnCore']); const ngModule = ng.module('salix', ['vnCore']);
export default ngModule; export default ngModule;
run.$inject = ['$window', '$rootScope', 'vnApp', '$state', '$document']; run.$inject = ['$window', '$rootScope', 'vnAuth', 'vnApp', '$state'];
export function run($window, $rootScope, vnApp, $state, $document) { export function run($window, $rootScope, vnAuth, vnApp, $state) {
$window.validations = {}; $window.validations = {};
vnApp.name = appName; vnApp.name = appName;
vnAuth.initialize();
$rootScope.$on('$viewContentLoaded', () => {}); $rootScope.$on('$viewContentLoaded', () => {});
window.myAppErrorLog = []; window.myAppErrorLog = [];
$state.defaultErrorHandler(function(error) { $state.defaultErrorHandler(function(error) {
@ -19,28 +20,37 @@ export function run($window, $rootScope, vnApp, $state, $document) {
window.myAppErrorLog.push(error); window.myAppErrorLog.push(error);
}); });
for (const binding in keybindings) { if ($window.routes) {
if (!keybindings[binding].key || !keybindings[binding].sref) let keybindings = {};
throw new Error('Binding not formed correctly');
$document.on('keyup', function(e) { for (const mod of $window.routes) {
if (e.defaultPrevented) return; if (!mod || !mod.keybindings)
continue;
let shortcut = { for (const binding of mod.keybindings) {
altKey: true, let err;
ctrlKey: true, if (!binding.key)
key: keybindings[binding].key err = `Missing attribute 'key' in binding`;
}; else if (!binding.state)
err = `Missing attribute 'state' in binding`;
else if (keybindings[binding.key])
err = `Binding key redeclared`;
let correctShortcut = true; if (err)
console.warn(`${err}: ${mod.module}: ${JSON.stringify(binding)}`);
else
keybindings[binding.key] = binding.state;
}
}
for (const key in shortcut) $window.addEventListener('keyup', function(event) {
correctShortcut = correctShortcut && shortcut[key] == e[key]; if (event.defaultPrevented || !event.altKey || !event.ctrlKey)
return;
if (correctShortcut) { let state = keybindings[event.key];
$state.go(keybindings[binding].sref); if (state) {
e.preventDefault(); $state.go(state);
e.stopImmediatePropagation(); event.preventDefault();
} }
}); });
} }
@ -56,8 +66,8 @@ ngModule.config(config);
// Unhandled exceptions // Unhandled exceptions
$exceptionHandler.$inject = ['vnApp', '$window']; $exceptionHandler.$inject = ['vnApp', '$window', '$state'];
function $exceptionHandler(vnApp, $window) { function $exceptionHandler(vnApp, $window, $state) {
return function(exception, cause) { return function(exception, cause) {
let message; let message;
@ -77,11 +87,9 @@ function $exceptionHandler(vnApp, $window) {
else else
message = `${exception.status}: ${exception.statusText}`; message = `${exception.status}: ${exception.statusText}`;
if (exception.status === 401) { if (exception.status === 401 && $state.current.name != 'login') {
let location = $window.location; let params = {continue: $window.location.hash};
let continueUrl = location.pathname + location.search + location.hash; $state.go('login', params);
continueUrl = encodeURIComponent(continueUrl);
$window.location = `/auth/?apiKey=${vnApp.name}&continue=${continueUrl}`;
} }
} else if (exception.name == 'UserError') } else if (exception.name == 'UserError')
message = exception.message; message = exception.message;

View File

@ -1,36 +0,0 @@
import ngModule from './module';
function modulesFactory(aclService) {
function getMainRoute(routeCollection) {
let cant = routeCollection.length;
for (let i = 0; i < cant; i++) {
if (!routeCollection[i].abstract)
return routeCollection[i];
}
return null;
}
function getModules() {
let modules = [];
for (let file in window.routes) {
let card = {
name: routes[file].name || routes[file].module,
icon: routes[file].icon || null
};
let mainRoute = getMainRoute(window.routes[file].routes);
if (mainRoute && aclService.routeHasPermission(mainRoute)) {
card.route = mainRoute;
modules.push(card);
}
}
return modules;
}
return {
getModules: getModules,
getMainRoute: getMainRoute
};
}
modulesFactory.$inject = ['aclService'];
ngModule.factory('modulesFactory', modulesFactory);

78
front/salix/routes.js Normal file
View File

@ -0,0 +1,78 @@
import ngModule from './module';
import deps from 'modules.yml';
import modules from 'spliting';
import splitingRegister from 'core/lib/spliting-register';
import getMainRoute from 'core/lib/get-main-route';
function loader(moduleName, validations) {
load.$inject = ['vnModuleLoader'];
function load(moduleLoader) {
return moduleLoader.load(moduleName, validations);
}
return load;
}
config.$inject = ['$stateProvider', '$urlRouterProvider'];
function config($stateProvider, $urlRouterProvider) {
splitingRegister.graph = deps;
splitingRegister.modules = modules;
$urlRouterProvider.otherwise('/');
$stateProvider.state('home', {
url: '/',
template: '<vn-home></vn-home>',
description: 'Salix'
});
$stateProvider.state('login', {
url: '/login?continue',
template: '<vn-login></vn-login>',
description: 'Login'
});
function getParams(route) {
let params = '';
let temporalParams = [];
if (!route.params)
return params;
Object.keys(route.params).forEach(key => {
temporalParams.push(`${key} = "${route.params[key]}"`);
});
return temporalParams.join(' ');
}
for (let file in window.routes) {
let routeFile = window.routes[file];
let fileRoutes = routeFile.routes;
let moduleName = routeFile.module;
let validations = routeFile.validations || false;
let mainModule = getMainRoute(fileRoutes);
if (!mainModule) continue;
for (let route of fileRoutes) {
let configRoute = {
url: route.url,
template: `<${route.component} ${getParams(route)}></${route.component}>`,
description: route.description,
reloadOnSearch: false,
resolve: {
loader: loader(moduleName, validations)
},
data: {
moduleIndex: file
}
};
if (route.abstract)
configRoute.abstract = true;
if (route.routeParams)
configRoute.params = route.routeParams;
$stateProvider.state(route.state, configRoute);
}
}
}
ngModule.config(config);

View File

@ -1,13 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Salix</title>
</head>
<body ng-app="vnAuth">
<vn-login></vn-login>
<% for (let jsFile of assets) { %>
<script type="text/javascript" src="<%= jsFile %>"></script>
<% } %>
</body>
</html>

View File

@ -3,7 +3,6 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title vn-title translate></title> <title vn-title translate></title>
<script src="/acl"></script>
</head> </head>
<body> <body>
<vn-app></vn-app> <vn-app></vn-app>

View File

@ -1,94 +0,0 @@
let url = require('url');
let md5 = require('md5');
module.exports = function(app) {
let User = app.models.User;
let applications = app.get('applications');
app.get('/auth/', function(req, res) {
res.render('auth.ejs', {
assets: app.getWpAssets('auth')
});
});
app.post('/auth/login', function(req, res) {
let body = req.body;
let user = body.user;
let password = body.password;
let syncOnFail = true;
let usesEmail = user && user.indexOf('@') !== -1;
login();
function login() {
let loginInfo = {password: password};
if (usesEmail)
loginInfo.email = user;
else
loginInfo.username = user;
User.login(loginInfo, 'user', loginCb);
}
function loginCb(err, token) {
if (err) {
if (syncOnFail && !usesEmail) {
syncOnFail = false;
let filter = {where: {name: user}};
app.models.Account.findOne(filter, findCb);
} else
badLogin();
return;
}
let apiKey;
let continueUrl;
try {
let query = url.parse(req.body.location, true).query;
apiKey = query.apiKey;
continueUrl = query.continue;
} catch (e) {
continueUrl = null;
}
if (!apiKey) apiKey = 'default';
let loginUrl = applications[apiKey] || '/login';
res.json({
token: token.id,
continue: continueUrl,
loginUrl: loginUrl
});
}
function findCb(err, instance) {
if (err || !instance || instance.password !== md5(password)) {
badLogin();
return;
}
let where = {id: instance.id};
let userData = {
id: instance.id,
username: user,
password: password,
email: instance.email,
created: instance.created,
updated: instance.updated
};
User.upsertWithWhere(where, userData, login);
}
function badLogin() {
res.status(401);
res.json({
message: 'Login failed'
});
}
});
app.get('/auth/logout', function(req, res) {
User.logout(req.accessToken.id, () => {
res.redirect('/');
});
});
};

View File

@ -1,146 +1,10 @@
module.exports = function(app) { module.exports = function(app) {
let models = app.models;
let bootTimestamp = new Date().getTime(); let bootTimestamp = new Date().getTime();
app.get('/', function(req, res) { app.get('/', function(req, res) {
let token = req.cookies.vnToken;
validateToken(token, function(isValid) {
if (!isValid) {
redirectToAuth(res, req.get('origin'));
return;
}
res.render('index.ejs', { res.render('index.ejs', {
assets: app.getWpAssets('salix'), assets: app.getWpAssets('salix'),
version: bootTimestamp version: bootTimestamp
}); });
}); });
});
app.get('/acl', function(req, res) {
let token = req.cookies.vnToken;
validateToken(token, function(isValid, token) {
if (isValid)
sendUserRole(res, token);
else
sendACL(res, {});
});
});
app.get('/login', function(req, res) {
let token = req.query.token;
let continueUrl = req.query.continue;
validateToken(token, function(isValid) {
if (isValid) {
res.cookie('vnToken', token /* , {httpOnly: true} */);
res.redirect(continueUrl ? continueUrl : '/');
} else
redirectToAuth(res);
});
});
app.get('/logout', function(req, res) {
let token = req.cookies.vnToken;
models.User.logout(token, function() {
redirectToAuth(res);
});
});
app.get('/validateToken', function(req, res) {
let token = req.headers.authorization;
validateToken(token, function(isValid) {
if (isValid)
res.json(null);
else {
res.status(401).json({
message: 'Invalid token'
});
}
});
});
function validateToken(tokenId, cb) {
models.AccessToken.findById(tokenId, function(err, token) {
if (token) {
token.validate(function(err, isValid) {
cb(isValid === true, token);
});
} else
cb(false);
});
}
function sendUserRole(res, token) {
if (token.userId) {
let query = {
where: {
principalId: token.userId,
principalType: 'USER'
},
include: [{
relation: 'role',
scope: {
fields: ['name']
}
}]
}; };
models.RoleMapping.find(query, function(_, roles) {
if (roles) {
let acl = {
userProfile: {},
roles: {}
};
Object.keys(roles).forEach(function(_, i) {
if (roles[i].roleId) {
let rol = roles[i].role();
acl.roles[rol.name] = true;
}
});
models.User.findById(token.userId, function(_, userProfile) {
// acl.userProfile = userProfile;
if (userProfile && userProfile.id) {
acl.userProfile.id = userProfile.id;
acl.userProfile.username = userProfile.username;
acl.userProfile.warehouseId = 1;
sendACL(res, acl);
} else
sendACL(res, {});
});
} else
sendACL(res, {});
});
} else
sendACL(res, {});
}
function redirectToAuth(res, continueUrl) {
let authUrl = app.get('url auth');
let params = {
apiKey: app.get('api key'),
continue: continueUrl
};
res.clearCookie('vnToken');
res.redirect(`${authUrl}/?${encodeUri(params)}`);
}
function sendACL(res, acl) {
let aclStr = JSON.stringify(acl);
res.header('Content-Type', 'application/javascript; charset=UTF-8');
res.send(`(function(window){window.salix = window.salix || {}; window.salix.acl = window.salix.acl || {}; window.salix.acl = ${aclStr}; })(window)`);
}
};
function encodeUri(object) {
let uri = '';
for (let key in object) {
if (object[key]) {
if (uri.length > 0)
uri += '&';
uri += encodeURIComponent(key) + '=';
uri += encodeURIComponent(object[key]);
}
}
return uri;
}

View File

@ -1,103 +0,0 @@
const app = require('../../../server/server');
const routes = require('../auth');
describe('Auth routes', () => {
beforeEach(async () => {
await app.models.User.destroyById(102);
});
afterAll(async () => {
await app.models.User.destroyById(102);
});
let User = app.models.User;
let loginFunction;
let logoutFunction;
let res;
let req;
beforeEach(() => {
spyOn(app, 'post');
spyOn(app, 'get').and.callThrough();
routes(app);
loginFunction = app.post.calls.mostRecent().args[1];
logoutFunction = app.get.calls.argsFor(2)[1];
res = {};
req = {body: {}};
});
describe('when the user doesnt exist but the client does and the password is correct', () => {
it('should create the user login and return the token', done => {
spyOn(User, 'upsertWithWhere').and.callThrough();
req.body.user = 'PetterParker';
req.body.password = 'nightmare';
res.json = response => {
expect(User.upsertWithWhere).toHaveBeenCalledWith(jasmine.any(Object), jasmine.any(Object), jasmine.any(Function));
expect(response.token).toBeDefined();
done();
};
loginFunction(req, res);
});
});
describe('when the user exists and the password is correct', () => {
it('should login and return the token', done => {
req.body.user = 'employee';
req.body.password = 'nightmare';
res.json = response => {
expect(response.token).toBeDefined();
done();
};
loginFunction(req, res);
});
it('should define the url to continue upon login', done => {
req.body.user = 'employee';
req.body.password = 'nightmare';
req.body.location = 'http://localhost:5000/auth/?apiKey=salix&continue="continueURL"';
res.json = response => {
expect(response.continue).toBeDefined();
done();
};
loginFunction(req, res);
});
it('should define the loginUrl upon login', done => {
req.body.user = 'employee';
req.body.password = 'nightmare';
req.body.location = 'http://localhost:5000/auth/?apiKey=salix';
res.json = response => {
expect(response.loginUrl).toBeDefined();
done();
};
loginFunction(req, res);
});
it('should logout after login', done => {
spyOn(User, 'logout').and.callThrough();
req.accessToken = {id: 'testingTokenId'};
logoutFunction(req, res);
res.redirect = url => {
expect(User.logout).toHaveBeenCalledWith('testingTokenId', jasmine.any(Function));
expect(url).toBe('/');
done();
};
});
});
describe('when the user is incorrect', () => {
it('should return a 401 unauthorized', done => {
req.body.user = 'IDontExist';
req.body.password = 'TotallyWrongPassword';
res.status = status => {
expect(status).toBe(401);
};
res.json = response => {
expect(response.message).toBe('Login failed');
done();
};
loginFunction(req, res);
});
});
});

View File

@ -78,5 +78,8 @@
{"state": "claim.card.detail", "icon": "icon-details"}, {"state": "claim.card.detail", "icon": "icon-details"},
{"state": "claim.card.development", "icon": "icon-traceability"}, {"state": "claim.card.development", "icon": "icon-traceability"},
{"state": "claim.card.action", "icon": "icon-actions"} {"state": "claim.card.action", "icon": "icon-actions"}
],
"keybindings": [
{"key": "r", "state": "claim.index"}
] ]
} }

View File

@ -2,12 +2,12 @@ import ngModule from '../../module';
import './style.scss'; import './style.scss';
class Controller { class Controller {
constructor($stateParams, $translate, $scope, $cookies) { constructor($stateParams, $translate, $scope, vnToken) {
this.$ = $scope; this.$ = $scope;
this.$stateParams = $stateParams; this.$stateParams = $stateParams;
this.$translate = $translate; this.$translate = $translate;
this.accessToken = $cookies.get('vnToken'); this.accessToken = vnToken.token;
this.companyFk = window.localStorage.defaultCompanyFk; this.companyFk = window.localStorage.defaultCompanyFk;
this.filter = { this.filter = {
include: { include: {
@ -57,7 +57,7 @@ class Controller {
} }
} }
Controller.$inject = ['$stateParams', '$translate', '$scope', '$cookies']; Controller.$inject = ['$stateParams', '$translate', '$scope', 'vnToken'];
ngModule.component('vnClientRiskIndex', { ngModule.component('vnClientRiskIndex', {
template: require('./index.html'), template: require('./index.html'),

View File

@ -11,7 +11,7 @@ describe('Client', () => {
beforeEach(angular.mock.inject((_$componentController_, $rootScope) => { beforeEach(angular.mock.inject((_$componentController_, $rootScope) => {
$componentController = _$componentController_; $componentController = _$componentController_;
$scope = $rootScope.$new(); $scope = $rootScope.$new();
controller = $componentController('vnClientRiskIndex', {$scope: $scope}); controller = $componentController('vnClientRiskIndex', {$scope});
})); }));
describe('risks() setter', () => { describe('risks() setter', () => {

View File

@ -347,5 +347,8 @@
{"state": "client.card.webPayment", "icon": "icon-onlinepayment"} {"state": "client.card.webPayment", "icon": "icon-onlinepayment"}
] ]
} }
],
"keybindings": [
{"key": "c", "state": "client.index"}
] ]
} }

View File

@ -125,5 +125,8 @@
{"state": "item.card.itemBarcode", "icon": "icon-barcode"}, {"state": "item.card.itemBarcode", "icon": "icon-barcode"},
{"state": "item.card.diary", "icon": "icon-transaction"}, {"state": "item.card.diary", "icon": "icon-transaction"},
{"state": "item.card.last-entries", "icon": "icon-regentry"} {"state": "item.card.last-entries", "icon": "icon-regentry"}
],
"keybindings": [
{"key": "a", "state": "item.index"}
] ]
} }

View File

@ -240,5 +240,8 @@
{"state": "ticket.card.picture", "icon": "image"}, {"state": "ticket.card.picture", "icon": "image"},
{"state": "ticket.card.log", "icon": "history"}, {"state": "ticket.card.log", "icon": "history"},
{"state": "ticket.card.request.index", "icon": "icon-100"} {"state": "ticket.card.request.index", "icon": "icon-100"}
],
"keybindings": [
{"key": "t", "state": "ticket.index"}
] ]
} }

602
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@
"description": "Salix application", "description": "Salix application",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"bcrypt": "^3.0.3",
"compression": "^1.7.3", "compression": "^1.7.3",
"cookie-parser": "^1.4.3", "cookie-parser": "^1.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",

View File

@ -81,11 +81,6 @@ let baseConfig = {
template: 'front/salix/index.ejs', template: 'front/salix/index.ejs',
filename: 'index.html', filename: 'index.html',
chunks: ['salix'] chunks: ['salix']
}),
new HtmlWebpackPlugin({
template: 'front/auth/auth.ejs',
filename: 'auth.html',
chunks: ['auth']
}) })
], ],
devtool: 'source-map', devtool: 'source-map',

View File

@ -2,6 +2,5 @@ buildDir: dist
devServerPort: 3500 devServerPort: 3500
publicPath: '/static' publicPath: '/static'
entry: { entry: {
salix: salix, salix: salix
auth: auth
} }