Browse Source

SAML; Dashboard

feature/cd
garrettmills 2 years ago
parent
commit
c389e151b5
No known key found for this signature in database GPG Key ID: 6ACD58D6ADACFC6E
  1. 62
      app/assets/app/auth/MFADisable.component.js
  2. 2
      app/assets/app/auth/Page.component.js
  3. 168
      app/assets/app/auth/PasswordReset.component.js
  4. 17
      app/assets/app/auth/login/Form.component.js
  5. 30
      app/assets/app/cobalt/Listing.component.js
  6. 4
      app/assets/app/components.js
  7. 19
      app/assets/app/dash-components.js
  8. 65
      app/assets/app/dash/NavBar.component.js
  9. 75
      app/assets/app/dash/SideBar.component.js
  10. 118
      app/assets/app/dash/message/MessageContainer.component.js
  11. 288
      app/assets/app/dash/profile/EditProfile.component.js
  12. 114
      app/assets/app/dash/profile/form/AppPassword.component.js
  13. 2
      app/assets/app/service/Action.service.js
  14. 28
      app/assets/app/service/AuthApi.service.js
  15. 37
      app/assets/app/service/EventBus.service.js
  16. 9
      app/assets/app/service/Location.service.js
  17. 56
      app/assets/app/service/Message.service.js
  18. 13
      app/assets/app/service/Password.service.js
  19. 15
      app/assets/app/service/Profile.service.js
  20. 32
      app/assets/app/service/Session.service.js
  21. 24
      app/assets/app/service/Utility.service.js
  22. 63
      app/assets/less/dashboard.less
  23. 2
      app/assets/less/form.less
  24. 8
      app/assets/less/public.less
  25. 34
      app/assets/lib/fa/LICENSE.txt
  26. 4556
      app/assets/lib/fa/css/all.css
  27. 5
      app/assets/lib/fa/css/all.min.css
  28. 15
      app/assets/lib/fa/css/brands.css
  29. 5
      app/assets/lib/fa/css/brands.min.css
  30. 4522
      app/assets/lib/fa/css/fontawesome.css
  31. 5
      app/assets/lib/fa/css/fontawesome.min.css
  32. 15
      app/assets/lib/fa/css/regular.css
  33. 5
      app/assets/lib/fa/css/regular.min.css
  34. 16
      app/assets/lib/fa/css/solid.css
  35. 5
      app/assets/lib/fa/css/solid.min.css
  36. 371
      app/assets/lib/fa/css/svg-with-js.css
  37. 5
      app/assets/lib/fa/css/svg-with-js.min.css
  38. 2172
      app/assets/lib/fa/css/v4-shims.css
  39. 5
      app/assets/lib/fa/css/v4-shims.min.css
  40. 4441
      app/assets/lib/fa/js/all.js
  41. 5
      app/assets/lib/fa/js/all.min.js
  42. 571
      app/assets/lib/fa/js/brands.js
  43. 5
      app/assets/lib/fa/js/brands.min.js
  44. 998
      app/assets/lib/fa/js/conflict-detection.js
  45. 5
      app/assets/lib/fa/js/conflict-detection.min.js
  46. 2478
      app/assets/lib/fa/js/fontawesome.js
  47. 5
      app/assets/lib/fa/js/fontawesome.min.js
  48. 280
      app/assets/lib/fa/js/regular.js
  49. 5
      app/assets/lib/fa/js/regular.min.js
  50. 1124
      app/assets/lib/fa/js/solid.js
  51. 5
      app/assets/lib/fa/js/solid.min.js
  52. 68
      app/assets/lib/fa/js/v4-shims.js
  53. 5
      app/assets/lib/fa/js/v4-shims.min.js
  54. 19
      app/assets/lib/fa/less/_animated.less
  55. 16
      app/assets/lib/fa/less/_bordered-pulled.less
  56. 12
      app/assets/lib/fa/less/_core.less
  57. 6
      app/assets/lib/fa/less/_fixed-width.less
  58. 1441
      app/assets/lib/fa/less/_icons.less
  59. 27
      app/assets/lib/fa/less/_larger.less
  60. 18
      app/assets/lib/fa/less/_list.less
  61. 56
      app/assets/lib/fa/less/_mixins.less
  62. 24
      app/assets/lib/fa/less/_rotated-flipped.less
  63. 5
      app/assets/lib/fa/less/_screen-reader.less
  64. 2066
      app/assets/lib/fa/less/_shims.less
  65. 22
      app/assets/lib/fa/less/_stacked.less
  66. 1453
      app/assets/lib/fa/less/_variables.less
  67. 23
      app/assets/lib/fa/less/brands.less
  68. 16
      app/assets/lib/fa/less/fontawesome.less
  69. 23
      app/assets/lib/fa/less/regular.less
  70. 24
      app/assets/lib/fa/less/solid.less
  71. 6
      app/assets/lib/fa/less/v4-shims.less
  72. 2562
      app/assets/lib/fa/metadata/categories.yml
  73. 57762
      app/assets/lib/fa/metadata/icons.json
  74. 21485
      app/assets/lib/fa/metadata/icons.yml
  75. 2317
      app/assets/lib/fa/metadata/shims.json
  76. 298
      app/assets/lib/fa/metadata/shims.yml
  77. 688
      app/assets/lib/fa/metadata/sponsors.yml
  78. 20
      app/assets/lib/fa/scss/_animated.scss
  79. 20
      app/assets/lib/fa/scss/_bordered-pulled.scss
  80. 21
      app/assets/lib/fa/scss/_core.scss
  81. 6
      app/assets/lib/fa/scss/_fixed-width.scss
  82. 1441
      app/assets/lib/fa/scss/_icons.scss
  83. 23
      app/assets/lib/fa/scss/_larger.scss
  84. 18
      app/assets/lib/fa/scss/_list.scss
  85. 56
      app/assets/lib/fa/scss/_mixins.scss
  86. 24
      app/assets/lib/fa/scss/_rotated-flipped.scss
  87. 5
      app/assets/lib/fa/scss/_screen-reader.scss
  88. 2066
      app/assets/lib/fa/scss/_shims.scss
  89. 31
      app/assets/lib/fa/scss/_stacked.scss
  90. 1458
      app/assets/lib/fa/scss/_variables.scss
  91. 23
      app/assets/lib/fa/scss/brands.scss
  92. 16
      app/assets/lib/fa/scss/fontawesome.scss
  93. 23
      app/assets/lib/fa/scss/regular.scss
  94. 24
      app/assets/lib/fa/scss/solid.scss
  95. 6
      app/assets/lib/fa/scss/v4-shims.scss
  96. 1336
      app/assets/lib/fa/sprites/brands.svg
  97. 463
      app/assets/lib/fa/sprites/regular.svg
  98. 2995
      app/assets/lib/fa/sprites/solid.svg
  99. 1
      app/assets/lib/fa/svgs/brands/500px.svg
  100. 1
      app/assets/lib/fa/svgs/brands/accessible-icon.svg

