Merge pull request 'feat: improve login and register ux' (#1104) from feature/login-improvements into main

Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/1104
Reviewed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
This commit is contained in:
Dominik Pschenitschni 2022-02-05 17:22:43 +00:00
commit 8058790f9a
13 changed files with 255 additions and 119 deletions

View file

@ -25,7 +25,6 @@ context('Registration', () => {
cy.get('#username').type(fixture.username) cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email) cy.get('#email').type(fixture.email)
cy.get('#password').type(fixture.password) cy.get('#password').type(fixture.password)
cy.get('#passwordValidation').type(fixture.password)
cy.get('#register-submit').click() cy.get('#register-submit').click()
cy.url().should('include', '/') cy.url().should('include', '/')
cy.clock(1625656161057) // 13:00 cy.clock(1625656161057) // 13:00
@ -43,7 +42,6 @@ context('Registration', () => {
cy.get('#username').type(fixture.username) cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email) cy.get('#email').type(fixture.email)
cy.get('#password').type(fixture.password) cy.get('#password').type(fixture.password)
cy.get('#passwordValidation').type(fixture.password)
cy.get('#register-submit').click() cy.get('#register-submit').click()
cy.get('div.message.danger').contains('A user with this username already exists.') cy.get('div.message.danger').contains('A user with this username already exists.')
}) })

View file

@ -0,0 +1,85 @@
<template>
<div class="password-field">
<input
class="input"
id="password"
name="password"
:placeholder="$t('user.auth.passwordPlaceholder')"
required
:type="passwordFieldType"
autocomplete="current-password"
@keyup.enter="e => $emit('submit', e)"
:tabindex="props.tabindex"
@focusout="validate"
@input="handleInput"
/>
<a
@click="togglePasswordFieldType"
class="password-field-type-toggle"
aria-label="passwordFieldType === 'password' ? $t('user.auth.showPassword') : $t('user.auth.hidePassword')"
v-tooltip="passwordFieldType === 'password' ? $t('user.auth.showPassword') : $t('user.auth.hidePassword')">
<icon :icon="passwordFieldType === 'password' ? 'eye' : 'eye-slash'"/>
</a>
</div>
<p class="help is-danger" v-if="!isValid">
{{ $t('user.auth.passwordRequired') }}
</p>
</template>
<script lang="ts" setup>
import {ref, watch} from 'vue'
import {useDebounceFn} from '@vueuse/core'
const props = defineProps({
tabindex: String,
modelValue: String,
// This prop is a workaround to trigger validation from the outside when the user never had focus in the input.
validateInitially: Boolean,
})
const emit = defineEmits(['submit', 'update:modelValue'])
const passwordFieldType = ref<String>('password')
const password = ref<String>('')
const isValid = ref<Boolean>(!props.validateInitially)
watch(
() => props.validateInitially,
(doValidate: Boolean) => {
if (doValidate) {
validate()
}
},
)
function validate() {
useDebounceFn(() => {
isValid.value = password.value !== ''
}, 100)()
}
function togglePasswordFieldType() {
passwordFieldType.value = passwordFieldType.value === 'password'
? 'text'
: 'password'
}
function handleInput(e) {
password.value = e.target.value
emit('update:modelValue', e.target.value)
}
</script>
<style scoped>
.password-field {
position: relative;
}
.password-field-type-toggle {
position: absolute;
color: var(--grey-400);
top: 50%;
right: 1rem;
transform: translateY(-50%);
}
</style>

View file

@ -1,18 +1,35 @@
<template> <template>
<div class="message-wrapper"> <div class="message-wrapper">
<div class="message" :class="variant"> <div class="message" :class="[variant, textAlignClass]">
<slot/> <slot/>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
defineProps({ import {computed, PropType} from 'vue'
const TEXT_ALIGN_MAP = Object.freeze({
left: '',
center: 'has-text-centered',
right: 'has-text-right',
})
type textAlignVariants = keyof typeof TEXT_ALIGN_MAP
const props = defineProps({
variant: { variant: {
type: String, type: String,
default: 'info', default: 'info',
}, },
textAlign: {
type: String as PropType<textAlignVariants>,
default: 'left',
},
}) })
const textAlignClass = computed(() => TEXT_ALIGN_MAP[props.textAlign])
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -14,6 +14,9 @@
<div> <div>
<h2 class="title" v-if="title">{{ title }}</h2> <h2 class="title" v-if="title">{{ title }}</h2>
<api-config/> <api-config/>
<Message v-if="motd !== ''" class="is-hidden-tablet mb-4">
{{ motd }}
</Message>
<slot/> <slot/>
</div> </div>
<legal/> <legal/>
@ -38,8 +41,8 @@ const store = useStore()
const {t} = useI18n() const {t} = useI18n()
const motd = computed(() => store.state.config.motd) const motd = computed(() => store.state.config.motd)
// @ts-ignore
const title = computed(() => t(route.meta.title ?? '')) const title = computed(() => t(route.meta?.title as string || ''))
useTitle(() => title.value) useTitle(() => title.value)
</script> </script>

