Rework login page to be AJAX/Vue.js based

This commit is contained in:
garrettmills
2020-04-22 09:19:25 -05:00
parent 175c335542
commit d68d5141c8
30 changed files with 12965 additions and 79 deletions

View File

@@ -0,0 +1,125 @@
import { Component } from '../../../lib/vues6/vues6.js'
import { auth_api } from '../../service/AuthApi.service.js'
import { location_service } from '../../service/Location.service.js'
const template = `
<div class="coreid-login-form 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-login-form-inner">
<div class="coreid-login-form-header font-weight-light">{{ app_name }}</div>
<div class="coreid-login-form-message">{{ login_message }}</div>
<form class="coreid-form" v-on:submit.prevent="do_nothing">
<div class="form-group">
<input
type="text"
id="coreid-login-form-username"
name="username"
class="form-control"
placeholder="Username"
v-model="username"
autofocus
@keyup="on_key_up"
:disabled="loading || step_two"
>
</div>
<div class="form-group" v-if="step_two">
<input
type="password"
id="coreid-login-form-password"
name="password"
class="form-control"
placeholder="Password"
v-model="password"
:disabled="loading"
@keyup="on_key_up"
ref="password_input"
>
</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">
<button type="button" class="btn btn-primary" :disabled="loading" v-if="step_two" v-on:click="back_click">Back</button>
<button type="button" class="btn btn-primary" :disabled="loading || btn_disabled" v-on:click="step_click">{{ button_text }}</button>
</div>
</form>
</div>
<div class="coreid-loading-spinner" v-if="loading"><div class="inner"></div></div>
</div>
`
export default class AuthLoginForm extends Component {
static get selector() { return 'coreid-login-form' }
static get props() { return ['app_name', 'login_message'] }
static get template() { return template }
username = ''
password = ''
button_text = 'Next'
step_two = false
btn_disabled = true
loading = false
error_message = ''
other_message = ''
watch_username(new_username, old_username) {
this.btn_disabled = !new_username
}
back_click() {
this.step_two = false
this.button_text = 'Next'
}
async on_key_up(event) {
if ( event.keyCode === 13 ) {
// Enter was pressed
event.preventDefault()
event.stopPropagation()
if ( !this.step_two && this.username ) return this.step_click(event)
else if ( this.step_two && this.username && this.password ) return this.step_click(event)
}
}
async step_click(event) {
if ( !this.step_two ) {
this.loading = true
try {
const is_valid = await auth_api.validate_username(this.username)
if ( !is_valid ) {
this.error_message = 'That username is invalid. Please try again.'
} else {
this.step_two = true
this.button_text = 'Continue'
this.error_message = ''
this.$nextTick(() => {
this.$refs.password_input.focus()
})
}
} catch (e) {
this.error_message = 'Sorry, an unknown error has occurred and we are unable to continue at this time.'
}
this.loading = false
} else {
this.loading = true
this.error_message = ''
const result = await auth_api.attempt({
username: this.username,
password: this.password,
create_session: true, // TODO support this being passed in
})
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)
}
}
do_nothing() {}
}

View File

@@ -0,0 +1,7 @@
import AuthLoginForm from "./auth/login/Form.component.js"
const components = {
AuthLoginForm
}
export { components }

View File

@@ -0,0 +1,23 @@
class AuthAPI {
async validate_username(username) {
const result = await axios.post('/api/v1/auth/validate/username', { username })
return result && result.data && result.data.data && result.data.data.is_valid
}
async attempt({ username, password, create_session }) {
try {
const result = await axios.post('/api/v1/auth/attempt', {
username, password, create_session
})
if ( result && result.data && result.data.data && result.data.data ) {
return result.data.data
}
} catch (e) {}
return { success: false }
}
}
const auth_api = new AuthAPI()
export { auth_api }

View File

@@ -0,0 +1,13 @@
class LocationService {
async redirect(to, delay = 0) {
return new Promise(res => {
setTimeout(() => {
window.location = to
res()
}, delay)
})
}
}
const location_service = new LocationService()
export { location_service }

