<template lang="pug"> .auth-login //- ----------------------------------------------------- //- LOGIN SCREEN //- ----------------------------------------------------- template(v-if='state.screen === `login`') template(v-if='state.strategies?.length > 1') p {{t('auth.selectAuthProvider')}} .auth-strategies.q-mb-md q-btn( v-for='str of state.strategies' :label='str.activeStrategy.displayName' :icon='`img:` + str.activeStrategy.strategy.icon' push no-caps :color='str.id === state.selectedStrategyId ? `primary` : ($q.dark.isActive ? `blue-grey-9` : `grey-1`)' :text-color='str.id === state.selectedStrategyId || $q.dark.isActive ? `white` : `blue-grey-9`' @click='state.selectedStrategyId = str.id' ) q-form(ref='loginForm', @submit='login') q-input( ref='loginEmailIpt' v-model='state.username' autofocus outlined :label='t(`auth.fields.` + (selectedStrategy.activeStrategy?.strategy?.usernameType ?? `email`))' :rules='selectedStrategy.activeStrategy?.strategy?.usernameType === `username` ? loginUsernameValidation : userEmailValidation' lazy-rules='ondemand' hide-bottom-space :autocomplete='selectedStrategy.activeStrategy?.strategy?.usernameType ?? `email`' ) template(#prepend) i.las.la-user q-input.q-mt-sm( v-model='state.password' outlined :label='t(`auth.fields.password`)' :rules='loginPasswordValidation' lazy-rules='ondemand' hide-bottom-space type='password' autocomplete='current-password' ) template(#prepend) i.las.la-key q-btn.full-width.q-mt-sm( type='submit' push color='primary' :label='t(`auth.actions.login`)' no-caps icon='las la-sign-in-alt' ) template(v-if='selectedStrategy.activeStrategy?.strategy?.key === `local`') q-separator.q-my-md q-btn.acrylic-btn.full-width.q-mb-sm( v-if='selectedStrategy.activeStrategy.registration' flat color='primary' :label='t(`auth.switchToRegister.link`)' no-caps icon='las la-user-plus' @click='switchTo(`register`)' ) q-btn.acrylic-btn.full-width( flat color='primary' :label='t(`auth.forgotPasswordLink`)' no-caps icon='las la-life-ring' @click='switchTo(`forgot`)' ) //- ----------------------------------------------------- //- FORGOT PASSWORD SCREEN //- ----------------------------------------------------- template(v-else-if='state.screen === `forgot`') p {{t('auth.forgotPasswordSubtitle')}} q-form(ref='forgotForm', @submit='forgotPassword') q-input( ref='forgotEmailIpt' v-model='state.username' outlined :rules='userEmailValidation' lazy-rules='ondemand' hide-bottom-space :label='t(`auth.fields.email`)' autocomplete='email' ) template(#prepend) i.las.la-envelope q-btn.full-width.q-mt-sm( type='submit' push color='primary' :label='t(`auth.sendResetPassword`)' no-caps icon='las la-life-ring' ) q-separator.q-my-md q-btn.acrylic-btn.full-width( flat color='primary' :label='t(`auth.forgotPasswordCancel`)' no-caps icon='las la-arrow-circle-left' @click='switchTo(`login`)' ) //- ----------------------------------------------------- //- REGISTER SCREEN //- ----------------------------------------------------- template(v-else-if='state.screen === `register`') p {{t('auth.registerSubTitle')}} q-form(ref='registerForm', @submit='register') q-input( ref='registerNameIpt' v-model='state.newName' outlined :rules='userNameValidation' lazy-rules='ondemand' hide-bottom-space :label='t(`auth.fields.name`)' autocomplete='name' ) template(#prepend) i.las.la-user-circle q-input.q-mt-sm( type='email' v-model='state.newEmail' outlined :rules='userEmailValidation' lazy-rules='ondemand' hide-bottom-space :label='t(`auth.fields.email`)' autocomplete='email' ) template(#prepend) i.las.la-envelope q-input.q-mt-sm( v-model='state.newPassword' outlined :label='t(`auth.fields.password`)' type='password' autocomplete='new-password' :rules='userPasswordValidation' hide-bottom-space lazy-rules='ondemand' ) template(#append) q-badge( v-show='state.newPassword' :color='passwordStrength.color' :label='passwordStrength.label' ) template(#prepend) i.las.la-key q-input.q-mt-sm( v-model='state.newPasswordVerify' outlined :label='t(`auth.fields.verifyPassword`)' type='password' autocomplete='new-password' :rules='userPasswordVerifyValidation' hide-bottom-space lazy-rules='ondemand' ) template(#prepend) i.las.la-key q-btn.full-width.q-mt-sm( type='submit' push color='primary' :label='t(`auth.actions.register`)' no-caps icon='las la-user-plus' ) q-separator.q-my-md q-btn.acrylic-btn.full-width( flat color='primary' :label='t(`auth.switchToLogin.link`)' no-caps icon='las la-arrow-circle-left' @click='switchTo(`login`)' ) //- ----------------------------------------------------- //- CHANGE PASSWORD SCREEN //- ----------------------------------------------------- template(v-else-if='state.screen === `changePwd`') p(v-if='state.continuationToken') {{t('auth.changePwd.instructions')}} q-form(ref='changePwdForm', @submit='changePwd') q-input( v-if='!state.continuationToken' ref='changePwdCurrentIpt' v-model='state.password' outlined type='password' :rules='loginPasswordValidation' lazy-rules='ondemand' hide-bottom-space :label='t(`auth.changePwd.currentPassword`)' autocomplete='password' ) template(#prepend) i.las.la-key q-input.q-mt-sm( ref='changePwdNewPwdIpt' v-model='state.newPassword' outlined :label='t(`auth.changePwd.newPassword`)' type='password' autocomplete='new-password' :rules='userPasswordValidation' hide-bottom-space lazy-rules='ondemand' ) template(#append) q-badge( v-show='state.newPassword' :color='passwordStrength.color' :label='passwordStrength.label' ) template(#prepend) i.las.la-key q-input.q-mt-sm( v-model='state.newPasswordVerify' outlined :label='t(`auth.changePwd.newPasswordVerify`)' type='password' autocomplete='new-password' :rules='userPasswordVerifyValidation' hide-bottom-space lazy-rules='ondemand' ) template(#prepend) i.las.la-key q-btn.full-width.q-mt-sm( type='submit' push color='primary' :label='t(`auth.changePwd.proceed`)' no-caps icon='las la-sync-alt' ) //- ----------------------------------------------------- //- TFA SCREEN //- ----------------------------------------------------- template(v-else-if='state.screen === `tfa`') p {{t('auth.tfa.subtitle')}} v-otp-input( v-model:value='state.securityCode' :num-inputs='6' :should-auto-focus='true' input-classes='otp-input' input-type='number' separator='' @on-complete='verifyTFA' ) q-btn.full-width.q-mt-md( push color='primary' :label='t(`auth.tfa.verifyToken`)' no-caps icon='las la-sign-in-alt' @click='verifyTFA' ) //- ----------------------------------------------------- //- TFA SETUP SCREEN //- ----------------------------------------------------- template(v-else-if='state.screen === `tfasetup`') p {{t('auth.tfaSetupTitle')}} p {{t('auth.tfaSetupInstrFirst')}} div(style='justify-content: center; display: flex;') div(v-html='state.tfaQRImage', style='width: 200px;') p.q-mt-sm {{t('auth.tfaSetupInstrSecond')}} v-otp-input( v-model:value='state.securityCode' :num-inputs='6' :should-auto-focus='true' input-classes='otp-input' input-type='number' separator='' ) q-btn.full-width.q-mt-md( push color='primary' :label='t(`auth.tfa.verifyToken`)' no-caps icon='las la-sign-in-alt' @click='finishSetupTFA' ) </template> <script setup> import gql from 'graphql-tag' import { find } from 'lodash-es' import Cookies from 'js-cookie' import zxcvbn from 'zxcvbn' import { useI18n } from 'vue-i18n' import { useQuasar } from 'quasar' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' import { useSiteStore } from 'src/stores/site' import { useUserStore } from 'src/stores/user' import VOtpInput from 'vue3-otp-input' // QUASAR const $q = useQuasar() // STORES const siteStore = useSiteStore() const userStore = useUserStore() // I18N const { t } = useI18n() // DATA const state = reactive({ strategies: [], selectedStrategyId: null, screen: 'login', username: '', password: '', securityCode: '', continuationToken: '', newName: '', newEmail: '', newPassword: '', newPasswordVerify: '', isTFAShown: false, isTFASetupShown: false, tfaQRImage: '' }) // REFS const loginEmailIpt = ref(null) const forgotEmailIpt = ref(null) const registerNameIpt = ref(null) const changePwdCurrentIpt = ref(null) const changePwdNewPwdIpt = ref(null) const loginForm = ref(null) const forgotForm = ref(null) const registerForm = ref(null) const changePwdForm = ref(null) // COMPUTED const selectedStrategy = computed(() => { return (state.selectedStrategyId && find(state.strategies, ['id', state.selectedStrategyId])) || {} }) const passwordStrength = computed(() => { if (state.newPassword.length < 8) { return { color: 'negative', label: t('common.password.weak') } } else { switch (zxcvbn(state.newPassword).score) { case 1: return { color: 'deep-orange-7', label: t('common.password.poor') } case 2: return { color: 'purple-7', label: t('common.password.average') } case 3: return { color: 'blue-7', label: t('common.password.good') } case 4: return { color: 'green-7', label: t('common.password.strong') } default: return { color: 'negative', label: t('common.password.weak') } } } }) // VALIDATION RULES const loginUsernameValidation = [ val => val.length > 0 || t('auth.errors.missingUsername') ] const loginPasswordValidation = [ val => val.length > 0 || t('auth.errors.missingPassword') ] const userNameValidation = [ val => val.length > 0 || t('auth.errors.missingName'), val => /^[^<>"]+$/.test(val) || t('auth.errors.invalidName') ] const userEmailValidation = [ val => val.length > 0 || t('auth.errors.missingEmail'), val => /^.+@.+\..+$/.test(val) || t('auth.errors.invalidEmail') ] const userPasswordValidation = [ val => val.length > 0 || t('auth.errors.missingPassword'), val => val.length >= 8 || t('auth.errors.passwordTooShort') ] const userPasswordVerifyValidation = [ val => val.length > 0 || t('auth.errors.missingVerifyPassword'), val => val === state.newPassword || t('auth.errors.passwordsNotMatch') ] // METHODS function switchTo (screen) { switch (screen) { case 'login': { state.screen = 'login' nextTick(() => { loginEmailIpt.value.focus() }) break } case 'forgot': { state.screen = 'forgot' nextTick(() => { forgotEmailIpt.value.focus() }) break } case 'register': { state.screen = 'register' nextTick(() => { registerNameIpt.value.focus() }) break } default: { throw new Error('Invalid Screen') } } } async function fetchStrategies (showAll = false) { const resp = await APOLLO_CLIENT.query({ query: gql` query loginFetchSiteStrategies( $siteId: UUID! $visibleOnly: Boolean ) { authSiteStrategies( siteId: $siteId visibleOnly: $visibleOnly ) { id activeStrategy { id displayName strategy { key color icon useForm usernameType } registration } } } `, variables: { siteId: siteStore.id, visibleOnly: !showAll } }) state.strategies = resp.data?.authSiteStrategies ?? [] state.selectedStrategyId = state.strategies[0].id } async function handleLoginResponse (resp) { state.continuationToken = resp.continuationToken switch (resp.nextAction) { case 'changePassword': { state.screen = 'changePwd' nextTick(() => { if (state.continuationToken) { changePwdNewPwdIpt.value.focus() } else { changePwdCurrentIpt.value.focus() } }) $q.loading.hide() break } case 'provideTfa': { state.securityCode = '' state.screen = 'tfa' $q.loading.hide() break } case 'setupTfa': { state.securityCode = '' state.screen = 'tfasetup' state.tfaQRImage = resp.tfaQRImage $q.loading.hide() break } case 'redirect': { $q.loading.show({ message: t('auth.loginSuccess') }) Cookies.set('jwt', resp.jwt, { expires: 365, path: '/', sameSite: 'Lax' }) setTimeout(() => { const loginRedirect = Cookies.get('loginRedirect') if (loginRedirect === '/' && resp.redirect) { Cookies.remove('loginRedirect') window.location.replace(resp.redirect) } else if (loginRedirect) { Cookies.remove('loginRedirect') window.location.replace(loginRedirect) } else if (resp.redirect) { window.location.replace(resp.redirect) } else { window.location.replace('/') } }, 1000) break } default: { $q.loading.hide() $q.notify({ type: 'negative', message: 'Unexpected Authentication Response' }) } } } /** * LOGIN */ async function login () { $q.loading.show({ message: t('auth.signingIn') }) try { const isFormValid = await loginForm.value.validate(true) if (!isFormValid) { throw new Error(t('auth.errors.login')) } const resp = await APOLLO_CLIENT.mutate({ mutation: gql` mutation( $username: String! $password: String! $strategyId: UUID! $siteId: UUID! ) { login( username: $username password: $password strategyId: $strategyId siteId: $siteId ) { operation { succeeded message } jwt nextAction continuationToken redirect tfaQRImage } } `, variables: { username: state.username, password: state.password, strategyId: state.selectedStrategyId, siteId: siteStore.id } }) if (resp.data?.login?.operation?.succeeded) { state.password = '' await handleLoginResponse(resp.data.login) } else { throw new Error(resp.data?.login?.operation?.message || t('auth.errors.loginError')) } } catch (err) { $q.loading.hide() $q.notify({ type: 'negative', message: err.message }) } } /** * FORGOT PASSWORD */ async function forgotPassword () { try { const isFormValid = await forgotForm.value.validate(true) if (!isFormValid) { throw new Error(t('auth.errors.forgotPassword')) } // TODO: Implement forgot password $q.notify({ type: 'negative', message: 'Not implemented yet.' }) } catch (err) { $q.notify({ type: 'negative', message: err.message }) } } /** * REGISTER */ async function register () { try { const isFormValid = await registerForm.value.validate(true) if (!isFormValid) { throw new Error(t('auth.errors.register')) } const resp = await APOLLO_CLIENT.mutate({ mutation: gql` mutation( $email: String! $password: String! $name: String! ) { register( email: $email password: $password name: $name ) { operation { succeeded message } jwt nextAction continuationToken redirect tfaQRImage } } `, variables: { email: state.newEmail, password: state.newPassword, name: state.newName } }) if (resp.data?.register?.operation?.succeeded) { state.password = '' state.newPassword = '' state.newPasswordVerify = '' await handleLoginResponse(resp.data.register) } else { throw new Error(resp.data?.register?.operation?.message || t('auth.errors.registerError')) } } catch (err) { $q.notify({ type: 'negative', message: err.message }) } } /** * CHANGE PASSWORD */ async function changePwd () { try { const isFormValid = await changePwdForm.value.validate(true) if (!isFormValid) { throw new Error(t('auth.errors.register')) } const resp = await APOLLO_CLIENT.mutate({ mutation: gql` mutation ( $continuationToken: String $newPassword: String! $strategyId: UUID! $siteId: UUID! ) { changePassword ( continuationToken: $continuationToken newPassword: $newPassword strategyId: $strategyId siteId: $siteId ) { operation { succeeded message } jwt nextAction continuationToken redirect tfaQRImage } } `, variables: { continuationToken: state.continuationToken, newPassword: state.newPassword, strategyId: state.selectedStrategyId, siteId: siteStore.id } }) if (resp.data?.changePassword?.operation?.succeeded) { state.password = '' $q.notify({ type: 'positive', message: t('auth.changePwd.success') }) await handleLoginResponse(resp.data.changePassword) } else { throw new Error(resp.data?.changePassword?.operation?.message || t('auth.errors.loginError')) } } catch (err) { $q.notify({ type: 'negative', message: err.message }) } } /** * VERIFY TFA TOKEN */ async function verifyTFA () { $q.loading.show({ message: t('auth.signingIn') }) try { if (!/^[0-9]{6}$/.test(state.securityCode)) { throw new Error(t('auth.errors.tfaMissing')) } const resp = await APOLLO_CLIENT.mutate({ mutation: gql` mutation( $continuationToken: String! $securityCode: String! $strategyId: UUID! $siteId: UUID! ) { loginTFA( continuationToken: $continuationToken securityCode: $securityCode strategyId: $strategyId siteId: $siteId ) { operation { succeeded message } jwt nextAction continuationToken redirect tfaQRImage } } `, variables: { continuationToken: state.continuationToken, securityCode: state.securityCode, strategyId: state.selectedStrategyId, siteId: siteStore.id } }) if (resp.data?.loginTFA?.operation?.succeeded) { state.continuationToken = '' state.securityCode = '' await handleLoginResponse(resp.data.loginTFA) } else { throw new Error(resp.data?.loginTFA?.operation?.message || t('auth.errors.loginError')) } } catch (err) { $q.loading.hide() $q.notify({ type: 'negative', message: err.message }) } } /** * FINISH TFA SETUP */ async function finishSetupTFA () { $q.loading.show({ message: t('auth.tfaSetupVerifying') }) try { if (!/^[0-9]{6}$/.test(state.securityCode)) { throw new Error(t('auth.errors.tfaMissing')) } const resp = await APOLLO_CLIENT.mutate({ mutation: gql` mutation( $continuationToken: String! $securityCode: String! $strategyId: UUID! $siteId: UUID! ) { loginTFA( continuationToken: $continuationToken securityCode: $securityCode strategyId: $strategyId siteId: $siteId setup: true ) { operation { succeeded message } jwt nextAction continuationToken redirect tfaQRImage } } `, variables: { continuationToken: state.continuationToken, securityCode: state.securityCode, strategyId: state.selectedStrategyId, siteId: siteStore.id } }) if (resp.data?.loginTFA?.operation?.succeeded) { state.continuationToken = '' state.securityCode = '' $q.notify({ type: 'positive', message: t('auth.tfaSetupSuccess') }) await handleLoginResponse(resp.data.loginTFA) } else { throw new Error(resp.data?.loginTFA?.operation?.message || t('auth.errors.loginError')) } } catch (err) { $q.loading.hide() $q.notify({ type: 'negative', message: err.message }) } } // MOUNTED onMounted(async () => { await fetchStrategies() }) </script> <style lang="scss"> .auth-login { .otp-input { width: 100%; height: 48px; padding: 5px; margin: 0 5px; font-size: 20px; border-radius: 6px; text-align: center; @at-root .body--light & { border: 2px solid rgba(0, 0, 0, 0.2); } @at-root .body--dark & { border: 2px solid rgba(255, 255, 255, 0.3); } &:focus-visible { outline-color: $primary; } /* Background colour of an input field with value */ &.is-complete { border-color: $positive; border-width: 2px; } &::-webkit-inner-spin-button, &::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } } } </style>