@@ -10,6 +10,7 @@ | |||
"start": "webpack-dev-server --open" | |||
}, | |||
"dependencies": { | |||
"qrcode": "^1.4.4", | |||
"safe-eval": "^0.4.1", | |||
"vee-validate": "^2.2.8", | |||
"vue": "^2.6.10", | |||
@@ -1,3 +1,8 @@ | |||
let landingPage = null; | |||
export function getLandingPage () { return landingPage; } | |||
export function setLandingPage (page) { landingPage = page; } | |||
export function resetLandingPage () { landingPage = null; } | |||
export function currentUser() { | |||
let userJson = localStorage.getItem('user'); | |||
return userJson ? JSON.parse(userJson) : null; | |||
@@ -23,21 +28,25 @@ export function getWithAuth() { | |||
}; | |||
} | |||
export function postWithAuth(obj) { | |||
return { | |||
method: 'POST', | |||
headers: { ...authHeader(), 'Content-Type': 'application/json' }, | |||
body: JSON.stringify(obj) | |||
}; | |||
function entityWithAuth(method, obj) { | |||
console.log("entityWithAuth("+method+"): obj="+obj+" (type="+(typeof obj)+")"); | |||
if (typeof obj === 'undefined' || obj === null || obj === 'undefined') { | |||
return { | |||
method: method, | |||
headers: { ...authHeader(), 'Content-Type': 'application/json' } | |||
}; | |||
} else { | |||
return { | |||
method: method, | |||
headers: { ...authHeader(), 'Content-Type': 'application/json' }, | |||
body: JSON.stringify(obj) | |||
}; | |||
} | |||
} | |||
export function putWithAuth(obj) { | |||
return { | |||
method: 'PUT', | |||
headers: { ...authHeader(), 'Content-Type': 'application/json' }, | |||
body: JSON.stringify(obj) | |||
}; | |||
} | |||
export function postWithAuth(obj) { return entityWithAuth('POST', obj); } | |||
export function putWithAuth(obj) { return entityWithAuth('PUT', obj); } | |||
export function deleteWithAuth() { | |||
return { | |||
@@ -5,6 +5,7 @@ import HomePage from '../account/HomePage' | |||
import RegisterPage from '../auth/RegisterPage' | |||
import LoginPage from '../auth/LoginPage' | |||
import ProfilePage from '../account/profile/ProfilePage' | |||
import ActionPage from '../account/profile/ActionPage' | |||
import PolicyPage from '../account/profile/PolicyPage' | |||
import NotificationsPage from '../account/NotificationsPage' | |||
import ChangePasswordPage from '../account/profile/ChangePasswordPage' | |||
@@ -15,7 +16,7 @@ import AccountsPage from '../admin/AccountsPage' | |||
import StripePayment from "../account/payment/StripePayment"; | |||
import InviteCodePayment from "../account/payment/InviteCodePayment"; | |||
import UnknownPayment from "../account/payment/UnknownPayment"; | |||
import { currentUser } from '../_helpers' | |||
import { currentUser, setLandingPage } from '../_helpers' | |||
Vue.use(Router); | |||
@@ -43,6 +44,7 @@ export const router = new Router({ | |||
}, | |||
{ path: '/me', component: ProfilePage }, | |||
{ path: '/me/policy', component: PolicyPage }, | |||
{ path: '/me/action', component: ActionPage }, | |||
{ path: '/me/changePassword', component: ChangePasswordPage }, | |||
{ path: '/notifications', component: NotificationsPage }, | |||
{ | |||
@@ -78,7 +80,10 @@ router.beforeEach((to, from, next) => { | |||
if (authRequired) { | |||
// redirect to login page if not logged in and trying to access a restricted page | |||
if (!user) return next('/login'); | |||
if (!user) { | |||
setLandingPage(to); | |||
return next('/login'); | |||
} | |||
// redirect to home page if not admin and trying to access an admin page | |||
if (to.path.startsWith('/admin') && user.admin !== true) return next('/'); | |||
@@ -12,9 +12,20 @@ export const userService = { | |||
addPolicyContactById, | |||
removePolicyContactByUuid, | |||
update, | |||
delete: _delete | |||
delete: _delete, | |||
approveAction, | |||
denyAction | |||
}; | |||
function setSessionUser (user) { | |||
// login successful if there's a session token in the response | |||
if (user.token) { | |||
// store user details and session token in local storage to keep user logged in between page refreshes | |||
localStorage.setItem('user', JSON.stringify(user)); | |||
} | |||
return user; | |||
} | |||
function login(name, password, messages, errors) { | |||
const requestOptions = { | |||
method: 'POST', | |||
@@ -23,14 +34,7 @@ function login(name, password, messages, errors) { | |||
}; | |||
return fetch(`${config.apiUrl}/auth/login`, requestOptions) | |||
.then(handleAuthResponse(messages, errors)) | |||
.then(user => { | |||
// login successful if there's a session token in the response | |||
if (user.token) { | |||
// store user details and session token in local storage to keep user logged in between page refreshes | |||
localStorage.setItem('user', JSON.stringify(user)); | |||
} | |||
return user; | |||
}); | |||
.then(setSessionUser); | |||
} | |||
function logout() { | |||
@@ -46,13 +50,7 @@ function register(user, messages, errors) { | |||
}; | |||
return fetch(`${config.apiUrl}/auth/register`, requestOptions) | |||
.then(handleAuthResponse(messages, errors)) | |||
.then(user => { | |||
if (user.token) { | |||
// store user details and session token in local storage to keep user logged in between page refreshes | |||
localStorage.setItem('user', JSON.stringify(user)); | |||
} | |||
return user; | |||
}); | |||
.then(setSessionUser); | |||
} | |||
function getAll(messages, errors) { | |||
@@ -79,6 +77,16 @@ function removePolicyContactByUuid(id, uuid, messages, errors) { | |||
return fetch(`${config.apiUrl}/users/${id}/policy/contacts/${uuid}`, deleteWithAuth()).then(handleCrudResponse(messages, errors)); | |||
} | |||
function approveAction(id, code, messages, errors) { | |||
return fetch(`${config.apiUrl}/auth/approve/${code}`, postWithAuth()) | |||
.then(handleCrudResponse(messages, errors)) | |||
.then(setSessionUser); | |||
} | |||
function denyAction(id, code, messages, errors) { | |||
return fetch(`${config.apiUrl}/auth/deny/${code}`, postWithAuth()).then(handleCrudResponse(messages, errors)); | |||
} | |||
function update(user, messages, errors) { | |||
return fetch(`${config.apiUrl}/users/${user.uuid}`, postWithAuth(user)).then(handleCrudResponse(messages, errors)); | |||
} | |||
@@ -1,13 +1,13 @@ | |||
import { userService } from '../_services'; | |||
import { router } from '../_helpers'; | |||
import { router, getLandingPage, resetLandingPage } from '../_helpers'; | |||
// todo: why can't we import currentUser from api-util and use that here? | |||
// when I try to do that, webpack succeeds but then an error occurs loading any page, with the | |||
// error message "_helpers.currentUser is not defined" | |||
const user = JSON.parse(localStorage.getItem('user')); | |||
const state = user | |||
? { status: { loggedIn: true }, user } | |||
: { status: {}, user: null }; | |||
? { status: { loggedIn: true }, user, actionStatus: {} } | |||
: { status: {}, user: null, actionStatus: {} }; | |||
const actions = { | |||
login({ dispatch, commit }, { user, messages, errors }) { | |||
@@ -16,7 +16,14 @@ const actions = { | |||
.then( | |||
user => { | |||
commit('loginSuccess', user); | |||
router.push('/'); | |||
const landing = getLandingPage(); | |||
console.log('getLandingPage returned: '+JSON.stringify(landing)); | |||
if (landing === null) { | |||
router.push('/'); | |||
} else { | |||
resetLandingPage(); | |||
router.push(landing.fullPath); | |||
} | |||
}, | |||
error => { | |||
commit('loginFailure', error); | |||
@@ -55,7 +62,7 @@ const actions = { | |||
router.push('/me'); | |||
setTimeout(() => { | |||
// display success message after route change completes | |||
dispatch('alert/success', 'Profile update was successful', { root: true }); | |||
dispatch('alert/success', messages.message_profile_update_success, { root: true }); | |||
}) | |||
}, | |||
error => { | |||
@@ -63,7 +70,25 @@ const actions = { | |||
dispatch('alert/error', error, { root: true }); | |||
} | |||
); | |||
} | |||
}, | |||
approveAction({ commit }, {uuid, code, messages, errors}) { | |||
commit('approveActionRequest'); | |||
userService.approveAction(uuid, code, messages, errors) | |||
.then( | |||
policy => commit('approveActionSuccess', policy), | |||
error => commit('approveActionFailure', error) | |||
); | |||
}, | |||
denyAction({ commit }, {uuid, code, messages, errors}) { | |||
commit('denyActionRequest'); | |||
userService.denyAction(uuid, code, messages, errors) | |||
.then( | |||
policy => commit('denyActionSuccess', policy), | |||
error => commit('denyActionFailure', error) | |||
); | |||
}, | |||
}; | |||
const mutations = { | |||
@@ -79,10 +104,12 @@ const mutations = { | |||
state.status = {}; | |||
state.user = null; | |||
}, | |||
logout(state) { | |||
state.status = {}; | |||
state.user = null; | |||
}, | |||
registerRequest(state, user) { | |||
state.status = { registering: true }; | |||
state.user = user; | |||
@@ -93,7 +120,29 @@ const mutations = { | |||
}, | |||
registerFailure(state) { | |||
state.status = {}; | |||
}, | |||
approveActionRequest(state) { | |||
state.actionStatus = { requesting: true, type: 'approve' }; | |||
}, | |||
approveActionSuccess(state, user) { | |||
state.actionStatus = { success: true, type: 'approve', result: user }; | |||
if (user.token) state.user = user; | |||
}, | |||
approveActionFailure(state, error) { | |||
state.actionStatus = { error: error, type: 'approve' }; | |||
}, | |||
denyActionRequest(state) { | |||
state.actionStatus = { requesting: true, type: 'deny' }; | |||
}, | |||
denyActionSuccess(state, denial) { | |||
state.actionStatus = { success: true, type: 'deny', result: denial }; | |||
state.denial = denial; | |||
}, | |||
denyActionFailure(state, error) { | |||
state.actionStatus = { error: error, type: 'deny' }; | |||
} | |||
}; | |||
export const account = { | |||
@@ -6,7 +6,8 @@ const state = { | |||
user: null, | |||
policy: {}, | |||
policyStatus: {}, | |||
contact: null | |||
contact: null, | |||
authenticator: null | |||
}; | |||
const actions = { | |||
@@ -131,6 +132,9 @@ const mutations = { | |||
}, | |||
addPolicyContactByUuidSuccess(state, contact) { | |||
state.contact = contact; | |||
if (contact.type === 'authenticator') { | |||
state.authenticator = contact.info; | |||
} | |||
}, | |||
addPolicyContactByUuidFailure(state, error) { | |||
state.contact = { error }; | |||
@@ -0,0 +1,65 @@ | |||
<template> | |||
<div> | |||
<h2>{{messages.message_action_processing}} {{messages['message_inbound_'+actionType]}} ...</h2> | |||
</div> | |||
</template> | |||
<script> | |||
import { mapState, mapActions } from 'vuex' | |||
export default { | |||
data () { | |||
return {actionType: null} | |||
}, | |||
computed: { | |||
...mapState('account', { | |||
currentUser: state => state.user | |||
}), | |||
...mapState('account', ['actionStatus']) | |||
}, | |||
methods: { | |||
...mapActions("account", ['approveAction', 'denyAction']), | |||
...mapState('system', ['messages']), | |||
}, | |||
created () { | |||
console.log('ActionPage.created: starting. query='+JSON.stringify(this.$route.query)); | |||
if (this.$route.query.approve) { | |||
this.actionType = 'approve'; | |||
console.log('ActionPage.created: calling approveAction'); | |||
this.approveAction({ | |||
uuid: this.currentUser.uuid, | |||
code: this.$route.query.approve, | |||
messages: this.messages, | |||
errors: this.errors | |||
}); | |||
} else if (this.$route.query.deny) { | |||
this.actionType = 'deny'; | |||
console.log('ActionPage.created: calling denyAction'); | |||
this.denyAction({ | |||
uuid: this.currentUser.uuid, | |||
code: this.$route.query.deny, | |||
messages: this.messages, | |||
errors: this.errors | |||
}); | |||
} else { | |||
this.$router.push({path:'/me/profile', params: {'action': 'invalid'}}); | |||
} | |||
}, | |||
watch: { | |||
actionStatus (status) { | |||
console.log('ActionPage.watch.actionStatus: received: '+JSON.stringify(status)); | |||
if (status.requesting) { | |||
console.log('ActionPage.watch.actionStatus: still requesting, doing nothing'); | |||
} else { | |||
if (status.success) { | |||
console.log('ActionPage.watch.actionStatus: sending to policy page with success'); | |||
this.$router.push({path: '/me/policy', query: {action: this.actionType, ok: 'true'}}); | |||
} else { | |||
console.log('ActionPage.watch.actionStatus: sending to policy page with failure'); | |||
this.$router.push({path: '/me/policy', query: {action: this.actionType}}); | |||
} | |||
} | |||
} | |||
} | |||
}; | |||
</script> |
@@ -1,5 +1,12 @@ | |||
<template> | |||
<div> | |||
<div v-if="inboundAction" :class="`alert ${inboundAction.alertType}`"> | |||
{{messages['message_inbound_'+inboundAction.actionType]}} | |||
{{messages['message_inbound_'+inboundAction.status]}} | |||
<div v-if="errors.has('token')" class="invalid-feedback d-block">{{ errors.first('token') }}</div> | |||
<div v-if="errors.has('request')" class="invalid-feedback d-block">{{ errors.first('request') }}</div> | |||
</div> | |||
<h2>{{messages.form_label_title_account_policy}}</h2> | |||
<form @submit.prevent="updatePolicy"> | |||
<hr/> | |||
@@ -8,7 +15,7 @@ | |||
<select v-model="deletionPolicy" name="deletionPolicy" class="form-control"> | |||
<option v-for="opt in accountDeletionOptions" v-bind:value="opt">{{messages['account_deletion_name_'+opt]}}</option> | |||
</select> | |||
<span>{{messages['account_deletion_description_'+policy.deletionPolicy]}}</span> | |||
<span>{{messages['account_deletion_description_'+deletionPolicy]}}</span> | |||
<div v-if="submitted && errors.has('deletionPolicy')" class="invalid-feedback d-block">{{ errors.first('deletionPolicy') }}</div> | |||
</div> | |||
@@ -103,8 +110,25 @@ | |||
<span class="sr-only">{{messages.message_true}}</span> | |||
</td> | |||
<td v-else> | |||
<i aria-hidden="true" :class="messages.field_label_policy_contact_value_disabled_icon" :title="messages.message_false"></i> | |||
<span class="sr-only">{{messages.message_false}}</span> | |||
<form v-if="verifyingContact === contact.uuid" @submit.prevent="submitVerification(contact.uuid)"> | |||
<div class="form-group"> | |||
<div v-if="contact.type === 'authenticator'"> | |||
<canvas :id="'canvas_'+contact.uuid"></canvas> | |||
<hr/> | |||
<span>{{messages.message_verify_authenticator_backupCodes}}<br/> | |||
<span :id="'backupCodes_'+contact.uuid"></span> | |||
</span> | |||
<hr/> | |||
<span>{{messages.message_verify_authenticator_backupCodes_description}}</span> | |||
</div> | |||
<label htmlFor="verifyCode">{{messages.field_label_policy_contact_verify_code}}</label> | |||
<input :disabled="actionStatus.requesting" :id="'verifyContactCode_'+contact.uuid" v-validate="'required'" name="verifyCode" type="text" size="8"/> | |||
<div v-if="errors.has('token')" class="invalid-feedback d-block">{{ errors.first('token') }}</div> | |||
<button class="btn btn-primary" :disabled="actionStatus.requesting">{{messages.button_label_submit_verify_code}}</button> | |||
<button class="btn btn-primary" :disabled="actionStatus.requesting" @click="cancelVerifyContact()">{{messages.button_label_cancel}}</button> | |||
</div> | |||
</form> | |||
<button v-if="verifyingContact !== contact.uuid" @click="startVerifyContact(contact)" class="btn btn-primary">{{messages.button_label_submit_verify_code}}</button> | |||
</td> | |||
<td v-if="contact.authFactor === 'required'"> | |||
@@ -238,7 +262,7 @@ | |||
<div v-if="newContact.type !== '' && newContact.type !== 'authenticator'" class="form-group"> | |||
<label htmlFor="contactInfo">{{messages['field_label_policy_contact_type_'+newContact.type+'_field']}}</label> | |||
<v-select v-if="newContact.type !== '' && newContact.type === 'sms'" :options="countries" :reduce="c => c.code" label="countryName" v-model="newContactSmsCountry" name="newContactSmsCountry" class="form-control"/> | |||
<input v-model="newContact.info" name="contactInfo" class="form-control"/> | |||
<input v-model="newContact.info" :type="newContact.type === 'sms' ? 'tel' : 'text'" name="contactInfo" class="form-control"/> | |||
<div v-if="contactSubmitted && errors.has('contactInfo')" class="invalid-feedback d-block">{{ errors.first('contactInfo') }}</div> | |||
<div v-if="contactSubmitted && errors.has('email')" class="invalid-feedback d-block">{{ errors.first('email') }}</div> | |||
<div v-if="contactSubmitted && errors.has('phone')" class="invalid-feedback d-block">{{ errors.first('phone') }}</div> | |||
@@ -297,7 +321,8 @@ | |||
</template> | |||
<script> | |||
import { mapState, mapActions } from 'vuex' | |||
import { mapState, mapActions } from 'vuex'; | |||
const QRCode = require('qrcode'); | |||
function initNewContact () { | |||
return { | |||
@@ -330,18 +355,21 @@ | |||
contacts: [], | |||
contactSubmitted: false, | |||
newContactSmsCountry: '', | |||
newContact: initNewContact() | |||
newContact: initNewContact(), | |||
verifyingContact: null, | |||
inboundAction: null | |||
} | |||
}, | |||
computed: { | |||
...mapState('account', { | |||
currentUser: state => state.user | |||
}), | |||
...mapState('account', ['actionStatus']), | |||
...mapState('system', [ | |||
'messages', 'accountDeletionOptions', 'timeDurationOptions', 'timeDurationOptionsReversed', | |||
'contactTypes', 'detectedLocale', 'countries' | |||
]), | |||
...mapState('users', ['policy', 'policyStatus', 'contact']), | |||
...mapState('users', ['policy', 'policyStatus', 'contact', 'authenticator']), | |||
hasAuthenticator() { | |||
for (let i=0; i<this.contacts.length; i++) { | |||
if (this.contacts[i].type === 'authenticator') return true; | |||
@@ -371,7 +399,10 @@ | |||
} | |||
}, | |||
methods: { | |||
...mapActions('users', ['getPolicyByUuid', 'updatePolicyByUuid', 'addPolicyContactByUuid', 'removePolicyContactByUuid']), | |||
...mapActions('account', ['approveAction', 'denyAction']), | |||
...mapActions('users', [ | |||
'getPolicyByUuid', 'updatePolicyByUuid', 'addPolicyContactByUuid', 'removePolicyContactByUuid', | |||
]), | |||
updatePolicy(e) { | |||
this.submitted = true; | |||
this.updatePolicyByUuid({ | |||
@@ -417,11 +448,54 @@ | |||
messages: this.messages, | |||
errors: this.errors | |||
}); | |||
}, | |||
startVerifyContact(contact) { | |||
console.log('startVerifyContact: '+JSON.stringify(contact)); | |||
this.verifyingContact = contact.uuid; | |||
if (contact.type === 'authenticator') { | |||
const canvas = document.getElementById('canvas_'+contact.uuid); | |||
QRCode.toCanvas(canvas, this.authenticator.key, function (error) { | |||
if (error) { | |||
console.error('QR generation error: '+error); | |||
} else { | |||
console.log('QR generation success'); | |||
} | |||
}); | |||
const backupCodes = document.getElementById('backupCodes_'+contact.uuid); | |||
if (backupCodes != null && typeof this.authenticator.backupCodes !== 'undefined' && this.authenticator.backupCodes != null && this.authenticator.backupCodes.length > 0) { | |||
backupCodes.innerText = this.authenticator.backupCodes.join(' '); | |||
} else { | |||
console.log('backupCodes element not found, or no backupCodes defined'); | |||
} | |||
} | |||
return false; // do not follow the click | |||
}, | |||
cancelVerifyContact() { | |||
this.verifyingContact = null; | |||
this.errors.clear(); | |||
return false; // do not follow the click | |||
}, | |||
submitVerification(uuid) { | |||
const codeElementId = 'verifyContactCode_'+uuid; | |||
const codeElement = document.getElementById(codeElementId); | |||
if (codeElement != null) { | |||
const code = codeElement.value; | |||
this.errors.clear(); | |||
this.approveAction({ | |||
uuid: this.currentUser.uuid, | |||
code: code, | |||
messages: this.messages, | |||
errors: this.errors | |||
}); | |||
console.log('submitVerification: would submit: ' + code); | |||
} else { | |||
console.log('submitVerification: DOM element not found: '+codeElementId); | |||
} | |||
} | |||
}, | |||
watch: { | |||
policy (p) { | |||
console.log('watch.policy: received: '+JSON.stringify(p)); | |||
// console.log('watch.policy: received: '+JSON.stringify(p)); | |||
if (typeof p.deletionPolicy !== 'undefined' && p.deletionPolicy !== null) { | |||
this.deletionPolicy = p.deletionPolicy; | |||
} | |||
@@ -443,17 +517,40 @@ | |||
this.newContact = initNewContact(); | |||
}, | |||
contact (c) { | |||
console.log('watch.contact: received: '+JSON.stringify(c)); | |||
// console.log('watch.contact: received: '+JSON.stringify(c)); | |||
if (typeof c.error === 'undefined' || c.error === null) { | |||
// force reload policy, refreshes contacts | |||
this.getPolicyByUuid({uuid: this.currentUser.uuid, messages: this.messages, errors: this.errors}); | |||
} | |||
}, | |||
actionStatus (status) { | |||
console.log('watch.actionStatus: received: '+JSON.stringify(status)); | |||
if (status.success) { | |||
this.getPolicyByUuid({uuid: this.currentUser.uuid, messages: this.messages, errors: this.errors}); | |||
} | |||
} | |||
}, | |||
created () { | |||
this.getPolicyByUuid({uuid: this.currentUser.uuid, messages: this.messages, errors: this.errors}); | |||
console.log('PolicyPage.created: $route.params='+JSON.stringify(this.$route.query)); | |||
if (this.$route.query.action) { | |||
this.inboundAction = { | |||
actionType: this.$route.query.action | |||
}; | |||
if (this.inboundAction.actionType === 'invalid') { | |||
this.inboundAction.status = 'invalid'; | |||
this.inboundAction.alertType = 'alert-danger'; | |||
} else { | |||
if (this.$route.query.ok) { | |||
this.inboundAction.status = 'success'; | |||
this.inboundAction.alertType = 'alert-success'; | |||
} else { | |||
this.inboundAction.status = 'failure'; | |||
this.inboundAction.alertType = 'alert-danger'; | |||
} | |||
} | |||
} | |||
this.newContactSmsCountry = countryFromLocale(this.detectedLocale); | |||
console.log('set this.newContactSmsCountry='+this.newContactSmsCountry); | |||
} | |||
}; | |||
</script> |