SAML; Dashboard

feature/cd
garrettmills 3 years ago
parent e3ecfb0d37
commit c389e151b5
No known key found for this signature in database
GPG Key ID: 6ACD58D6ADACFC6E

@ -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)
}
}
}

@ -6,7 +6,7 @@ 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>
<div class="coreid-message">{{ message }}</div>
<div class="coreid-message" v-html="message"></div>
<div class="buttons text-right pad-top">
<button
type="button"

@ -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()
}
}
}

@ -48,7 +48,9 @@ const template = `
export default class AuthLoginForm extends Component {
static get selector() { return 'coreid-login-form' }
static get props() { return ['app_name', 'login_message'] }
static get props() { return [
'app_name', 'login_message', 'additional_params', 'no_session',
] }
static get template() { return template }
username = ''
@ -102,20 +104,23 @@ export default class AuthLoginForm extends Component {
this.loading = true
this.error_message = ''
const result = await auth_api.attempt({
let attempt_vars = {
username: this.username,
password: this.password,
create_session: true, // TODO support this being passed in
})
create_session: !this.no_session,
}
if ( typeof this.additional_params === 'object' ) {
attempt_vars = {...attempt_vars, ...this.additional_params}
}
const result = await auth_api.attempt(attempt_vars)
if ( !result.success ) {
this.error_message = result.message || 'Sorry, an unknown error has occurred and we are unable to continue at this time.'
this.loading = false
return
}
// TODO handle 2FA
this.other_message = 'Success! Let\'s get you on your way...'
await location_service.redirect(result.next, 1500)
}

@ -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'] }
}

@ -2,12 +2,16 @@ import AuthLoginForm from "./auth/login/Form.component.js"
import AuthPage from './auth/Page.component.js'
import MFASetupPage from './auth/MFASetup.component.js'
import MFAChallengePage from './auth/MFAChallenge.component.js'
import MFADisableComponent from './auth/MFADisable.component.js'
import PasswordResetComponent from './auth/PasswordReset.component.js'
const components = {
AuthLoginForm,
AuthPage,
MFASetupPage,
MFAChallengePage,
MFADisableComponent,
PasswordResetComponent,
}
export { components }

@ -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">&times;</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">&times;</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">&times;</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
}
}

@ -4,7 +4,7 @@ class ActionService {
async perform({ text, action, ...args }) {
if ( action === 'redirect' ) {
if ( args.next ) {
return location_service.redirect(args.next, 1500)
return location_service.redirect(args.next, args.delay || 1500)
}
} else {
throw new TypeError(`Unknown action type: ${action}`)

@ -4,10 +4,10 @@ class AuthAPI {
return result && result.data && result.data.data && result.data.data.is_valid
}
async attempt({ username, password, create_session }) {
async attempt({ username, password, create_session, ...others }) {
try {
const result = await axios.post('/api/v1/auth/attempt', {
username, password, create_session
username, password, create_session, ...others
})
if ( result && result.data && result.data.data && result.data.data ) {
@ -32,6 +32,30 @@ class AuthAPI {
const result = await axios.post('/api/v1/auth/mfa/enable')
return result && result.data && result.data.data && result.data.data.success && result.data.data.mfa_enabled
}
async mfa_disable() {
const result = await axios.post('/api/v1/auth/mfa/disable')
return result && result.data && result.data.data && result.data.data.success && !result.data.data.mfa_enabled
}
async has_mfa() {
const result = await axios.get('/api/v1/auth/mfa/enable/date')
if ( result && result.data && result.data.data ) return result.data.data
}
async app_passwords() {
const result = await axios.get('/api/v1/password/app_passwords')
if ( result && result.data && Array.isArray(result.data.data) ) return result.data.data
}
async create_app_password(name) {
const result = await axios.post('/api/v1/password/app_passwords', { name })
if ( result && result.data && result.data.data ) return result.data.data
}
async delete_app_password(uuid) {
await axios.delete(`/api/v1/password/app_passwords/${uuid}`)
}
}
const auth_api = new AuthAPI()

@ -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 }

@ -11,6 +11,15 @@ class LocationService {
async back() {
return window.history.back()
}
async reload(delay = 0) {
return new Promise(res => {
setTimeout(() => {
window.location.reload()
res()
}, delay)
})
}
}
const location_service = new LocationService()

@ -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;
}

@ -114,7 +114,7 @@
border-radius: 7px;
display: flex;
align-items: center;
min-height: 65vh;
min-height: 70vh;
background-color: #f8f8f8;
.coreid-login-form-inner, .coreid-auth-page-inner {

@ -10,3 +10,11 @@
background: #eee;
color: #666;
}
.text-red {
color: darkred;
}
.text-green {
color: darkgreen;