62
app/assets/app/auth/MFADisable.component.js

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

2
app/assets/app/auth/Page.component.js

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

168
app/assets/app/auth/PasswordReset.component.js

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

17
app/assets/app/auth/login/Form.component.js

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

30
app/assets/app/cobalt/Listing.component.js

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

4
app/assets/app/components.js

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

19
app/assets/app/dash-components.js

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

65
app/assets/app/dash/NavBar.component.js

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

75
app/assets/app/dash/SideBar.component.js

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

118
app/assets/app/dash/message/MessageContainer.component.js

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

288
app/assets/app/dash/profile/EditProfile.component.js

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

114
app/assets/app/dash/profile/form/AppPassword.component.js

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

2
app/assets/app/service/Action.service.js

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

28
app/assets/app/service/AuthApi.service.js

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

37
app/assets/app/service/EventBus.service.js

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

9
app/assets/app/service/Location.service.js

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

56
app/assets/app/service/Message.service.js

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

13
app/assets/app/service/Password.service.js

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

15
app/assets/app/service/Profile.service.js

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

32
app/assets/app/service/Session.service.js

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

24
app/assets/app/service/Utility.service.js

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

63
app/assets/less/dashboard.less

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

2
app/assets/less/form.less

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

8
app/assets/less/public.less

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

34
app/assets/lib/fa/LICENSE.txt

@ -0,0 +1,34 @@
Font Awesome Free License
-------------------------
Font Awesome Free is free, open source, and GPL friendly. You can use it for
commercial projects, open source projects, or really almost whatever you want.
Full Font Awesome Free license: https://fontawesome.com/license/free.
# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/)
In the Font Awesome Free download, the CC BY 4.0 license applies to all icons
packaged as SVG and JS file types.
# Fonts: SIL OFL 1.1 License (https://scripts.sil.org/OFL)
In the Font Awesome Free download, the SIL OFL license applies to all icons
packaged as web and desktop font files.
# Code: MIT License (https://opensource.org/licenses/MIT)
In the Font Awesome Free download, the MIT license applies to all non-font and
non-icon files.
# Attribution
Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font
Awesome Free files already contain embedded comments with sufficient
attribution, so you shouldn't need to do anything additional when using these
files normally.
We've kept attribution comments terse, so we ask that you do not actively work
to remove them from files, especially code. They're a great way for folks to
learn about Font Awesome.
# Brand Icons
All brand icons are trademarks of their respective owners. The use of these
trademarks does not indicate endorsement of the trademark holder by Font
Awesome, nor vice versa. **Please do not use brand logos for any purpose except
to represent the company, product, or service to which they refer.**

4556
app/assets/lib/fa/css/all.css

File diff suppressed because it is too large

5
app/assets/lib/fa/css/all.min.css

File diff suppressed because one or more lines are too long

15
app/assets/lib/fa/css/brands.css

@ -0,0 +1,15 @@
/*!
* Font Awesome Free 5.13.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
@font-face {
font-family: 'Font Awesome 5 Brands';
font-style: normal;
font-weight: 400;
font-display: block;
src: url("../webfonts/fa-brands-400.eot");
src: url("../webfonts/fa-brands-400.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-brands-400.woff2") format("woff2"), url("../webfonts/fa-brands-400.woff") format("woff"), url("../webfonts/fa-brands-400.ttf") format("truetype"), url("../webfonts/fa-brands-400.svg#fontawesome") format("svg"); }
.fab {
font-family: 'Font Awesome 5 Brands';
font-weight: 400; }

5
app/assets/lib/fa/css/brands.min.css

@ -0,0 +1,5 @@
/*!
* Font Awesome Free 5.13.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
@font-face{font-family:"Font Awesome 5 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.eot);src:url(../webfonts/fa-brands-400.eot?#iefix) format("embedded-opentype"),url(