@@ -10,10 +10,12 @@ | |||||
"start": "webpack-dev-server --open" | "start": "webpack-dev-server --open" | ||||
}, | }, | ||||
"dependencies": { | "dependencies": { | ||||
"luxon": "^1.21.3", | |||||
"qrcode": "^1.4.4", | "qrcode": "^1.4.4", | ||||
"safe-eval": "^0.4.1", | "safe-eval": "^0.4.1", | ||||
"vee-validate": "^2.2.8", | "vee-validate": "^2.2.8", | ||||
"vue": "^2.6.10", | "vue": "^2.6.10", | ||||
"vue-datetime": "^1.0.0-beta.11", | |||||
"vue-router": "^3.0.6", | "vue-router": "^3.0.6", | ||||
"vue-select": "^3.4.0", | "vue-select": "^3.4.0", | ||||
"vue-sidebar-menu": "^4.4.3", | "vue-sidebar-menu": "^4.4.3", | ||||
@@ -12,6 +12,7 @@ import ActionPage from '../account/profile/ActionPage' | |||||
import PolicyPage from '../account/profile/PolicyPage' | import PolicyPage from '../account/profile/PolicyPage' | ||||
import NotificationsPage from '../account/NotificationsPage' | import NotificationsPage from '../account/NotificationsPage' | ||||
import ChangePasswordPage from '../account/profile/ChangePasswordPage' | import ChangePasswordPage from '../account/profile/ChangePasswordPage' | ||||
import SshKeysPage from '../account/profile/SshKeysPage' | |||||
import NetworksPage from '../account/NetworksPage' | import NetworksPage from '../account/NetworksPage' | ||||
import NewNetworkPage from '../account/NewNetworkPage' | import NewNetworkPage from '../account/NewNetworkPage' | ||||
import NetworkPage from '../account/NetworkPage' | import NetworkPage from '../account/NetworkPage' | ||||
@@ -49,6 +50,7 @@ export const router = new Router({ | |||||
{ path: '/me/policy', component: PolicyPage }, | { path: '/me/policy', component: PolicyPage }, | ||||
{ path: '/me/action', component: ActionPage }, | { path: '/me/action', component: ActionPage }, | ||||
{ path: '/me/changePassword', component: ChangePasswordPage }, | { path: '/me/changePassword', component: ChangePasswordPage }, | ||||
{ path: '/me/keys', component: SshKeysPage }, | |||||
{ path: '/notifications', component: NotificationsPage }, | { path: '/notifications', component: NotificationsPage }, | ||||
{ | { | ||||
path: '/bubbles', component: NetworksPage , | path: '/bubbles', component: NetworksPage , | ||||
@@ -161,5 +161,15 @@ export const util = { | |||||
} | } | ||||
return false; | return false; | ||||
}; | }; | ||||
}, | |||||
userHasLocale: function(user) { | |||||
return !(typeof user === 'undefined' || user === null || !user.hasOwnProperty('locale') || user.locale === null || user.locale === '' || user.locale === 'detect'); | |||||
}, | |||||
jsLocale: function (user, detectedLocale) { | |||||
const loc = util.userHasLocale(user) ? user.locale : detectedLocale; | |||||
return loc === null ? null : loc.replace('_', '-').toLowerCase(); | |||||
} | } | ||||
}; | }; |
@@ -13,6 +13,9 @@ export const userService = { | |||||
addPolicyContactById, | addPolicyContactById, | ||||
removePolicyContactByUuid, | removePolicyContactByUuid, | ||||
setLocale, | setLocale, | ||||
addSshKeyByUserId, | |||||
removeSshKeyByUserId, | |||||
listSshKeysByUserId, | |||||
updateUser, | updateUser, | ||||
deleteUser, | deleteUser, | ||||
approveAction, | approveAction, | ||||
@@ -120,6 +123,18 @@ function setLocale(locale, messages, errors) { | |||||
} | } | ||||
} | } | ||||
function addSshKeyByUserId(userId, sshKey, messages, errors) { | |||||
return fetch(`${config.apiUrl}/users/${userId}/keys`, util.putWithAuth(sshKey)).then(util.handleCrudResponse(messages, errors)); | |||||
} | |||||
function removeSshKeyByUserId(userId, sshKeyId, messages, errors) { | |||||
return fetch(`${config.apiUrl}/users/${userId}/keys/${sshKeyId}`, util.deleteWithAuth()).then(util.handleCrudResponse(messages, errors)); | |||||
} | |||||
function listSshKeysByUserId(userId, messages, errors) { | |||||
return fetch(`${config.apiUrl}/users/${userId}/keys`, util.getWithAuth()).then(util.handleCrudResponse(messages, errors)); | |||||
} | |||||
function updateUser(user, messages, errors) { | function updateUser(user, messages, errors) { | ||||
return fetch(`${config.apiUrl}/users/${user.uuid}`, util.postWithAuth(user)).then(util.handleCrudResponse(messages, errors)); | return fetch(`${config.apiUrl}/users/${user.uuid}`, util.postWithAuth(user)).then(util.handleCrudResponse(messages, errors)); | ||||
} | } | ||||
@@ -46,7 +46,23 @@ function evalInContext(vue, string) { | |||||
String.prototype.parseMessage = function (vue, ctx) { | String.prototype.parseMessage = function (vue, ctx) { | ||||
const context = (typeof ctx !== 'undefined' && ctx !== null) ? Object.assign(vue, ctx) : vue; | const context = (typeof ctx !== 'undefined' && ctx !== null) ? Object.assign(vue, ctx) : vue; | ||||
return this ? ''+this.replace(/{{[\w\._]*?}}/g, match => { | |||||
return this ? ''+this.replace(/{{[\w][\w\._]*?}}/g, match => { | |||||
const expression = match.slice(2, -2); | |||||
return evalInContext(context, expression) | |||||
}) : ''; | |||||
}; | |||||
String.prototype.parseDateMessage = function (millis, messages) { | |||||
const date = new Date(millis); | |||||
const context = { | |||||
YYYY: date.getFullYear(), | |||||
MMM: messages['label_date_month_'+date.getMonth()], | |||||
M: messages['label_date_month_short_'+date.getMonth()], | |||||
EEE: messages['label_date_day_'+date.getDay()], | |||||
E: messages['label_date_day_short_'+date.getDay()], | |||||
d: date.getDate() | |||||
}; | |||||
return this ? ''+this.replace(/{{[\w]+?}}/g, match => { | |||||
const expression = match.slice(2, -2); | const expression = match.slice(2, -2); | ||||
return evalInContext(context, expression) | return evalInContext(context, expression) | ||||
}) : ''; | }) : ''; | ||||
@@ -5,14 +5,17 @@ import { util } from '../_helpers'; | |||||
const state = { | const state = { | ||||
loading: { | loading: { | ||||
users: false, user: false, updating: false, deleting: false, | users: false, user: false, updating: false, deleting: false, | ||||
policy: false, updatingPolicy: false, addPolicyContact: false, removePolicyContact: false | |||||
policy: false, updatingPolicy: false, addPolicyContact: false, removePolicyContact: false, | |||||
listSshKeys: false, addSshKey: false, removeSshKey: false | |||||
}, | }, | ||||
errors: {}, | errors: {}, | ||||
users: null, | users: null, | ||||
user: null, | user: null, | ||||
policy: {}, | policy: {}, | ||||
contact: null, | contact: null, | ||||
authenticator: {} | |||||
authenticator: {}, | |||||
sshKey: null, | |||||
sshKeys: [] | |||||
}; | }; | ||||
export const CONTACT_TYPE_AUTHENTICATOR = 'authenticator'; | export const CONTACT_TYPE_AUTHENTICATOR = 'authenticator'; | ||||
@@ -113,6 +116,33 @@ const actions = { | |||||
); | ); | ||||
}, | }, | ||||
addSshKeyByUserId({ commit }, {userId, sshKey, messages, errors}) { | |||||
commit('addSshKeyByUserIdRequest'); | |||||
userService.addSshKeyByUserId(userId, sshKey, messages, errors) | |||||
.then( | |||||
key => commit('addSshKeyByUserIdSuccess', key), | |||||
error => commit('addSshKeyByUserIdFailure', error) | |||||
); | |||||
}, | |||||
removeSshKeyByUserId({ commit }, {userId, sshKeyId, messages, errors}) { | |||||
commit('removeSshKeyByUserIdRequest'); | |||||
userService.removeSshKeyByUserId(userId, sshKeyId, messages, errors) | |||||
.then( | |||||
ok => commit('removeSshKeyByUserIdSuccess', sshKeyId), | |||||
error => commit('removeSshKeyByUserIdFailure', error) | |||||
); | |||||
}, | |||||
listSshKeysByUserId({ commit }, {userId, messages, errors}) { | |||||
commit('listSshKeysByUserIdRequest'); | |||||
userService.listSshKeysByUserId(userId, messages, errors) | |||||
.then( | |||||
sshKeys => commit('listSshKeysByUserIdSuccess', sshKeys), | |||||
error => commit('listSshKeysByUserIdFailure', error) | |||||
); | |||||
}, | |||||
delete({ commit }, {userId, messages, errors}) { | delete({ commit }, {userId, messages, errors}) { | ||||
commit('deleteRequest', userId); | commit('deleteRequest', userId); | ||||
userService.deleteUser(userId, messages, errors) | userService.deleteUser(userId, messages, errors) | ||||
@@ -217,6 +247,44 @@ const mutations = { | |||||
state.errors.update = error; | state.errors.update = error; | ||||
}, | }, | ||||
addSshKeyByUserIdRequest(state) { | |||||
state.loading.addSshKey = true; | |||||
}, | |||||
addSshKeyByUserIdSuccess(state, sshKey) { | |||||
state.loading.addSshKey = false; | |||||
state.sshKey = sshKey; | |||||
state.sshKeys.push(sshKey); | |||||
}, | |||||
addSshKeyByUserIdFailure(state, error) { | |||||
state.loading.addSshKey = false; | |||||
state.errors.sshKey = error; | |||||
}, | |||||
removeSshKeyByUserIdRequest(state) { | |||||
state.loading.removeSshKey = true; | |||||
}, | |||||
removeSshKeyByUserIdSuccess(state, sshKeyId) { | |||||
state.loading.removeSshKey = false; | |||||
state.sshKey = null; | |||||
state.sshKeys = state.sshKeys.filter(function(k) { return k.uuid !== sshKeyId; }) | |||||
}, | |||||
removeSshKeyByUserIdFailure(state, error) { | |||||
state.loading.removeSshKey = false; | |||||
state.errors.sshKey = error; | |||||
}, | |||||
listSshKeysByUserIdRequest(state) { | |||||
state.loading.listSshKeys = true; | |||||
}, | |||||
listSshKeysByUserIdSuccess(state, sshKeys) { | |||||
state.loading.listSshKeys = false; | |||||
state.sshKeys = sshKeys; | |||||
}, | |||||
listSshKeysByUserIdFailure(state, error) { | |||||
state.loading.listSshKeys = false; | |||||
state.errors.sshKey = error; | |||||
}, | |||||
deleteRequest(state, id) { | deleteRequest(state, id) { | ||||
// todo: use proper delete API | // todo: use proper delete API | ||||
// add 'deleting:true' property to user being deleted | // add 'deleting:true' property to user being deleted | ||||
@@ -254,9 +254,9 @@ | |||||
</tbody> | </tbody> | ||||
</table> | </table> | ||||
<hr/> | |||||
<h4>{{messages.form_title_account_add_contact}}</h4> | <h4>{{messages.form_title_account_add_contact}}</h4> | ||||
<form @submit.prevent="addContact"> | <form @submit.prevent="addContact"> | ||||
<hr/> | |||||
<div class="form-group"> | <div class="form-group"> | ||||
<label htmlFor="contactType">{{messages.field_label_policy_contact_type}}</label> | <label htmlFor="contactType">{{messages.field_label_policy_contact_type}}</label> | ||||
<select v-model="newContact.type" name="contactType" class="form-control"> | <select v-model="newContact.type" name="contactType" class="form-control"> | ||||
@@ -7,6 +7,8 @@ | |||||
<hr/> | <hr/> | ||||
<router-link to="/me/policy">{{messages.link_label_account_policy}}</router-link> | <router-link to="/me/policy">{{messages.link_label_account_policy}}</router-link> | ||||
<hr/> | <hr/> | ||||
<router-link to="/me/keys">{{messages.link_label_account_ssh_keys}}</router-link> | |||||
<hr/> | |||||
<form @submit.prevent="handleSubmit"> | <form @submit.prevent="handleSubmit"> | ||||
<div class="form-group"> | <div class="form-group"> | ||||
@@ -0,0 +1,157 @@ | |||||
<template> | |||||
<div> | |||||
<h4>{{messages.form_title_ssh_keys}}</h4> | |||||
<table border="1"> | |||||
<thead> | |||||
<tr> | |||||
<td>{{messages.field_label_ssh_key_name}}</td> | |||||
<!-- <td>{{messages.field_label_ssh_key_public_key_hash}}</td>--> | |||||
<td>{{messages.field_label_ssh_key_expiration}}</td> | |||||
<td v-if="user.admin">{{messages.field_label_ssh_key_install}}</td> | |||||
<td></td> | |||||
</tr> | |||||
</thead> | |||||
<tbody v-if="sshKeys"> | |||||
<tr v-for="key in sshKeys"> | |||||
<td>{{key.name}}</td> | |||||
<!-- <td><small>{{key.sshPublicKeyHash}}</small></td>--> | |||||
<td nowrap="nowrap"> | |||||
<span v-if="key.expiration">{{messages.date_format_ssh_key_expiration.parseDateMessage(key.expiration, messages)}}</span> | |||||
<span v-else>{{messages.message_ssh_key_no_expiration}}</span> | |||||
</td> | |||||
<td v-if="user.admin"> | |||||
<span v-if="key.installSshKey">{{messages.message_true}}</span> | |||||
<span v-else>{{messages.message_false}}</span> | |||||
</td> | |||||
<td> | |||||
<i @click="removeSshKey(key.uuid)" aria-hidden="true" :class="messages.button_label_remove_ssh_key_icon" :title="messages.button_label_remove_ssh_key"></i> | |||||
<span class="sr-only">{{messages.button_label_remove_ssh_key}}</span> | |||||
</td> | |||||
</tr> | |||||
</tbody> | |||||
</table> | |||||
<hr/> | |||||
<form @submit.prevent="addSshKey"> | |||||
<h4>{{messages.form_title_add_ssh_key}}</h4> | |||||
<div class="form-group"> | |||||
<label htmlFor="name">{{messages.field_label_ssh_key_name}}</label> | |||||
<input v-validate="'required'" v-model="name" name="name" class="form-control"/> | |||||
<div v-if="submitted && errors.has('name')" class="invalid-feedback d-block">{{ errors.first('name') }}</div> | |||||
</div> | |||||
<div class="form-group"> | |||||
<label htmlFor="sshPublicKey">{{messages.field_label_ssh_key_public_key}}</label> | |||||
<textarea v-validate="'required'" v-model="sshPublicKey" name="name" class="form-control" cols="50"></textarea> | |||||
<div v-html="messages.field_description_ssh_key_public_key"></div> | |||||
<div v-if="submitted && errors.has('sshPublicKey')" class="invalid-feedback d-block">{{ errors.first('sshPublicKey') }}</div> | |||||
</div> | |||||
<div class="form-group"> | |||||
<label htmlFor="expiration">{{messages.field_label_ssh_key_expiration}}</label> | |||||
<datetime v-model="expiration" input-id="expiration" input-class="form-control" :min-datetime="minExpiration" :phrases="dateControlPhrases" :zone="timezone" :week-start="parseInt(messages.datecontrol_weekstart)"></datetime> | |||||
<div v-html="messages.field_description_ssh_key_expiration"></div> | |||||
<div v-if="submitted && errors.has('expiration')" class="invalid-feedback d-block">{{ errors.first('expiration') }}</div> | |||||
</div> | |||||
<div class="form-group" v-if="user.admin"> | |||||
<label htmlFor="installSshKey">{{messages.field_label_ssh_key_install}}</label> | |||||
<input v-model="installSshKey" name="installSshKey" class="form-control" type="checkbox"/> | |||||
<div v-if="submitted && errors.has('installSshKey')" class="invalid-feedback d-block">{{ errors.first('installSshKey') }}</div> | |||||
</div> | |||||
<div class="form-group"> | |||||
<button class="btn btn-primary" :disabled="loading() || !newKeyValid">{{messages.button_label_add_ssh_key}}</button> | |||||
<img v-show="loading()" src="data:image/gif;base64,R0lGODlhEAAQAPIAAP///wAAAMLCwkJCQgAAAGJiYoKCgpKSkiH/C05FVFNDQVBFMi4wAwEAAAAh/hpDcmVhdGVkIHdpdGggYWpheGxvYWQuaW5mbwAh+QQJCgAAACwAAAAAEAAQAAADMwi63P4wyklrE2MIOggZnAdOmGYJRbExwroUmcG2LmDEwnHQLVsYOd2mBzkYDAdKa+dIAAAh+QQJCgAAACwAAAAAEAAQAAADNAi63P5OjCEgG4QMu7DmikRxQlFUYDEZIGBMRVsaqHwctXXf7WEYB4Ag1xjihkMZsiUkKhIAIfkECQoAAAAsAAAAABAAEAAAAzYIujIjK8pByJDMlFYvBoVjHA70GU7xSUJhmKtwHPAKzLO9HMaoKwJZ7Rf8AYPDDzKpZBqfvwQAIfkECQoAAAAsAAAAABAAEAAAAzMIumIlK8oyhpHsnFZfhYumCYUhDAQxRIdhHBGqRoKw0R8DYlJd8z0fMDgsGo/IpHI5TAAAIfkECQoAAAAsAAAAABAAEAAAAzIIunInK0rnZBTwGPNMgQwmdsNgXGJUlIWEuR5oWUIpz8pAEAMe6TwfwyYsGo/IpFKSAAAh+QQJCgAAACwAAAAAEAAQAAADMwi6IMKQORfjdOe82p4wGccc4CEuQradylesojEMBgsUc2G7sDX3lQGBMLAJibufbSlKAAAh+QQJCgAAACwAAAAAEAAQAAADMgi63P7wCRHZnFVdmgHu2nFwlWCI3WGc3TSWhUFGxTAUkGCbtgENBMJAEJsxgMLWzpEAACH5BAkKAAAALAAAAAAQABAAAAMyCLrc/jDKSatlQtScKdceCAjDII7HcQ4EMTCpyrCuUBjCYRgHVtqlAiB1YhiCnlsRkAAAOwAAAAAAAAAAAA==" /> | |||||
</div> | |||||
</form> | |||||
</div> | |||||
</template> | |||||
<script> | |||||
import { mapState, mapActions, mapGetters } from 'vuex' | |||||
import { util } from '../../_helpers' | |||||
import { Settings } from 'luxon' | |||||
export default { | |||||
data() { | |||||
return { | |||||
submitted: false, | |||||
user: util.currentUser(), | |||||
name: null, | |||||
expiration: null, | |||||
sshPublicKey: null, | |||||
installSshKey: false, | |||||
minExpiration: (new Date()).toISOString(), | |||||
timezone: null | |||||
}; | |||||
}, | |||||
computed: { | |||||
...mapState('system', ['messages', 'detectedTimezone', 'detectedLocale']), | |||||
...mapState('users', ['sshKeys']), | |||||
newKeyValid () { | |||||
return (this.name !== null && this.name !== '') | |||||
&& (this.sshPublicKey !== null && this.sshPublicKey !== '' && this.sshPublicKey.startsWith('ssh-rsa ')); | |||||
}, | |||||
dateControlPhrases () { | |||||
return {ok: this.messages.message_datecontrol_ok, cancel: this.messages.message_datecontrol_cancel}; | |||||
} | |||||
}, | |||||
methods: { | |||||
...mapActions('system', ['detectTimezone', 'detectLocale']), | |||||
...mapActions('users', ['addSshKeyByUserId', 'removeSshKeyByUserId', 'listSshKeysByUserId']), | |||||
...mapGetters('users', ['loading']), | |||||
addSshKey (e) { | |||||
this.errors.clear(); | |||||
this.submitted = true; | |||||
this.addSshKeyByUserId({ | |||||
userId: this.user.uuid, | |||||
sshKey: { | |||||
name: this.name, | |||||
sshPublicKey: this.sshPublicKey, | |||||
expirationISO8601: this.expiration, | |||||
installSshKey: this.user.admin ? this.installSshKey : null | |||||
}, | |||||
messages: this.messages, | |||||
errors: this.errors | |||||
}); | |||||
}, | |||||
removeSshKey (keyId) { | |||||
this.removeSshKeyByUserId({ | |||||
userId: this.user.uuid, | |||||
sshKeyId: keyId, | |||||
messages: this.messages, | |||||
errors: this.errors | |||||
}); | |||||
}, | |||||
}, | |||||
watch: { | |||||
detectedLocale (locale) { | |||||
if (locale) { | |||||
Settings.defaultLocale = util.jsLocale(this.user, locale); | |||||
} | |||||
}, | |||||
detectedTimezone (tz) { | |||||
if (tz) this.timezone = tz.timeZoneId; | |||||
} | |||||
}, | |||||
created () { | |||||
if (this.detectedTimezone === null || !this.detectedTimezone.hasOwnProperty('timeZoneId')) { | |||||
this.detectTimezone(); | |||||
} else { | |||||
this.timezone = this.detectedTimezone.timeZoneId; | |||||
} | |||||
const user = util.currentUser(); | |||||
if (util.userHasLocale(user)) { | |||||
Settings.defaultLocale = util.jsLocale(this.user, null); | |||||
} else if (this.detectedLocale === null) { | |||||
this.detectLocale(); | |||||
} else { | |||||
Settings.defaultLocale = util.jsLocale(null, this.detectedLocale); | |||||
} | |||||
this.listSshKeysByUserId({userId: user.uuid, messages: this.messages, errors: this.errors}); | |||||
} | |||||
}; | |||||
</script> |
@@ -3,8 +3,9 @@ | |||||
<head> | <head> | ||||
<meta charset="UTF-8"> | <meta charset="UTF-8"> | ||||
<title>Bubble</title> | <title>Bubble</title> | ||||
<link href="//netdna.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css" rel="stylesheet" /> | |||||
<link href="/bootstrap.min.css" rel="stylesheet" /> | |||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.3.1/css/all.css" integrity="sha384-mzrmE5qonljUremFsqc01SB46JvROS7bZs3IO2EmfFsd15uHvIt+Y8vEf7N7fWAU" crossorigin="anonymous"> | <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.3.1/css/all.css" integrity="sha384-mzrmE5qonljUremFsqc01SB46JvROS7bZs3IO2EmfFsd15uHvIt+Y8vEf7N7fWAU" crossorigin="anonymous"> | ||||
<link rel="stylesheet" href="/vue-datetime.css"> | |||||
<style> | <style> | ||||
a { cursor: pointer; } | a { cursor: pointer; } | ||||
@@ -2,6 +2,7 @@ import Vue from 'vue'; | |||||
import VeeValidate from 'vee-validate'; | import VeeValidate from 'vee-validate'; | ||||
import VueSidebarMenu from 'vue-sidebar-menu' | import VueSidebarMenu from 'vue-sidebar-menu' | ||||
import vSelect from 'vue-select' | import vSelect from 'vue-select' | ||||
import { Datetime } from 'vue-datetime'; | |||||
// not sure what the best way is to include these icons | // not sure what the best way is to include these icons | ||||
// import { library } from '@fortawesome/fontawesome-svg-core' | // import { library } from '@fortawesome/fontawesome-svg-core' | ||||
@@ -19,6 +20,7 @@ import App from './app/App'; | |||||
Vue.use(VeeValidate); | Vue.use(VeeValidate); | ||||
Vue.use(VueSidebarMenu); | Vue.use(VueSidebarMenu); | ||||
Vue.component('v-select', vSelect); | Vue.component('v-select', vSelect); | ||||
Vue.component('datetime', Datetime); | |||||
Vue.config.productionTip = false; | Vue.config.productionTip = false; | ||||
// not sure what the best way is to include these icons, we reference them programmatically via string resource/messages | // not sure what the best way is to include these icons, we reference them programmatically via string resource/messages | ||||