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('#email').type(fixture.email)
cy.get('#password').type(fixture.password)
cy.get('#passwordValidation').type(fixture.password)
cy.get('#register-submit').click()
cy.url().should('include', '/')
cy.clock(1625656161057) // 13:00
@ -43,7 +42,6 @@ context('Registration', () => {
cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email)
cy.get('#password').type(fixture.password)
cy.get('#passwordValidation').type(fixture.password)
cy.get('#register-submit').click()
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>
<div class="message-wrapper">
<div class="message" :class="variant">
<div class="message" :class="[variant, textAlignClass]">
<slot/>
</div>
</div>
</template>
<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: {
type: String,
default: 'info',
},
textAlign: {
type: String as PropType<textAlignVariants>,
default: 'left',
},
})
const textAlignClass = computed(() => TEXT_ALIGN_MAP[props.textAlign])
</script>
<style lang="scss" scoped>

View file

@ -14,6 +14,9 @@
<div>
<h2 class="title" v-if="title">{{ title }}</h2>
<api-config/>
<Message v-if="motd !== ''" class="is-hidden-tablet mb-4">
{{ motd }}
</Message>
<slot/>
</div>
<legal/>
@ -38,8 +41,8 @@ const store = useStore()
const {t} = useI18n()
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)
</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",
"usernameEmail": "Username Or Email Address",
"usernamePlaceholder": "e.g. frederick",
"email": "E-mail address",
"email": "Email address",
"emailPlaceholder": "e.g. frederic{'@'}vikunja.io",
"password": "Password",
"passwordRepeat": "Retype your password",
"passwordPlaceholder": "e.g. •••••••••••",
"forgotPassword": "Forgot your password?",
"resetPassword": "Reset your password",
@ -45,12 +44,19 @@
"totpTitle": "Two Factor Authentication Code",
"totpPlaceholder": "e.g. 123456",
"login": "Login",
"register": "Register",
"createAccount": "Create account",
"loginWith": "Log in with {provider}",
"authenticating": "Authenticating…",
"openIdStateError": "State does not match, refusing to continue!",
"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": {
"title": "Settings",
@ -61,7 +67,7 @@
"currentPasswordPlaceholder": "Your current password",
"passwordsDontMatch": "The new password and its confirmation don't match.",
"passwordUpdateSuccess": "The password was successfully updated.",
"updateEmailTitle": "Update Your E-Mail Address",
"updateEmailTitle": "Update Your Email Address",
"updateEmailNew": "New Email Address",
"updateEmailSuccess": "Your email address was successfully updated. We've sent you a link to confirm it.",
"general": {

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
<template>
<div>
<message variant="danger" v-if="errorMessage !== ''">
<message variant="danger" v-if="errorMessage !== ''" class="mb-4">
{{ errorMessage }}
</message>
<form @submit.prevent="submit" id="registerform">
@ -18,8 +18,12 @@
v-focus
v-model="credentials.username"
@keyup.enter="submit"
@focusout="validateUsername"
/>
</div>
<p class="help is-danger" v-if="!usernameValid">
{{ $t('user.auth.usernameRequired') }}
</p>
</div>
<div class="field">
<label class="label" for="email">{{ $t('user.auth.email') }}</label>
@ -33,68 +37,46 @@
type="email"
v-model="credentials.email"
@keyup.enter="submit"
@focusout="validateEmail"
/>
</div>
<p class="help is-danger" v-if="!emailValid">
{{ $t('user.auth.emailInvalid') }}
</p>
</div>
<div class="field">
<label class="label" for="password">{{ $t('user.auth.password') }}</label>
<div class="control">
<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>
<password @submit="submit" @update:modelValue="v => credentials.password = v" :validate-initially="validatePasswordInitially"/>
</div>
<div class="field is-grouped">
<div class="control">
<x-button
:loading="loading"
id="register-submit"
@click="submit"
class="mr-2"
:disabled="!everythingValid"
>
{{ $t('user.auth.register') }}
{{ $t('user.auth.createAccount') }}
</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') }}
</x-button>
</div>
</div>
</router-link>
</p>
</form>
</div>
</template>
<script setup>
import {useDebounceFn} from '@vueuse/core'
import {ref, reactive, toRaw, computed, onBeforeMount} from 'vue'
import {useI18n} from 'vue-i18n'
import router from '@/router'
import {store} from '@/store'
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
// 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({
username: '',
email: '',
password: '',
})
const passwordValidation = ref('')
const loading = computed(() => store.state.loading)
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() {
errorMessage.value = ''
validatePasswordInitially.value = true
if (credentials.password !== passwordValidation.value) {
errorMessage.value = t('user.auth.passwordsDontMatch')
if (!everythingValid.value) {
return
}
try {
await store.dispatch('auth/register', toRaw(credentials))
} catch (e) {

View file

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