parent
e3ecfb0d37
commit
c389e151b5
@ -0,0 +1,62 @@
|
||||
import { Component } from '../../lib/vues6/vues6.js'
|
||||
import { session } from '../service/Session.service.js'
|
||||
import { location_service } from '../service/Location.service.js'
|
||||
import { auth_api } from '../service/AuthApi.service.js'
|
||||
|
||||
const template = `
|
||||
<div class="coreid-auth-page col-lg-6 col-md-8 col-sm-10 col-xs-12 offset-lg-3 offset-md-2 offset-sm-1 offset-xs-0 text-left">
|
||||
<div class="coreid-auth-page-inner">
|
||||
<div class="coreid-header font-weight-light">{{ app_name }}</div>
|
||||
<span v-if="step === 0">
|
||||
<div class="coreid-message">
|
||||
This process will disable multi-factor authentication on your account.
|
||||
<br><br>
|
||||
For security reasons, this will sign you out of all devices. It will also deactivate any existing app passwords you have generated.
|
||||
<br><br>
|
||||
Are you sure you want to continue?
|
||||
</div>
|
||||
<div v-if="error_message" class="error-message">{{ error_message }}</div>
|
||||
<div v-if="other_message" class="other-message">{{ other_message }}</div>
|
||||
<div class="buttons text-right pad-top">
|
||||
<button type="button" class="btn btn-primary" @click="back_click">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @click="continue_click">Disable MFA</button>
|
||||
</div>
|
||||
</span>
|
||||
<div class="coreid-loading-spinner" v-if="loading"><div class="inner"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
export default class MFADisableComponent extends Component {
|
||||
static get selector() { return 'coreid-mfa-disable-page' }
|
||||
static get template() { return template }
|
||||
static get props() { return [] }
|
||||
|
||||
app_name = ''
|
||||
step = 0
|
||||
loading = false
|
||||
error_message = ''
|
||||
other_message = ''
|
||||
|
||||
vue_on_create() {
|
||||
this.app_name = session.get('app.name')
|
||||
console.log({session})
|
||||
}
|
||||
|
||||
async back_click() {
|
||||
this.loading = true
|
||||
await location_service.redirect('/dash/profile', 500)
|
||||
}
|
||||
|
||||
async continue_click() {
|
||||
this.loading = true
|
||||
const success = await auth_api.mfa_disable()
|
||||
if ( success ) {
|
||||
this.other_message = 'MFA was successfully disabled. You\'ll now sign-in normally.'
|
||||
await location_service.redirect('/dash/profile', 3000)
|
||||
} else {
|
||||
this.error_message = 'An unknown error occurred while trying to disable MFA. Let\'s try again...'
|
||||
await location_service.reload(4000)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,168 @@
|
||||
import { Component } from '../../lib/vues6/vues6.js'
|
||||
import { session } from '../service/Session.service.js'
|
||||
import { location_service } from '../service/Location.service.js'
|
||||
import { password_service } from '../service/Password.service.js'
|
||||
|
||||
const template = `
|
||||
<div class="coreid-auth-page col-lg-6 col-md-8 col-sm-10 col-xs-12 offset-lg-3 offset-md-2 offset-sm-1 offset-xs-0 text-left">
|
||||
<div class="coreid-auth-page-inner">
|
||||
<div class="coreid-header font-weight-light">{{ app_name }}</div>
|
||||
<span v-if="step === 0">
|
||||
<div class="coreid-message">
|
||||
We're going to walk you through resetting your {{ app_name }} password.
|
||||
<span v-if="has_mfa">
|
||||
<br><br>
|
||||
Note that this process will invalidate any existing app passwords you have created.
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<span v-if="step === 1">
|
||||
<div class="form-group">
|
||||
<label for="coreid-password-reset-input-step-1">Please enter a new password for your account:</label>
|
||||
<input
|
||||
id="coreid-password-reset-input-step-1"
|
||||
type="password"
|
||||
v-model="password"
|
||||
placeholder="New password"
|
||||
class="form-control"
|
||||
@keyup="on_key_up"
|
||||
:disabled="loading"
|
||||
name="password"
|
||||
ref="input_1"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<div class="other-message" v-if="step_1_calc_time">This password would take {{ step_1_calc_time }} to crack.</div>
|
||||
<div class="error-message" v-if="step_1_problem">{{ step_1_problem }}.</div>
|
||||
</div>
|
||||
</span>
|
||||
<span v-if="step === 2">
|
||||
<div class="form-group">
|
||||
<label for="coreid-password-reset-input-step-1">Confirm the password:</label>
|
||||
<input
|
||||
id="coreid-password-reset-input-step-2"
|
||||
type="password"
|
||||
v-model="confirm_password"
|
||||
placeholder="Confirm new password"
|
||||
class="form-control"
|
||||
@keyup="on_key_up"
|
||||
:disabled="loading"
|
||||
name="password_confirmation"
|
||||
ref="input_2"
|
||||
>
|
||||
</div>
|
||||
</span>
|
||||
<div v-if="error_message" class="error-message">{{ error_message }}</div>
|
||||
<div v-if="other_message" class="other-message">{{ other_message }}</div>
|
||||
<div class="buttons text-right pad-top">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="back_click"
|
||||
:disabled="loading"
|
||||
>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="continue_click"
|
||||
:disabled="loading || (step === 1 && !step_1_valid) || (step === 2 && !step_2_valid)"
|
||||
>{{ step === 2 ? 'Change Password' : 'Continue' }}</button>
|
||||
</div>
|
||||
<div class="coreid-loading-spinner" v-if="loading"><div class="inner"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
export default class PasswordResetComponent extends Component {
|
||||
static get selector() { return 'coreid-password-reset-page' }
|
||||
static get template() { return template }
|
||||
static get props() { return ['app_name'] }
|
||||
|
||||
step = 0
|
||||
loading = false
|
||||
has_mfa = false
|
||||
|
||||
error_message = ''
|
||||
other_message = ''
|
||||
|
||||
step_1_valid = false
|
||||
step_1_calc_time = ''
|
||||
step_1_problem = ''
|
||||
|
||||
step_2_valid = false
|
||||
|
||||
password = ''
|
||||
confirm_password = ''
|
||||
|
||||
vue_on_create() {
|
||||
this.has_mfa = !!session.get('user.has_mfa')
|
||||
}
|
||||
|
||||
async back_click() {
|
||||
this.loading = true
|
||||
this.error_message = this.other_message = ''
|
||||
if ( this.step === 0 ) {
|
||||
await location_service.redirect('/dash/profile', 1500)
|
||||
} else {
|
||||
this.step -= 1
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async continue_click() {
|
||||
this.loading = true
|
||||
if ( this.step === 0 ) {
|
||||
this.step += 1
|
||||
this.error_message = this.other_message = ''
|
||||
this.loading = false
|
||||
this.$nextTick(() => {
|
||||
this.$refs.input_1.focus()
|
||||
})
|
||||
} else if ( this.step === 1 ) {
|
||||
if ( this.step_1_valid ) {
|
||||
this.step += 1
|
||||
this.error_message = this.other_message = ''
|
||||
this.$nextTick(() => {
|
||||
this.$refs.input_2.focus()
|
||||
})
|
||||
}
|
||||
this.loading = false
|
||||
} else if ( this.step === 2 ) {
|
||||
if ( this.step_2_valid ) {
|
||||
try {
|
||||
await password_service.reset(this.password)
|
||||
this.other_message = 'Your password was reset. For security reasons, you will be asked to sign-in again.'
|
||||
await location_service.redirect('/dash/profile', 5000)
|
||||
} catch (e) {
|
||||
let message = 'An unknown error occurred while attempting to reset your password.'
|
||||
if ( e.response && e.response.data && e.response.data.message ) {
|
||||
message = e.response.data.message
|
||||
}
|
||||
|
||||
this.error_message = message
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
on_key_up(event) {
|
||||
if ( this.step === 1 ) {
|
||||
const result = zxcvbn(this.password)
|
||||
this.step_1_calc_time = result.crack_times_display.offline_slow_hashing_1e4_per_second
|
||||
this.step_1_problem = result.feedback.warning
|
||||
this.step_1_valid = result.score >= 3 // TODO make this configurable
|
||||
} else if ( this.step === 2 ) {
|
||||
this.step_2_valid = this.password === this.confirm_password
|
||||
}
|
||||
|
||||
if ( event.keyCode === 13 ) {
|
||||
// Enter was pressed
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return this.continue_click()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
import { Component } from '../../lib/vues6/vues6.js'
|
||||
|
||||
const template = `
|
||||
<div>
|
||||
<h2 class="mb-4" v-if="definition.title">{{ definition.title }}</h2>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col" v-for="col of definition.columns">{{ col.name }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) of definition.data">
|
||||
<th scope="row">{{ index + 1 }}</th>
|
||||
<td v-for="col of definition.columns">
|
||||
<span v-if="col.renderer === 'boolean'">{{ col.field ? 'Yes' : 'No' }}</span>
|
||||
<span v-if="col.renderer !== 'boolean'">{{ col.field in row ? row[col.field] : '-' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
|
||||
export default class ListingComponent extends Component {
|
||||
static get selector() { return 'cobalt-listing' }
|
||||
static get template() { return template }
|
||||
static get props() { return ['definition'] }
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
import SideBarComponent from './dash/SideBar.component.js'
|
||||
import NavBarComponent from './dash/NavBar.component.js'
|
||||
import MessageContainerComponent from './dash/message/MessageContainer.component.js'
|
||||
import EditProfileComponent from './dash/profile/EditProfile.component.js'
|
||||
import AppPasswordFormComponent from './dash/profile/form/AppPassword.component.js'
|
||||
|
||||
import ListingComponent from './cobalt/Listing.component.js'
|
||||
|
||||
const dash_components = {
|
||||
SideBarComponent,
|
||||
NavBarComponent,
|
||||
MessageContainerComponent,
|
||||
EditProfileComponent,
|
||||
AppPasswordFormComponent,
|
||||
|
||||
ListingComponent,
|
||||
}
|
||||
|
||||
export { dash_components }
|
@ -0,0 +1,65 @@
|
||||
import { Component } from '../../lib/vues6/vues6.js'
|
||||
import { event_bus } from '../service/EventBus.service.js'
|
||||
import { session } from '../service/Session.service.js'
|
||||
import { message_service } from '../service/Message.service.js'
|
||||
|
||||
const template = `
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
|
||||
<button id="menu-toggle" class="btn" @click="toggle_sidebar">
|
||||
<i class="fa fa-ellipsis-v"></i>
|
||||
</button>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
data-target='#navbarSupportedContent'
|
||||
aria-controls='navbarSupportedContent'
|
||||
aria-expanded='false'
|
||||
aria-label='Toggle navigation'
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div id="navbarSupportedContent" class="collapse navbar-collapse">
|
||||
<ul class="navbar-nav ml-auto mt-2 mt-lg-0">
|
||||
<li class="nav-item dropdown">
|
||||
<a
|
||||
href="#"
|
||||
role='button'
|
||||
data-toggle='dropdown'
|
||||
aria-haspopup='true'
|
||||
aria-expanded='false'
|
||||
id="navbarDropdown"
|
||||
class="nav-link dropdown-toggle"
|
||||
>{{ first_name }} {{ last_name }}</a>
|
||||
<div
|
||||
class="dropdown-menu dropdown-menu-right"
|
||||
aria-labelledby="navbarDropdown"
|
||||
>
|
||||
<h6 class="dropdown-header">Hello, {{ first_name }}.</h6>
|
||||
<a href="/dash/profile" class="dropdown-item">My Profile</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a href="/auth/logout" class="dropdown-item">Sign-Out of {{ app_name }}</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
`
|
||||
|
||||
export default class NavBarComponent extends Component {
|
||||
static get selector() { return 'coreid-navbar' }
|
||||
static get template() { return template }
|
||||
static get props() { return [] }
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.toggle_event = event_bus.event('sidebar.toggle')
|
||||
this.first_name = session.get('user.first_name')
|
||||
this.last_name = session.get('user.last_name')
|
||||
this.app_name = session.get('app.name')
|
||||
}
|
||||
|
||||
toggle_sidebar() {
|
||||
this.toggle_event.fire()
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
import { Component } from '../../lib/vues6/vues6.js'
|
||||
import { event_bus } from '../service/EventBus.service.js'
|
||||
import { action_service } from '../service/Action.service.js'
|
||||
|
||||
const template = `
|
||||
<div class="bg-light border-right coreid-sidebar-wrapper" id="sidebar-wrapper" v-bind:class="{ collapsed: isCollapsed }">
|
||||
<div class="sidebar-heading">{{ app_name }}</div>
|
||||
<div class="list-group list-group-flush">
|
||||
<a
|
||||
href="#"
|
||||
@click="perform(action)"
|
||||
class="list-group-item list-group-item-action bg-light"
|
||||
v-for="action in actions"
|
||||
>{{ action.text }}</a>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// TODO figure out why this doesn't show up in mobile layouts
|
||||
|
||||
export default class SideBarComponent extends Component {
|
||||
static get selector() { return 'coreid-sidebar' }
|
||||
static get props() { return ['app_name'] }
|
||||
static get template() { return template }
|
||||
|
||||
actions = [
|
||||
{
|
||||
text: 'Profile',
|
||||
action: 'redirect',
|
||||
next: '/dash/profile',
|
||||
},
|
||||
{
|
||||
text: 'Users',
|
||||
action: 'redirect',
|
||||
next: '/dash/users',
|
||||
},
|
||||
{
|
||||
text: 'Groups',
|
||||
action: 'redirect',
|
||||
next: '/dash/groups',
|
||||
},
|
||||
{
|
||||
text: 'LDAP Clients',
|
||||
action: 'redirect',
|
||||
next: '/dash/ldap/clients',
|
||||
},
|
||||
{
|
||||
text: 'SAML Service Providers',
|
||||
action: 'redirect',
|
||||
next: '/dash/saml/service-providers',
|
||||
},
|
||||
{
|
||||
text: 'Settings',
|
||||
action: 'redirect',
|
||||
next: '/dash/settings',
|
||||
},
|
||||
]
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
event_bus.event('sidebar.toggle').subscribe(() => {
|
||||
this.toggle()
|
||||
})
|
||||
}
|
||||
|
||||
isCollapsed = false
|
||||
|
||||
toggle() {
|
||||
this.isCollapsed = !this.isCollapsed
|
||||
}
|
||||
|
||||
perform(action) {
|
||||
return action_service.perform({delay: 0, ...action})
|
||||
}
|
||||
}
|
@ -0,0 +1,118 @@
|
||||
import { Component } from '../../../lib/vues6/vues6.js'
|
||||
import { event_bus } from '../../service/EventBus.service.js'
|
||||
import { message_service } from '../../service/Message.service.js'
|
||||
|
||||
const template = `
|
||||
<span class="message-container">
|
||||
<span v-for="message of messages">
|
||||
<div class="alert alert-dismissible fade show" role="alert" v-bind:class="[message.type]">
|
||||
{{ message.message }}
|
||||
<button
|
||||
class="close"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
@click="dismiss_alert($event, message)"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<span v-for="modal of modals">
|
||||
<div
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-hidden="true"
|
||||
ref="modal"
|
||||
>
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ modal.title }}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ modal.message }}
|
||||
</div>
|
||||
<div class="modal-footer" v-if="modal.buttons && modal.buttons.length > 0">
|
||||
<button
|
||||
type="button"
|
||||
:class="button.class || ['btn', 'btn-secondary']"
|
||||
v-for="button of modal.buttons"
|
||||
:data-dismiss="button.type === 'close' ? 'modal' : ''"
|
||||
@click="modal_button_click($event, modal, button)"
|
||||
>{{ button.text }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
`
|
||||
|
||||
export default class MessageContainerComponent extends Component {
|
||||
static get selector() { return 'coreid-message-container' }
|
||||
static get template() { return template }
|
||||
static get props() { return [] }
|
||||
|
||||
messages = []
|
||||
modals = []
|
||||
|
||||
vue_on_create() {
|
||||
this.alert_event = event_bus.event('message.alert')
|
||||
this.alert_event.subscribe(({ message, type = 'info', timeout = 0, on_dismiss = () => {} }) => {
|
||||
this.create_alert(message, type, timeout, on_dismiss)
|
||||
})
|
||||
|
||||
this.modal_event = event_bus.event('message.modal')
|
||||
this.modal_event.subscribe(({ title, message, buttons = [] }) => {
|
||||
this.create_modal(title, message, buttons)
|
||||
})
|
||||
|
||||
message_service.init_listener()
|
||||
}
|
||||
|
||||
dismiss_alert($event, message) {
|
||||
this.messages = this.messages.filter(x => x !== message)
|
||||
message.on_dismiss($event)
|
||||
}
|
||||
|
||||
create_alert(message, type, timeout, on_dismiss = () => {}) {
|
||||
const msg = {
|
||||
message,
|
||||
type: type.startsWith('alert-') ? type : `alert-${type}`,
|
||||
on_dismiss,
|
||||
}
|
||||
|
||||
this.messages.push(msg)
|
||||
|
||||
if ( timeout > 0 ) {
|
||||
setTimeout(() => {
|
||||
this.dismiss_alert(msg)
|
||||
}, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
create_modal(title, message, buttons = []) {
|
||||
const index = this.modals.length
|
||||
const modal = {
|
||||
title,
|
||||
message,
|
||||
buttons
|
||||
}
|
||||
|
||||
this.modals.push(modal)
|
||||
this.$nextTick(() => {
|
||||
$(this.$refs.modal[index]).modal()
|
||||
})
|
||||
}
|
||||
|
||||
modal_button_click($event, modal, button) {
|
||||
if ( typeof button.on_click === 'function' ) {
|
||||
button.on_click($event)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,288 @@
|
||||
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="/assets/profile.jpg" alt="Profile Image" class="img-fluid">
|
||||
</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>Basic Profile</h4>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6 form-group">
|
||||
<label for="coreid-profile-first-input">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="coreid-profile-first-input"
|
||||
placeholder="John"
|
||||
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">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="coreid-profile-last-input"
|
||||
placeholder="Doe"
|
||||
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">E-Mail Address</label>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control"
|
||||
id="coreid-profile-email-input"
|
||||
placeholder="john.doe@contoso.com"
|
||||
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">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>Password</h4>
|
||||
<p class="font-italic" v-if="last_reset">Your password was last changed on {{ last_reset }}.</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
@click="change_password"
|
||||
>Change Password</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>Multi-factor Authentication</h4>
|
||||
<p class="font-italic">MFA was enabled for your account on {{ mfa_enable_date }}.</p>
|
||||
<button
|
||||
class="btn btn-danger btn-sm"
|
||||
type="button"
|
||||
@click="disable_mfa"
|
||||
>Disable MFA</button>
|
||||
|
||||
<h6 class="pad-top">App Passwords</h6>
|
||||
<p>App passwords are specially generated passwords that allow you to sign into legacy services with your {{ app_name }} account.</p>
|
||||
<p>You should only use this to authenticate against a service that needs to repeatedly use your password on your behalf (e.g. e-mail clients).</p>
|
||||
<p>Use these with caution, as they can bypass your multi-factor authentication.</p>
|
||||
|
||||
<p class="font-italic text-muted" v-if="app_passwords.length > 0">You have {{ app_passwords.length }} app {{ app_passwords.length === 1 ? 'password' : 'passwords' }} associated with your account.</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">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)"
|
||||
>Deactivate</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button class="btn btn-sm btn-primary" @click="on_click_generate_app_password">Generate New</button>
|
||||
|
||||
<h6 class="pad-top">Recovery Codes</h6>
|
||||
<p>Recovery codes can be used to regain access to your account in the event that you lose access to the device that generates your MFA codes.</p>
|
||||
<p class="font-italic">No recovery codes have been generated for your account.</p>
|
||||
<button class="btn btn-sm btn-success">Generate Recovery Codes</button>
|
||||
</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>
|
||||
</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 = ''
|
||||
|
||||
form_message = 'No changes.'
|
||||
|
||||
has_mfa = false
|
||||
ready = false
|
||||
|
||||
app_passwords = []
|
||||
|
||||
on_key_up = ($event) => {}
|
||||
|
||||
vue_on_create() {
|
||||
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()
|
||||
}
|
||||
|
||||
console.log('profile form', this)
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,114 @@
|
||||
import { Component } from '../../../../lib/vues6/vues6.js'
|
||||
import { utility } from '../../../service/Utility.service.js'
|
||||
import { auth_api } from '../../../service/AuthApi.service.js'
|
||||
|
||||
const template = `
|
||||
<div
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-hidden="true"
|
||||
ref="modal"
|
||||
>
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Generate App-Password</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group" v-if="!display_password">
|
||||
<label :for="uuid">App Name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
:id="uuid"
|
||||
v-model="name"
|
||||
@keyup="on_name_change"
|
||||
placeholder="My really cool e-mail client"
|
||||
:disabled="!enable_form"
|
||||
ref="input"
|
||||
>
|
||||
</div>
|
||||
<div v-if="display_password">
|
||||
The app password for <code>{{ name }}</code> was generated successfully. Copy the password below and use it in <code>{{ name }}</code> to sign in. Note that, once you close this window, you will no longer be able to view this password.
|
||||
</div>
|
||||
<div v-if="display_password" class="text-center pad-top">
|
||||
<pre><code>{{ display_password }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
data-dismiss="modal"
|
||||
@click="$emit('modal-cancel')"
|
||||
v-if="!display_password"
|
||||
>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!valid || !enable_form"
|
||||
@click="generate_pw"
|
||||
v-if="!display_password"
|
||||
>Generate</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
v-if="display_password"
|
||||
data-dismiss="modal"
|
||||
@click="$emit('modal-success')"
|
||||
>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
export default class AppPasswordFormComponent extends Component {
|
||||
static get selector() { return 'coreid-form-app-password' }
|
||||
static get template() { return template }
|
||||
static get props() { return [] }
|
||||
|
||||
name = ''
|
||||
valid = false
|
||||
uuid = ''
|
||||
enable_form = true
|
||||
display_password = ''
|
||||
|
||||
vue_on_create() {
|
||||
this.uuid = utility.uuid()
|
||||
console.log({auth_api})
|
||||
}
|
||||
|
||||
async on_name_change(event) {
|
||||
this.valid = this.name.trim().length > 0
|
||||
|
||||
if ( event.keyCode === 13 ) {
|
||||
// Enter was pressed
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if ( this.valid && this.enable_form ) await this.generate_pw()
|
||||
}
|
||||
}
|
||||
|
||||
trigger() {
|
||||
this.name = ''
|
||||
this.valid = false
|
||||
this.enable_form = true
|
||||
this.display_password = ''
|
||||
|
||||
this.$nextTick(() => {
|
||||
$(this.$refs.modal).modal()
|
||||
})
|
||||
}
|
||||
|
||||
async generate_pw() {
|
||||
this.enable_form = false
|
||||
const result = await auth_api.create_app_password(this.name)
|
||||
this.display_password = result.password
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
class Event {
|
||||
firings = []
|
||||
subscriptions = []
|
||||
|
||||
constructor(name) {
|
||||
this.name = name
|
||||
}
|
||||
|
||||
subscribe(handler) {
|
||||
if ( typeof handler !== 'function' ) {
|
||||
throw new TypeError('Event subscription handlers must be functions.')
|
||||
}
|
||||
|
||||
this.subscriptions.push(handler)
|
||||
}
|
||||
|
||||
async fire(...args) {
|
||||
this.firings.push({ args })
|
||||
|
||||
return Promise.all(this.subscriptions.map(x => x(...args)))
|
||||
}
|
||||
}
|
||||
|
||||
class EventBusService {
|
||||
_events = {}
|
||||
|
||||
event(name) {
|
||||
if ( !this._events[name] ) {
|
||||
this._events[name] = new Event(name)
|
||||
}
|
||||
|
||||
return this._events[name]
|
||||
}
|
||||
}
|
||||
|
||||
const event_bus = new EventBusService()
|
||||
export { event_bus, Event }
|
@ -0,0 +1,56 @@
|
||||
import { event_bus } from './EventBus.service.js'
|
||||
|
||||
class MessageService {
|
||||
listener_interval = 25000
|
||||
|
||||
alert({type, message, timeout = 0, on_dismiss = () => {} }) {
|
||||
event_bus.event('message.alert').fire({ type, message, timeout, on_dismiss })
|
||||
}
|
||||
|
||||
modal({title, message, buttons = [] }) {
|
||||
event_bus.event('message.modal').fire({ title, message, buttons })
|
||||
}
|
||||
|
||||
async fetch() {
|
||||
const result = await axios.get('/api/v1/message/banners')
|
||||
if ( result && result.data && result.data.data ) return result.data.data
|
||||
}
|
||||
|
||||
async dismiss(banner_id) {
|
||||
return axios.post(`/api/v1/message/banners/read/${banner_id}`)
|
||||
}
|
||||
|
||||
init_listener() {
|
||||
this.message_ids = []
|
||||
this.listener = setInterval(() => this._listener_tick(), this.listener_interval)
|
||||
window.addEventListener('beforeunload', () => this.stop_listener())
|
||||
this._listener_tick()
|
||||
}
|
||||
|
||||
async _listener_tick() {
|
||||
const result = await this.fetch()
|
||||
if ( result ) {
|
||||
for ( const banner of result ) {
|
||||
if ( this.message_ids.includes(banner.id) ) continue
|
||||
|
||||
this.message_ids.push(banner.id)
|
||||
await this.alert({
|
||||
type: banner.type,
|
||||
message: banner.message,
|
||||
on_dismiss: (e) => {
|
||||
this.dismiss(banner.id).then(() => {
|
||||
this.message_ids = this.message_ids.filter(x => x !== banner.id)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop_listener() {
|
||||
clearInterval(this.listener)
|
||||
}
|
||||
}
|
||||
|
||||
const message_service = new MessageService()
|
||||
export { message_service }
|
@ -0,0 +1,13 @@
|
||||
class PasswordService {
|
||||
async get_resets() {
|
||||
const result = await axios.get('/api/v1/password/resets')
|
||||
if ( result && result.data && result.data.data ) return result.data.data
|
||||
}
|
||||
|
||||
async reset(password) {
|
||||
await axios.post('/api/v1/password/resets', { password })
|
||||
}
|
||||
}
|
||||
|
||||
const password_service = new PasswordService()
|
||||
export { password_service }
|
@ -0,0 +1,15 @@
|
||||
class ProfileService {
|
||||
|
||||
async get_profile(user_id = 'me') {
|
||||
const results = await axios.get(`/api/v1/profile/${user_id}`)
|
||||
if ( results && results.data && results.data.data ) return results.data.data
|
||||
}
|
||||
|
||||
async update_profile({ user_id, first_name, last_name, email, tagline = undefined }) {
|
||||
await axios.patch(`/api/v1/profile/${user_id}`, { first_name, last_name, email, tagline })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const profile_service = new ProfileService()
|
||||
export { profile_service }
|
@ -0,0 +1,32 @@
|
||||
class Session {
|
||||
data = {}
|
||||
|
||||
init(data) {
|
||||
this.data = data
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const parts = key.split('.')
|
||||
let value = this.data
|
||||
for ( const part of parts ) {
|
||||
value = value[part]
|
||||
if ( typeof value === 'undefined' ) return value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
const parts = key.split('.')
|
||||
let parent = this.data
|
||||
|
||||
for ( const part of parts.slice(0, -1) ) {
|
||||
if ( !parent[part] ) parent[part] = {}
|
||||
parent = parent[part]
|
||||
}
|
||||
|
||||
parent[parts.reverse()[0]] = value
|
||||
}
|
||||
}
|
||||
|
||||
const session = new Session()
|
||||
export { session }
|
@ -0,0 +1,24 @@
|
||||
class UtilityService {
|
||||
_debounce_timeouts = {}
|
||||
|
||||
uuid() {
|
||||
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
|
||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
||||
)
|
||||
}
|
||||
|
||||
debounce(handler = () => {}, delay = 500) {
|
||||
let timeout = null
|
||||
|
||||
return (...args) => {
|
||||
clearTimeout(timeout)
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
handler(...args)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const utility = new UtilityService()
|
||||
export { utility }
|
@ -0,0 +1,63 @@
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#sidebar-wrapper {
|
||||
min-height: 100vh;
|
||||
margin-left: -15rem;
|
||||
-webkit-transition: margin .25s ease-out;
|
||||
-moz-transition: margin .25s ease-out;
|
||||
-o-transition: margin .25s ease-out;
|
||||
transition: margin .25s ease-out;
|
||||
}
|
||||
|
||||
#sidebar-wrapper .sidebar-heading {
|
||||
padding: 0.875rem 1.25rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
#sidebar-wrapper .list-group {
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
#page-content-wrapper {
|
||||
min-width: 100vw;
|
||||
}
|
||||
|
||||
#wrapper.toggled #sidebar-wrapper {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
#sidebar-wrapper {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
#page-content-wrapper {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#wrapper.toggled #sidebar-wrapper {
|
||||
margin-left: -15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.coreid-sidebar-wrapper {
|
||||
&.collapsed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.message-container {
|
||||
.alert {
|
||||
margin-bottom: 0;
|
||||
border-width: 0;
|
||||
border-bottom-width: 1px;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pad-top {
|
||||
padding-top: 30px;
|
||||
}
|