You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
423 lines
17 KiB
423 lines
17 KiB
import { Component } from '../../../lib/vues6/vues6.js'
|
|
import { session } from '../../service/Session.service.js'
|
|
import { password_service } from '../../service/Password.service.js'
|
|
import { auth_api } from '../../service/AuthApi.service.js'
|
|
import { location_service } from '../../service/Location.service.js'
|
|
import { message_service } from '../../service/Message.service.js'
|
|
import { utility } from '../../service/Utility.service.js'
|
|
import { profile_service } from '../../service/Profile.service.js'
|
|
|
|
const template = `
|
|
<div class="coreid-profile-container mb-5">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-8 offset-2 col-sm-4 offset-sm-0">
|
|
<img src="/api/v1/profile/me/photo" :alt="t['profile.profile_photo']" ref="photo" class="img-fluid">
|
|
<div class="overlay">
|
|
<button class="btn btn-outline-light" @click="on_profile_change_click">{{ t['common.change'] }}</button>
|
|
</div>
|
|
</div>
|
|
<div class="col-sm-8 offset-sm-0 col-12 text-sm-left text-center pad-top">
|
|
<div class="card-title"><h3>{{ profile_first }} {{ profile_last }}</h3></div>
|
|
<div class="card-subtitle mb-2 text-muted">{{ profile_tagline }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ul class="list-group list-group-flush">
|
|
<li class="list-group-item">
|
|
<h4>{{ t['profile.basic_profile'] }}</h4>
|
|
<div class="row">
|
|
<div class="col-12 col-md-6 form-group">
|
|
<label for="coreid-profile-first-input">{{ t['register.first_name'] }}</label>
|
|
<input
|
|
type="text"
|
|
class="form-control"
|
|
id="coreid-profile-first-input"
|
|
:placeholder="t['profile.placeholder_first']"
|
|
v-model="profile_first"
|
|
@keyup="on_key_up($event)"
|
|
>
|
|
</div>
|
|
<div class="col-12 col-md-6 form-group">
|
|
<label for="coreid-profile-last-input">{{ t['register.last_name'] }}</label>
|
|
<input
|
|
type="text"
|
|
class="form-control"
|
|
id="coreid-profile-last-input"
|
|
:placeholder="t['profile.placeholder_last']"
|
|
v-model="profile_last"
|
|
@keyup="on_key_up($event)"
|
|
>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-12 form-group">
|
|
<label for="coreid-profile-email-input">{{ t['register.email'] }}</label>
|
|
<input
|
|
type="email"
|
|
class="form-control"
|
|
id="coreid-profile-email-input"
|
|
:placeholder="t['profile.placeholder_email'] "
|
|
v-model="profile_email"
|
|
ref="email_input"
|
|
@keyup="on_key_up($event)"
|
|
>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-12 form-group">
|
|
<label for="coreid-profile-tag-input">{{ t['profile.tagline'] }}</label>
|
|
<input
|
|
type="text"
|
|
class="form-control"
|
|
id="coreid-profile-tag-input"
|
|
v-model="profile_tagline"
|
|
@keyup="on_key_up($event)"
|
|
>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
<li class="list-group-item text-right font-italic text-muted">
|
|
{{ form_message }}
|
|
</li>
|
|
<li class="list-group-item" v-if="!user_id || user_id === 'me'">
|
|
<h4>{{ t['password.password'] }}</h4>
|
|
<p class="font-italic" v-if="last_reset">{{ t['profile.pw_last_reset'].replace('LAST_RESET', last_reset) }}</p>
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary btn-sm"
|
|
@click="change_password"
|
|
>{{ t['password.change'] }}</button>
|
|
</li>
|
|
<li class="list-group-item" v-if="!has_mfa && (!user_id || user_id === 'me')">
|
|
<h4>Multi-factor Authentication</h4>
|
|
<p>MFA is a good-practice security measure that requires you to provide a second factor of identification when you sign in from a service or device that makes use of {{ app_name }}.</p>
|
|
<p>Once enabled, {{ app_name }} will prompt you to enter a code when you sign-in with the {{ app_name }} web interface from a new device. It will also require you to append the code to your password when signing in to a service that uses {{ app_name }} as a backend.</p>
|
|
<button class="btn btn-success btn-sm" type="button" @click="enable_mfa">Enable MFA</button>
|
|
</li>
|
|
<li class="list-group-item" v-if="has_mfa && (!user_id || user_id === 'me')">
|
|
<h4>{{ t['mfa.mfa'] }}</h4>
|
|
<p class="font-italic">{{ t['profile.mfa_enabled_on'].replace('MFA_ENABLED', mfa_enable_date) }}</p>
|
|
<button
|
|
class="btn btn-danger btn-sm"
|
|
type="button"
|
|
@click="disable_mfa"
|
|
>{{ t['mfa.disable'] }}</button>
|
|
|
|
<h6 class="pad-top">{{ t['profile.app_pws'] }}</h6>
|
|
<p>{{ t['profile.app_pw_1'].replace('APP_NAME', app_name) }}</p>
|
|
<p>{{ t['profile.app_pw_2'] }}</p>
|
|
<p>{{ t['profile.app_pw_3'] }}</p>
|
|
|
|
<p class="font-italic text-muted" v-if="app_passwords.length > 0">{{ t['profile.app_pw_remaining.'+(app_passwords.length === 1 ? 'one' : 'many')].replace('NUM_PWS', app_passwords.length) }}</p>
|
|
<ul class="list-group mb-4" v-if="app_passwords.length > 0">
|
|
<li class="list-group-item" v-for="pw of app_passwords">
|
|
<div class="row">
|
|
<div class="col-9">
|
|
{{ pw.name }}
|
|
<br><span class="text-muted font-italic">{{ t['profile.issued'] }} {{ pw.created }}</span>
|
|
</div>
|
|
<div class="col-3 my-auto">
|
|
<button
|
|
class="btn btn-sm btn-danger"
|
|
type="button"
|
|
@click="deactivate_app_password($event, pw)"
|
|
>{{ t['common.deactivate'] }}</button>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
|
|
<button class="btn btn-sm btn-primary" @click="on_click_generate_app_password">{{ t['profile.gen_new'] }}</button>
|
|
|
|
<h6 class="pad-top">{{ t['profile.recovery_codes'] }}</h6>
|
|
<p>{{ t['profile.recovery_1'] }}</p>
|
|
<span v-if="!has_mfa_recovery">
|
|
<p class="font-italic">{{ t['profile.no_recovery'] }}</p>
|
|
<button class="btn btn-sm btn-success" @click="on_mfa_recovery_generate">{{ t['profile.generate_recovery'] }}</button>
|
|
</span>
|
|
<span v-if="has_mfa_recovery">
|
|
<p class="font-italic">{{ t['profile.recovery_gen_on'].replace('MFA_RECOVERY', mfa_recovery_date) }} {{ t['profile.codes_remaining.'+(mfa_recovery_codes === 1 ? 'one' : 'many')].replace('NUM_CODES', mfa_recovery_codes) }}</p>
|
|
<button class="btn btn-sm btn-success" @click="on_mfa_recovery_generate">{{ t['profile.regenerate_recovery'] }}</button>
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<coreid-form-app-password
|
|
v-if="!user_id || user_id === 'me'"
|
|
ref="app_password_modal"
|
|
@modal-success="load_app_passwords"
|
|
></coreid-form-app-password>
|
|
<coreid-profile-photo-uploader
|
|
ref="profile_photo_uploader"
|
|
:user-id="user_id"
|
|
@upload="on_profile_photo_upload"
|
|
></coreid-profile-photo-uploader>
|
|
</div>
|
|
`
|
|
|
|
export default class EditProfileComponent extends Component {
|
|
static get selector() { return 'coreid-profile-edit' }
|
|
static get template() { return template }
|
|
static get props() { return ['user_id'] }
|
|
|
|
profile_first = ''
|
|
profile_last = ''
|
|
profile_email = ''
|
|
profile_tagline = ''
|
|
last_reset = ''
|
|
mfa_enable_date = ''
|
|
|
|
has_mfa_recovery = false
|
|
mfa_recovery_date = ''
|
|
mfa_recovery_codes = 0
|
|
|
|
form_message = 'No changes.'
|
|
|
|
has_mfa = false
|
|
ready = false
|
|
|
|
app_passwords = []
|
|
app_name = ''
|
|
t = {}
|
|
|
|
on_key_up = ($event) => {}
|
|
|
|
async vue_on_create() {
|
|
this.t = await T(
|
|
'profile.profile_photo',
|
|
'common.change',
|
|
'profile.basic_profile',
|
|
'register.first_name',
|
|
'profile.placeholder_first',
|
|
'register.last_name',
|
|
'profile.placeholder_last',
|
|
'register.email',
|
|
'profile.placeholder_email',
|
|
'profile.tagline',
|
|
'password.password',
|
|
'password.change',
|
|
'profile.pw_last_reset',
|
|
'mfa.mfa',
|
|
'profile.mfa_enabled_on',
|
|
'mfa.disable',
|
|
'profile.app_pws',
|
|
'profile.issued',
|
|
'common.deactivate',
|
|
'profile.app_pw_remaining.one',
|
|
'profile.app_pw_remaining.many',
|
|
'profile.gen_new',
|
|
'profile.recovery_codes',
|
|
'profile.recovery_1',
|
|
'profile.no_recovery',
|
|
'profile.generate_recovery',
|
|
'profile.recovery_gen_on',
|
|
'profile.codes_remaining.one',
|
|
'profile.codes_remaining.many',
|
|
'profile.regenerate_recovery',
|
|
'profile.app_pw_1',
|
|
'profile.app_pw_2',
|
|
'profile.app_pw_3'
|
|
)
|
|
|
|
this.app_name = session.get('app.name')
|
|
this.load().then(() => {
|
|
this.ready = true
|
|
})
|
|
|
|
const save = utility.debounce(this.save_form.bind(this))
|
|
this.on_key_up = () => {
|
|
this.form_message = 'Saving...'
|
|
save()
|
|
}
|
|
}
|
|
|
|
get_submit_data() {
|
|
return {
|
|
first_name: this.profile_first,
|
|
last_name: this.profile_last,
|
|
email: this.profile_email,
|
|
tagline: this.profile_tagline,
|
|
user_id: this.user_id || 'me',
|
|
}
|
|
}
|
|
|
|
on_profile_change_click() {
|
|
this.$refs.profile_photo_uploader.show()
|
|
}
|
|
|
|
async on_profile_photo_upload() {
|
|
this.$refs.profile_photo_uploader.close()
|
|
let src = this.$refs.photo.src
|
|
if ( src.indexOf('?') > 0 ) src = src.split('?')[0]
|
|
this.$refs.photo.src = `${src}?i=${(new Date).getTime()}`
|
|
}
|
|
|
|
valid_email() {
|
|
return this.$refs.email_input.validity.valid
|
|
}
|
|
|
|
async save_form($event) {
|
|
const submit_data = this.get_submit_data()
|
|
try {
|
|
if ( !this.valid_email() ) {
|
|
this.form_message = 'Invalid e-mail address.'
|
|
} else {
|
|
await profile_service.update_profile(submit_data)
|
|
this.form_message = 'All changes saved.'
|
|
}
|
|
} catch(e) {
|
|
this.form_message = 'Unknown error occurred while saving.'
|
|
}
|
|
}
|
|
|
|
populate_from_session() {
|
|
this.profile_first = session.get('user.first_name')
|
|
this.profile_last = session.get('user.last_name')
|
|
this.profile_email = session.get('user.email')
|
|
this.profile_tagline = session.get('user.tagline')
|
|
}
|
|
|
|
async load() {
|
|
const result = await profile_service.get_profile(this.user_id || 'me')
|
|
if ( !result ) throw new Error('Unable to load profile!')
|
|
|
|
this.profile_first = result.first_name
|
|
this.profile_last = result.last_name
|
|
this.profile_email = result.email
|
|
this.profile_tagline = result.tagline
|
|
|
|
if ( !this.user_id || this.user_id === 'me' ) {
|
|
const reset = (await password_service.get_resets()).reverse()[0]
|
|
if (reset && reset.reset_on) {
|
|
this.last_reset = (new Date(reset.reset_on)).toLocaleDateString()
|
|
}
|
|
|
|
const mfa = await auth_api.has_mfa()
|
|
this.has_mfa = mfa && mfa.mfa_enabled
|
|
if (this.has_mfa) {
|
|
this.mfa_enable_date = (new Date(mfa.mfa_enable_date)).toLocaleDateString()
|
|
|
|
const result = await auth_api.has_mfa_recovery()
|
|
if ( result && result.has_recovery ) {
|
|
this.has_mfa_recovery = true
|
|
this.mfa_recovery_date = (new Date(result.generated)).toLocaleDateString()
|
|
this.mfa_recovery_codes = result.remaining_codes
|
|
}
|
|
}
|
|
|
|
await this.load_app_passwords()
|
|
}
|
|
}
|
|
|
|
async load_app_passwords() {
|
|
let app_pws = await auth_api.app_passwords()
|
|
if ( !Array.isArray(app_pws) ) app_pws = []
|
|
this.app_passwords = app_pws.map(x => {
|
|
if ( x.expires ) x.expires = (new Date(x.expires)).toLocaleDateString()
|
|
if ( x.created ) x.created = (new Date(x.created)).toLocaleDateString()
|
|
return x
|
|
})
|
|
}
|
|
|
|
disable_mfa() {
|
|
location_service.redirect('/auth/mfa/disable')
|
|
}
|
|
|
|
enable_mfa() {
|
|
location_service.redirect('/auth/mfa/setup')
|
|
}
|
|
|
|
change_password() {
|
|
location_service.redirect('/password/reset')
|
|
}
|
|
|
|
on_click_generate_app_password() {
|
|
this.$refs.app_password_modal.trigger()
|
|
}
|
|
|
|
async deactivate_app_password($event, pw) {
|
|
message_service.modal({
|
|
title: 'Deactivate app password?',
|
|
message: `You are about to deactivate the app password for ${pw.name}. If you do this, ${pw.name} will no longer be able to sign-in on your behalf. Continue?`,
|
|
buttons: [
|
|
{
|
|
text: 'Cancel',
|
|
type: 'close',
|
|
},
|
|
{
|
|
text: 'Deactivate',
|
|
type: 'close',
|
|
class: ['btn', 'btn-danger'],
|
|
on_click: async () => {
|
|
await auth_api.delete_app_password(pw.uuid)
|
|
await this.load_app_passwords()
|
|
},
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
async on_mfa_recovery_generate($event) {
|
|
if ( !this.has_mfa ) return
|
|
if ( !this.has_mfa_recovery ) {
|
|
await this.generate_mfa_recovery()
|
|
} else {
|
|
message_service.modal({
|
|
title: 'Are you sure?',
|
|
message: 'There are already MFA recovery codes associated with your account. If you re-generate them, you will be unable to use the old ones. Continue?',
|
|
buttons: [
|
|
{
|
|
text: 'Cancel',
|
|
type: 'close',
|
|
},
|
|
{
|
|
text: 'Re-generate',
|
|
type: 'close',
|
|
class: ['btn', 'btn-warning'],
|
|
on_click: async () => {
|
|
await this.generate_mfa_recovery()
|
|
},
|
|
},
|
|
],
|
|
})
|
|
}
|
|
}
|
|
|
|
async generate_mfa_recovery() {
|
|
const codes = await auth_api.generate_mfa_recovery()
|
|
if ( codes ) {
|
|
this.display_mfa_recovery_modal(codes)
|
|
} else {
|
|
message_service.alert({
|
|
type: 'error',
|
|
message: 'An unknown error occurred while attempting to generate MFA recovery codes.'
|
|
})
|
|
}
|
|
|
|
await this.load()
|
|
}
|
|
|
|
display_mfa_recovery_modal(codes) {
|
|
const code_display = codes.map(x => `<li><code>${x}</code></li>`).join('\n')
|
|
message_service.modal({
|
|
title: 'MFA Recovery Codes',
|
|
message: `We've generated recovery codes for your account. You can use these to recover access to your account in the event that you lose your MFA device.
|
|
<br><br>
|
|
Be sure to put these somewhere safe. After you close this modal, they will disappear:
|
|
<br><br>
|
|
<ul>
|
|
${code_display}
|
|
</ul>`,
|
|
buttons: [
|
|
{
|
|
text: 'Close',
|
|
type: 'close',
|
|
},
|
|
],
|
|
})
|
|
}
|
|
}
|
|
|