From dc994e82954ac11e62c02d8aa43c88e83ddc9452 Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Mon, 18 Jun 2018 13:29:20 +1000 Subject: [PATCH] Added feature to Log in as another user and log back out. Only available for Admins. --- src/backend/internal/token.js | 28 ++++- src/backend/internal/user.js | 17 ++- src/backend/lib/access/users-loginas.json | 7 ++ src/backend/routes/api/users.js | 26 +++++ src/frontend/js/app/api.js | 44 +++++--- src/frontend/js/app/controller.js | 5 +- src/frontend/js/app/main.js | 10 +- src/frontend/js/app/tokens.js | 129 ++++++++++++++++++++++ src/frontend/js/app/ui/header/main.ejs | 7 +- src/frontend/js/app/ui/header/main.js | 13 +++ src/frontend/js/app/user/form.js | 12 +- src/frontend/js/app/users/list-item.ejs | 1 + src/frontend/js/app/users/list-item.js | 26 ++++- src/frontend/scss/theme/navbar.scss | 6 + 14 files changed, 297 insertions(+), 34 deletions(-) create mode 100644 src/backend/lib/access/users-loginas.json create mode 100644 src/frontend/js/app/tokens.js diff --git a/src/backend/internal/token.js b/src/backend/internal/token.js index 3b58b83..6d1b8ca 100644 --- a/src/backend/internal/token.js +++ b/src/backend/internal/token.js @@ -65,7 +65,7 @@ module.exports = { }, { expiresIn: expiry.unix() }) - .then((signed) => { + .then(signed => { return { token: signed.token, expires: expiry.toISOString() @@ -136,5 +136,31 @@ module.exports = { } else { throw new error.AssertionFailedError('Existing token contained invalid user data'); } + }, + + /** + * @param {Object} user + * @returns {Promise} + */ + getTokenFromUser: user => { + let Token = new TokenModel(); + let expiry = helpers.parseDatePeriod('1d'); + + return Token.create({ + iss: 'api', + attrs: { + id: user.id + }, + scope: ['user'] + }, { + expiresIn: expiry.unix() + }) + .then(signed => { + return { + token: signed.token, + expires: expiry.toISOString(), + user: user + }; + }); } }; diff --git a/src/backend/internal/user.js b/src/backend/internal/user.js index 6077585..ee1c0bc 100644 --- a/src/backend/internal/user.js +++ b/src/backend/internal/user.js @@ -156,7 +156,7 @@ const internalUser = { return query; }) - .then((row) => { + .then(row => { if (row) { return _.omit(row, omissions()); } else { @@ -429,6 +429,21 @@ const internalUser = { .then(() => { return internalUser.get(access, {id: data.id, expand: ['services']}); }); + }, + + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + */ + loginAs: (access, data) => { + return access.can('users:loginas', data.id) + .then(() => { + return internalUser.get(access, data); + }) + .then(user => { + return internalToken.getTokenFromUser(user); + }); } }; diff --git a/src/backend/lib/access/users-loginas.json b/src/backend/lib/access/users-loginas.json new file mode 100644 index 0000000..4e82a67 --- /dev/null +++ b/src/backend/lib/access/users-loginas.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/src/backend/routes/api/users.js b/src/backend/routes/api/users.js index 945aa3b..4bb6774 100644 --- a/src/backend/routes/api/users.js +++ b/src/backend/routes/api/users.js @@ -228,4 +228,30 @@ router .catch(next); }); +/** + * Specific user login as + * + * /api/users/123/login + */ +router + .route('/:user_id/login') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) // preferred so it doesn't apply to nonexistent routes + + /** + * POST /api/users/123/login + * + * Log in as a user + */ + .post((req, res, next) => { + internalUser.loginAs(res.locals.access, {id: parseInt(req.params.user_id, 10)}) + .then(result => { + res.status(201) + .send(result); + }) + .catch(next); + }); + module.exports = router; diff --git a/src/frontend/js/app/api.js b/src/frontend/js/app/api.js index 620a3fb..b8b9424 100644 --- a/src/frontend/js/app/api.js +++ b/src/frontend/js/app/api.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import _ from 'underscore'; +import Tokens from './tokens'; /** * @param {String} message @@ -39,7 +40,7 @@ function fetch (verb, path, data, options) { return new Promise(function (resolve, reject) { let api_url = '/api/'; let url = api_url + path; - let token = window.localStorage.getItem('juxtapose-token') || null; + let token = Tokens.getTopToken(); $.ajax({ url: url, @@ -54,7 +55,7 @@ function fetch (verb, path, data, options) { }, beforeSend: function (xhr) { - xhr.setRequestHeader('Authorization', 'Bearer ' + token); + xhr.setRequestHeader('Authorization', 'Bearer ' + (token ? token.t : null)); }, success: function (data, textStatus, response) { @@ -115,16 +116,15 @@ module.exports = { */ login: function (identity, secret) { return fetch('post', 'tokens', {identity: identity, secret: secret}) - .then(function (response) { + .then(response => { if (response.token) { // Set storage token - window.localStorage.setItem('juxtapose-token', response.token); + Tokens.addToken(response.token); return response.token; } else { - window.localStorage.removeItem('juxtapose-token'); + Tokens.clearTokens(); + throw(new Error('No token returned')); } - - throw(new Error('No token returned')); }); }, @@ -133,15 +133,14 @@ module.exports = { */ refresh: function () { return fetch('get', 'tokens') - .then(function (response) { + .then(response => { if (response.token) { - window.localStorage.setItem('juxtapose-token', response.token); + Tokens.setCurrentToken(response.token); return response.token; } else { - window.localStorage.removeItem('juxtapose-token'); + Tokens.clearTokens(); + throw(new Error('No token returned')); } - - throw(new Error('No token returned')); }); } }, @@ -215,6 +214,14 @@ module.exports = { */ saveServiceSettings: function (id, settings) { return fetch('post', 'users/' + id + '/services', {settings: settings}); + }, + + /** + * @param {Integer} id + * @returns {Promise} + */ + loginAs: function (id) { + return fetch('post', 'users/' + id + '/login'); } }, @@ -345,17 +352,22 @@ module.exports = { * * @param {Integer} from_user_id * @param {Integer} to_user_id - * @param {String} [service_type] + * @param {String} [in_service_type] + * @param {String} [out_service_type] * @returns {Promise} */ - copy: function (from_user_id, to_user_id, service_type) { + copy: function (from_user_id, to_user_id, in_service_type, out_service_type) { let data = { from: from_user_id, to: to_user_id }; - if (service_type) { - data.service_type = service_type; + if (in_service_type) { + data.in_service_type = in_service_type; + } + + if (out_service_type) { + data.out_service_type = out_service_type; } return fetch('post', 'rules/copy', data); diff --git a/src/frontend/js/app/controller.js b/src/frontend/js/app/controller.js index 12d0c3e..f1a9fff 100644 --- a/src/frontend/js/app/controller.js +++ b/src/frontend/js/app/controller.js @@ -2,7 +2,8 @@ import Backbone from 'backbone'; -const Cache = require('./cache'); +const Cache = require('./cache'); +const Tokens = require('./tokens'); module.exports = { @@ -477,7 +478,7 @@ module.exports = { * Logout */ logout: function () { - window.localStorage.removeItem('juxtapose-token'); + Tokens.dropTopToken(); this.navigate('/'); window.location = '/'; window.location.reload(); diff --git a/src/frontend/js/app/main.js b/src/frontend/js/app/main.js index 2b01266..355e077 100644 --- a/src/frontend/js/app/main.js +++ b/src/frontend/js/app/main.js @@ -10,6 +10,7 @@ const Controller = require('./controller'); const Router = require('./router'); const UI = require('./ui/main'); const Api = require('./api'); +const Tokens = require('./tokens'); const App = Mn.Application.extend({ @@ -27,11 +28,7 @@ const App = Mn.Application.extend({ // Check if token is coming through if (this.getParam('token')) { - window.localStorage.setItem('juxtapose-token', this.getParam('token')); - - if (this.getParam('expires')) { - window.localStorage.setItem('juxtapose-expires', this.getParam('expires')); - } + Tokens.addToken(this.getParam('token')) } // Check if we are still logged in by refreshing the token @@ -126,8 +123,9 @@ const App = Mn.Application.extend({ */ bootstrap: function () { return Api.Users.getById('me', ['services']) - .then((response) => { + .then(response => { Cache.User.set(response); + Tokens.setCurrentName(response.nickname || response.name); }); }, diff --git a/src/frontend/js/app/tokens.js b/src/frontend/js/app/tokens.js new file mode 100644 index 0000000..12b68c9 --- /dev/null +++ b/src/frontend/js/app/tokens.js @@ -0,0 +1,129 @@ +'use strict'; + +const STORAGE_NAME = 'juxtapose-tokens'; + +/** + * @returns {Array} + */ +const getStorageTokens = function () { + let json = window.localStorage.getItem(STORAGE_NAME); + if (json) { + try { + return JSON.parse(json); + } catch (err) { + return []; + } + } + + return []; +}; + +/** + * @param {Array} tokens + */ +const setStorageTokens = function (tokens) { + window.localStorage.setItem(STORAGE_NAME, JSON.stringify(tokens)); +}; + +const Tokens = { + + /** + * @returns {Integer} + */ + getTokenCount: () => { + return getStorageTokens().length; + }, + + /** + * @returns {Object} t,n + */ + getTopToken: () => { + let tokens = getStorageTokens(); + if (tokens && tokens.length) { + return tokens[0]; + } + + return null; + }, + + /** + * @returns {String} + */ + getNextTokenName: () => { + let tokens = getStorageTokens(); + if (tokens && tokens.length > 1 && typeof tokens[1] !== 'undefined' && typeof tokens[1].n !== 'undefined') { + return tokens[1].n; + } + + return null; + }, + + /** + * + * @param {String} token + * @param {String} [name] + * @returns {Integer} + */ + addToken: (token, name) => { + // Get top token and if it's the same, ignore this call + let top = Tokens.getTopToken(); + if (!top || top.t !== token) { + let tokens = getStorageTokens(); + tokens.unshift({t: token, n: name || null}); + setStorageTokens(tokens); + } + + return Tokens.getTokenCount(); + }, + + /** + * @param {String} token + * @returns {Boolean} + */ + setCurrentToken: token => { + let tokens = getStorageTokens(); + if (tokens.length) { + tokens[0].t = token; + setStorageTokens(tokens); + return true; + } + + return false; + }, + + /** + * @param {String} name + * @returns {Boolean} + */ + setCurrentName: name => { + let tokens = getStorageTokens(); + if (tokens.length) { + tokens[0].n = name; + console.log('SET CURRENT NAME:', name); + setStorageTokens(tokens); + return true; + } + + return false; + }, + + /** + * @returns {Integer} + */ + dropTopToken: () => { + let tokens = getStorageTokens(); + tokens.shift(); + setStorageTokens(tokens); + return tokens.length; + }, + + /** + * + */ + clearTokens: () => { + window.localStorage.removeItem(STORAGE_NAME); + } + +}; + +module.exports = Tokens; diff --git a/src/frontend/js/app/ui/header/main.ejs b/src/frontend/js/app/ui/header/main.ejs index c11b066..4668edc 100644 --- a/src/frontend/js/app/ui/header/main.ejs +++ b/src/frontend/js/app/ui/header/main.ejs @@ -23,6 +23,11 @@

Juxtapose

diff --git a/src/frontend/js/app/ui/header/main.js b/src/frontend/js/app/ui/header/main.js index fb31bfa..394c99e 100644 --- a/src/frontend/js/app/ui/header/main.js +++ b/src/frontend/js/app/ui/header/main.js @@ -6,6 +6,7 @@ const template = require('./main.ejs'); const Controller = require('../../controller'); const Cache = require('../../cache'); const Api = require('../../api'); +const Tokens = require('../../tokens'); module.exports = Mn.View.extend({ template: template, @@ -64,6 +65,18 @@ module.exports = Mn.View.extend({ getName: function () { return Cache.User.get('nickname') || Cache.User.get('name'); + }, + + hasOtherLogin: function () { + return Tokens.getTokenCount() > 1; + }, + + getLogoutText: function () { + if (Tokens.getTokenCount() > 1) { + return 'Log back in as ' + Tokens.getNextTokenName(); + } + + return 'Logout'; } }; }, diff --git a/src/frontend/js/app/user/form.js b/src/frontend/js/app/user/form.js index 9bcf102..83c0163 100644 --- a/src/frontend/js/app/user/form.js +++ b/src/frontend/js/app/user/form.js @@ -32,7 +32,7 @@ module.exports = Mn.View.extend({ App.UI.closeModal(); Controller.showUsers(); }) - .catch((err) => { + .catch(err => { alert(err.message); this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); }); @@ -73,17 +73,19 @@ module.exports = Mn.View.extend({ } method(data) - .then((result) => { + .then(result => { if (result.id === Cache.User.get('id')) { Cache.User.set(result); } view.model.set(result); - App.UI.closeModal(); - //Controller.showUsers(); + + if (view.model.get('id') !== Cache.User.get('id')) { + Controller.showUsers(); + } }) - .catch((err) => { + .catch(err => { alert(err.message); this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); }); diff --git a/src/frontend/js/app/users/list-item.ejs b/src/frontend/js/app/users/list-item.ejs index c6af624..5275ce6 100644 --- a/src/frontend/js/app/users/list-item.ejs +++ b/src/frontend/js/app/users/list-item.ejs @@ -8,4 +8,5 @@ + diff --git a/src/frontend/js/app/users/list-item.js b/src/frontend/js/app/users/list-item.js index 9c7e695..ebf5909 100644 --- a/src/frontend/js/app/users/list-item.js +++ b/src/frontend/js/app/users/list-item.js @@ -5,6 +5,8 @@ import Mn from 'backbone.marionette'; const template = require('./list-item.ejs'); const Controller = require('../controller'); const Api = require('../api'); +const Cache = require('../cache'); +const Tokens = require('../tokens'); module.exports = Mn.View.extend({ template: template, @@ -15,7 +17,8 @@ module.exports = Mn.View.extend({ edit: '.btn-edit', password: '.btn-password', service_settings: '.btn-service-settings', - copy_rules: '.btn-copy-rules' + copy_rules: '.btn-copy-rules', + login: '.btn-login' }, events: { @@ -71,6 +74,21 @@ module.exports = Mn.View.extend({ this.ui.copy_rules.prop('disabled', false).removeClass('btn-disabled'); }); }, + + 'click @ui.login': function (e) { + e.preventDefault(); + this.ui.login.prop('disabled', true).addClass('btn-disabled'); + Api.Users.loginAs(this.model.get('id')) + .then(res => { + Tokens.addToken(res.token, res.user.nickname || res.user.name); + window.location = '/'; + window.location.reload(); + }) + .catch(err => { + alert(err.message); + this.ui.login.prop('disabled', false).removeClass('btn-disabled'); + }); + } }, templateContext: function () { @@ -79,11 +97,15 @@ module.exports = Mn.View.extend({ return { getAvatar: function () { return view.model.get('avatar') || '//d105my0i9v4ibf.cloudfront.net/c/live/2.11.277-83f1b21/img/default-avatar.jpg'; + }, + + isSelf: function () { + return Cache.User.get('id') === view.model.get('id'); } }; }, initialize: function () { - this.listenTo(this.model, 'change', this.render) + this.listenTo(this.model, 'change', this.render); } }); diff --git a/src/frontend/scss/theme/navbar.scss b/src/frontend/scss/theme/navbar.scss index c3f9c3e..973d02e 100644 --- a/src/frontend/scss/theme/navbar.scss +++ b/src/frontend/scss/theme/navbar.scss @@ -171,3 +171,9 @@ left: 200px; top: 0; } + +.navbar-nav .logout-link-item a { + font-size: 20px; + padding-left: 14px !important; + padding-right: 14px !important; +} \ No newline at end of file