182
app/assets/less/form.less Normal file
View File

@@ -0,0 +1,182 @@
@keyframes loading-bar {
0% {
left: 0;
width: 0;
}
5% {
left: 0;
width: 12.5%;
}
10% {
left: 0;
width: 25%;
}
15% {
left: 0;
width: 37.5%;
}
20% {
left: 0;
width: 50%;
}
25% {
left: 0;
width: 62.5%;
}
30% {
left: 0;
width: 75%;
}
35% {
left: 0;
width: 87.5%;
}
40% {
left: 0;
width: 100%;
}
50% {
left: 0;
width: 100%;
}
55% {
left: 12.5%;
width: 87.5%;
}
60% {
left: 25%;
width: 75%;
}
65% {
left: 37.5%;
width: 62.5%;
}
70% {
left: 50%;
width: 50%;
}
75% {
left: 62.5%;
width: 37.5%;
}
80% {
left: 75%;
width: 25%;
}
85% {
left: 87.5%;
width: 12.5%;
}
90% {
left: 100%;
width: 0;
}
100% {
left: 0;
width: 0;
}
}
.coreid-form input {
border-radius: 0;
border: none;
border-bottom: 2px solid #aaa;
font-size: 1.2em;
background: none;
box-shadow: none;
&:focus {
box-shadow: none;
border-bottom: 2px solid #666;
}
}
.coreid-login-form {
border: 2px solid #ddd;
border-radius: 7px;
.coreid-login-form-inner {
padding: 30px;
padding-top: 170px;
padding-bottom: 160px;
}
.coreid-login-form-header {
font-size: 2.5em;
margin-bottom: 10px;
}
.coreid-login-form-message {
margin-bottom: 40px;
}
.buttons {
margin-top: 40px;
margin-bottom: 0;
.btn {
background: #666;
border-color: #444;
&:hover {
background: #777;
}
&:focus {
background: #888;
box-shadow: #333;
}
}
}
.coreid-loading-spinner {
overflow: hidden;
background-color: #ddd;
height: 7px;
margin: 0;
padding: 0;
width: calc(100% + 30px);
margin-left: -15px;
border-radius: 0 0 5px 5px;
.inner {
height: 7px;
width: 50px;
background-color: #bbb;
position: absolute;
left: 0;
border-radius: 0 0 5px 5px;
animation-name: loading-bar;
animation-duration: 1.5s;
animation-fill-mode: both;
animation-iteration-count: infinite;
}
}
.error-message {
color: darkred;
font-size: 0.8em;
}
.other-message {
font-size: 0.8em;
}
}

View File

@@ -0,0 +1,12 @@
.masthead {
height: 100vh;
min-height: 500px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.app-container {
background: #eee;
color: #666;
}

View File

@@ -0,0 +1,9 @@
#about {
background: #666;
color: #eee;
a {
color: white;
text-decoration: underline;
}
}

3
app/assets/lib/axios/axios.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
export default class VuES6Loader {
constructor(component_list) {
this.components = component_list
}
load() {
for ( const ComponentClass of Object.values(this.components) ) {
const method_properties = Object.getOwnPropertyNames(ComponentClass.prototype)
const watch = {}
method_properties.filter(x => x.startsWith('watch_')).some(method_name => {
const field_name = method_name.substr(6)
const handler = function(...args) {
return ComponentClass.prototype[method_name].bind(this)(...args)
}
watch[field_name] = handler
})
const methods = {}
method_properties.filter(x => !x.startsWith('watch_')).some(method_name => {
const handler = function(...args) {
return ComponentClass.prototype[method_name].bind(this)(...args)
}
methods[method_name] = handler
})
Vue.component(ComponentClass.selector, {
props: ComponentClass.props,
data: () => {
return new ComponentClass()
},
watch,
methods,
template: ComponentClass.template,
})
}
}
}
export class Component {
static get selector() { return '' }
static get template() { return '' }
static get props() { return [] }
}