6
src/helpers/isEmail.ts Normal file
View file

@ -0,0 +1,6 @@
export function isEmail(email: string): Boolean {
const format = /^.+@.+$/
const match = email.match(format)
return match === null ? false : match.length > 0
}

View file

@ -31,10 +31,9 @@
"username": "Username", "username": "Username",
"usernameEmail": "Username Or Email Address", "usernameEmail": "Username Or Email Address",
"usernamePlaceholder": "e.g. frederick", "usernamePlaceholder": "e.g. frederick",
"email": "E-mail address", "email": "Email address",
"emailPlaceholder": "e.g. frederic{'@'}vikunja.io", "emailPlaceholder": "e.g. frederic{'@'}vikunja.io",
"password": "Password", "password": "Password",
"passwordRepeat": "Retype your password",
"passwordPlaceholder": "e.g. •••••••••••", "passwordPlaceholder": "e.g. •••••••••••",
"forgotPassword": "Forgot your password?", "forgotPassword": "Forgot your password?",
"resetPassword": "Reset your password", "resetPassword": "Reset your password",
@ -45,12 +44,19 @@
"totpTitle": "Two Factor Authentication Code", "totpTitle": "Two Factor Authentication Code",
"totpPlaceholder": "e.g. 123456", "totpPlaceholder": "e.g. 123456",
"login": "Login", "login": "Login",
"register": "Register", "createAccount": "Create account",
"loginWith": "Log in with {provider}", "loginWith": "Log in with {provider}",
"authenticating": "Authenticating…", "authenticating": "Authenticating…",
"openIdStateError": "State does not match, refusing to continue!", "openIdStateError": "State does not match, refusing to continue!",
"openIdGeneralError": "An error occured while authenticating against the third party.", "openIdGeneralError": "An error occured while authenticating against the third party.",
"logout": "Logout" "logout": "Logout",
"emailInvalid": "Please enter a valid email address.",
"usernameRequired": "Please provide a username.",
"passwordRequired": "Please provide a password.",
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?"
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
@ -61,7 +67,7 @@
"currentPasswordPlaceholder": "Your current password", "currentPasswordPlaceholder": "Your current password",
"passwordsDontMatch": "The new password and its confirmation don't match.", "passwordsDontMatch": "The new password and its confirmation don't match.",
"passwordUpdateSuccess": "The password was successfully updated.", "passwordUpdateSuccess": "The password was successfully updated.",
"updateEmailTitle": "Update Your E-Mail Address", "updateEmailTitle": "Update Your Email Address",
"updateEmailNew": "New Email Address", "updateEmailNew": "New Email Address",
"updateEmailSuccess": "Your email address was successfully updated. We've sent you a link to confirm it.", "updateEmailSuccess": "Your email address was successfully updated. We've sent you a link to confirm it.",
"general": { "general": {

View file

@ -16,6 +16,8 @@ import {
faCocktail, faCocktail,
faCoffee, faCoffee,
faCog, faCog,
faEye,
faEyeSlash,
faEllipsisH, faEllipsisH,
faEllipsisV, faEllipsisV,
faExclamation, faExclamation,
@ -87,6 +89,8 @@ library.add(faCocktail)
library.add(faCoffee) library.add(faCoffee)
library.add(faCog) library.add(faCog)
library.add(faComments) library.add(faComments)
library.add(faEye)
library.add(faEyeSlash)
library.add(faEllipsisH) library.add(faEllipsisH)
library.add(faEllipsisV) library.add(faEllipsisV)
library.add(faExclamation) library.add(faExclamation)

View file

@ -133,7 +133,7 @@ const router = createRouter({
name: 'user.register', name: 'user.register',
component: RegisterComponent, component: RegisterComponent,
meta: { meta: {
title: 'user.auth.register', title: 'user.auth.createAccount',
}, },
}, },
{ {

View file

@ -1,9 +1,9 @@
<template> <template>
<div> <div>
<message variant="success" class="has-text-centered" v-if="confirmedEmailSuccess"> <message variant="success" text-align="center" class="mb-4" v-if="confirmedEmailSuccess">
{{ $t('user.auth.confirmEmailSuccess') }} {{ $t('user.auth.confirmEmailSuccess') }}
</message> </message>
<message variant="danger" v-if="errorMessage"> <message variant="danger" v-if="errorMessage" class="mb-4">
{{ errorMessage }} {{ errorMessage }}
</message> </message>
<form @submit.prevent="submit" id="loginform" v-if="localAuthEnabled"> <form @submit.prevent="submit" id="loginform" v-if="localAuthEnabled">
@ -20,24 +20,26 @@
autocomplete="username" autocomplete="username"
v-focus v-focus
@keyup.enter="submit" @keyup.enter="submit"
tabindex="1"
@focusout="validateField('username')"
/> />
</div> </div>
<p class="help is-danger" v-if="!usernameValid">
{{ $t('user.auth.usernameRequired') }}
</p>
</div> </div>
<div class="field"> <div class="field">
<div class="label-with-link">
<label class="label" for="password">{{ $t('user.auth.password') }}</label> <label class="label" for="password">{{ $t('user.auth.password') }}</label>
<div class="control"> <router-link
<input :to="{ name: 'user.password-reset.request' }"
class="input" class="reset-password-link"
id="password" tabindex="6"
name="password" >
:placeholder="$t('user.auth.passwordPlaceholder')" {{ $t('user.auth.forgotPassword') }}
ref="password" </router-link>
required
type="password"
autocomplete="current-password"
@keyup.enter="submit"
/>
</div> </div>
<password tabindex="2" @submit="submit" v-model="password" :validate-initially="validatePasswordInitially"/>
</div> </div>
<div class="field" v-if="needsTotpPasscode"> <div class="field" v-if="needsTotpPasscode">
<label class="label" for="totpPasscode">{{ $t('user.auth.totpTitle') }}</label> <label class="label" for="totpPasscode">{{ $t('user.auth.totpTitle') }}</label>
@ -52,32 +54,28 @@
type="text" type="text"
v-focus v-focus
@keyup.enter="submit" @keyup.enter="submit"
tabindex="3"
/> />
</div> </div>
</div> </div>
<div class="field is-grouped login-buttons">
<div class="control is-expanded">
<x-button <x-button
@click="submit" @click="submit"
:loading="loading" :loading="loading"
tabindex="4"
> >
{{ $t('user.auth.login') }} {{ $t('user.auth.login') }}
</x-button> </x-button>
<x-button <p class="mt-2" v-if="registrationEnabled">
{{ $t('user.auth.noAccountYet') }}
<router-link
:to="{ name: 'user.register' }" :to="{ name: 'user.register' }"
v-if="registrationEnabled" type="secondary"
variant="secondary" tabindex="5"
> >
{{ $t('user.auth.register') }} {{ $t('user.auth.createAccount') }}
</x-button>
</div>
<div class="control">
<router-link :to="{ name: 'user.password-reset.request' }" class="reset-password-link">
{{ $t('user.auth.forgotPassword') }}
</router-link> </router-link>
</div> </p>
</div>
</form> </form>
<div <div
@ -97,6 +95,7 @@
</template> </template>
<script> <script>
import {useDebounceFn} from '@vueuse/core'
import {mapState} from 'vuex' import {mapState} from 'vuex'
import {HTTPFactory} from '@/http-common' import {HTTPFactory} from '@/http-common'
@ -105,15 +104,20 @@ import {getErrorText} from '@/message'
import Message from '@/components/misc/message' import Message from '@/components/misc/message'
import {redirectToProvider} from '../../helpers/redirectToProvider' import {redirectToProvider} from '../../helpers/redirectToProvider'
import {getLastVisited, clearLastVisited} from '../../helpers/saveLastVisited' import {getLastVisited, clearLastVisited} from '../../helpers/saveLastVisited'
import Password from '@/components/input/password'
export default { export default {
components: { components: {
Password,
Message, Message,
}, },
data() { data() {
return { return {
confirmedEmailSuccess: false, confirmedEmailSuccess: false,
errorMessage: '', errorMessage: '',
usernameValid: true,
password: '',
validatePasswordInitially: false,
} }
}, },
beforeMount() { beforeMount() {
@ -166,6 +170,13 @@ export default {
localAuthEnabled: state => state.config.auth.local.enabled, localAuthEnabled: state => state.config.auth.local.enabled,
openidConnect: state => state.config.auth.openidConnect, openidConnect: state => state.config.auth.openidConnect,
}), }),
validateField() {
// using computed so that debounced function definition stays
return useDebounceFn((field) => {
this[`${field}Valid`] = this.$refs[field].value !== ''
}, 100)
},
}, },
methods: { methods: {
setLoading() { setLoading() {
@ -185,7 +196,14 @@ export default {
// For more info, see https://kolaente.dev/vikunja/frontend/issues/78 // For more info, see https://kolaente.dev/vikunja/frontend/issues/78
const credentials = { const credentials = {
username: this.$refs.username.value, username: this.$refs.username.value,
password: this.$refs.password.value, password: this.password,
}
if (credentials.username === '' || credentials.password === '') {
// Trigger the validation error messages
this.validateField('username')
this.validatePasswordInitially = true
return
} }
if (this.needsTotpPasscode) { if (this.needsTotpPasscode) {
@ -196,7 +214,7 @@ export default {
await this.$store.dispatch('auth/login', credentials) await this.$store.dispatch('auth/login', credentials)
this.$store.commit('auth/needsTotpPasscode', false) this.$store.commit('auth/needsTotpPasscode', false)
} catch (e) { } catch (e) {
if (e.response && e.response.data.code === 1017 && !credentials.totpPasscode) { if (e.response?.data.code === 1017 && !this.credentials.totpPasscode) {
return return
} }
@ -211,22 +229,21 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.login-buttons {
@media screen and (max-width: 450px) {
flex-direction: column;
.control:first-child {
margin-bottom: 1rem;
}
}
}
.button { .button {
margin: 0 0.4rem 0 0; margin: 0 0.4rem 0 0;
} }
.reset-password-link { .reset-password-link {
display: inline-block; display: inline-block;
padding-top: 5px; }
.label-with-link {
display: flex;
justify-content: space-between;
margin-bottom: .5rem;
.label {
margin-bottom: 0;
}
} }
</style> </style>

View file

@ -1,9 +1,9 @@
<template> <template>
<div> <div>
<message v-if="errorMsg"> <message v-if="errorMsg" class="mb-4">
{{ errorMsg }} {{ errorMsg }}
</message> </message>
<div class="has-text-centered" v-if="successMessage"> <div class="has-text-centered mb-4" v-if="successMessage">
<message variant="success"> <message variant="success">
{{ successMessage }} {{ successMessage }}
</message> </message>

View file

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<message variant="danger" v-if="errorMessage !== ''"> <message variant="danger" v-if="errorMessage !== ''" class="mb-4">
{{ errorMessage }} {{ errorMessage }}
</message> </message>
<form @submit.prevent="submit" id="registerform"> <form @submit.prevent="submit" id="registerform">
@ -18,8 +18,12 @@
v-focus v-focus
v-model="credentials.username" v-model="credentials.username"
@keyup.enter="submit" @keyup.enter="submit"
@focusout="validateUsername"
/> />
</div> </div>
<p class="help is-danger" v-if="!usernameValid">
{{ $t('user.auth.usernameRequired') }}
</p>
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="email">{{ $t('user.auth.email') }}</label> <label class="label" for="email">{{ $t('user.auth.email') }}</label>
@ -33,68 +37,46 @@
type="email" type="email"
v-model="credentials.email" v-model="credentials.email"
@keyup.enter="submit" @keyup.enter="submit"
@focusout="validateEmail"
/> />
</div> </div>
<p class="help is-danger" v-if="!emailValid">
{{ $t('user.auth.emailInvalid') }}
</p>
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="password">{{ $t('user.auth.password') }}</label> <label class="label" for="password">{{ $t('user.auth.password') }}</label>
<div class="control"> <password @submit="submit" @update:modelValue="v => credentials.password = v" :validate-initially="validatePasswordInitially"/>
<input
class="input"
id="password"
name="password"
:placeholder="$t('user.auth.passwordPlaceholder')"
required
type="password"
autocomplete="new-password"
v-model="credentials.password"
@keyup.enter="submit"
/>
</div>
</div>
<div class="field">
<label class="label" for="passwordValidation">{{ $t('user.auth.passwordRepeat') }}</label>
<div class="control">
<input
class="input"
id="passwordValidation"
name="passwordValidation"
:placeholder="$t('user.auth.passwordPlaceholder')"
required
type="password"
autocomplete="new-password"
v-model="passwordValidation"
@keyup.enter="submit"
/>
</div>
</div> </div>
<div class="field is-grouped">
<div class="control">
<x-button <x-button
:loading="loading" :loading="loading"
id="register-submit" id="register-submit"
@click="submit" @click="submit"
class="mr-2" class="mr-2"
:disabled="!everythingValid"
> >
{{ $t('user.auth.register') }} {{ $t('user.auth.createAccount') }}
</x-button> </x-button>
<x-button :to="{ name: 'user.login' }" variant="secondary"> <p class="mt-2">
{{ $t('user.auth.alreadyHaveAnAccount') }}
<router-link :to="{ name: 'user.login' }">
{{ $t('user.auth.login') }} {{ $t('user.auth.login') }}
</x-button> </router-link>
</div> </p>
</div>
</form> </form>
</div> </div>
</template> </template>
<script setup> <script setup>
import {useDebounceFn} from '@vueuse/core'
import {ref, reactive, toRaw, computed, onBeforeMount} from 'vue' import {ref, reactive, toRaw, computed, onBeforeMount} from 'vue'
import {useI18n} from 'vue-i18n'
import router from '@/router' import router from '@/router'
import {store} from '@/store' import {store} from '@/store'
import Message from '@/components/misc/message' import Message from '@/components/misc/message'
import {isEmail} from '@/helpers/isEmail'
import Password from '@/components/input/password'
// FIXME: use the `beforeEnter` hook of vue-router // FIXME: use the `beforeEnter` hook of vue-router
// Check if the user is already logged in, if so, redirect them to the homepage // Check if the user is already logged in, if so, redirect them to the homepage
@ -104,27 +86,45 @@ onBeforeMount(() => {
} }
}) })
const {t} = useI18n()
const credentials = reactive({ const credentials = reactive({
username: '', username: '',
email: '', email: '',
password: '', password: '',
}) })
const passwordValidation = ref('')
const loading = computed(() => store.state.loading) const loading = computed(() => store.state.loading)
const errorMessage = ref('') const errorMessage = ref('')
const validatePasswordInitially = ref(false)
const DEBOUNCE_TIME = 100
// debouncing to prevent error messages when clicking on the log in button
const emailValid = ref(true)
const validateEmail = useDebounceFn(() => {
emailValid.value = isEmail(credentials.email)
}, DEBOUNCE_TIME)
const usernameValid = ref(true)
const validateUsername = useDebounceFn(() => {
usernameValid.value = credentials.username !== ''
}, DEBOUNCE_TIME)
const everythingValid = computed(() => {
return credentials.username !== '' &&
credentials.email !== '' &&
credentials.password !== '' &&
emailValid.value &&
usernameValid.value
})
async function submit() { async function submit() {
errorMessage.value = '' errorMessage.value = ''
validatePasswordInitially.value = true
if (credentials.password !== passwordValidation.value) { if (!everythingValid.value) {
errorMessage.value = t('user.auth.passwordsDontMatch')
return return
} }
try { try {
await store.dispatch('auth/register', toRaw(credentials)) await store.dispatch('auth/register', toRaw(credentials))
} catch (e) { } catch (e) {

View file

@ -1,9 +1,9 @@
<template> <template>
<div> <div>
<message variant="danger" v-if="errorMsg"> <message variant="danger" v-if="errorMsg" class="mb-4">
{{ errorMsg }} {{ errorMsg }}
</message> </message>
<div class="has-text-centered" v-if="isSuccess"> <div class="has-text-centered mb-4" v-if="isSuccess">
<message variant="success"> <message variant="success">
{{ $t('user.auth.resetPasswordSuccess') }} {{ $t('user.auth.resetPasswordSuccess') }}
</message> </message>