web/satellite/vuetify-poc: add login functionality

This change implements login functionality in the vuetify app.

Issue: #6470

Change-Id: I6079888af14ded6d4886b3fc16108ca410f52982
This commit is contained in:
Wilfred Asomani 2023-11-02 17:06:29 +00:00
parent fd55dad735
commit 14b83bb390
5 changed files with 408 additions and 113 deletions

View File

@ -36,16 +36,6 @@ const routes: RouteRecordRaw[] = [
name: 'Login',
component: () => import(/* webpackChunkName: "Login" */ '@poc/views/Login.vue'),
},
{
path: '/login-2fa',
name: 'Login 2FA',
component: () => import(/* webpackChunkName: "Login 2FA" */ '@poc/views/Login2FA.vue'),
},
{
path: '/login-2fa-recovery',
name: 'Login 2FA Recovery Code',
component: () => import(/* webpackChunkName: "Login 2FA Recovery Code" */ '@poc/views/Login2FARecovery.vue'),
},
{
path: '/signup',
name: 'Signup',

View File

@ -1,11 +1,17 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
import { Validator } from '@/utils/validation';
export type ValidationRule<T> = string | boolean | ((value: T) => string | boolean);
export function RequiredRule(value: unknown): string | boolean {
return (Array.isArray(value) ? !!value.length : !!value) || 'Required';
}
export function EmailRule(value: string): string | boolean {
return Validator.email(value) || 'E-mail must be valid.';
}
export interface DialogStepComponent {
title: string;
iconSrc?: string;

View File

@ -5,66 +5,122 @@
<v-container class="fill-height">
<v-row align="top" justify="center">
<v-col cols="12" sm="9" md="7" lg="5">
<v-card title="Log into your account" rounded="xlg" class="pa-2 pa-sm-7">
<v-card v-if="!isMFARequired" title="Log into your account" rounded="xlg" class="pa-2 pa-sm-7">
<v-card-item>
<v-alert
v-if="captchaError"
variant="tonal"
color="error"
text="HCaptcha is required"
rounded="lg"
density="comfortable"
border
/>
<v-alert
v-if="isActivatedBannerShown"
variant="tonal"
:color="isActivatedError ? 'error' : 'success'"
:title="isActivatedError ? 'Oops!' :'Success!'"
:text="isActivatedError ? 'This account has already been verified.' : 'Account verified.'"
rounded="lg"
density="comfortable"
border
/>
<v-alert
v-if="inviteInvalid"
variant="tonal"
color="error"
title="Oops!"
text="The invite link you used has expired or is invalid."
rounded="lg"
density="comfortable"
border
/>
<v-alert
v-if="isBadLoginMessageShown"
variant="tonal"
color="error"
title="Invalid Credentials"
text="Login failed. Please check if this is the correct satellite for your account. If you are
sure your credentials are correct, please check your email inbox for a notification with
further instructions."
rounded="lg"
density="comfortable"
border
/>
</v-card-item>
<v-card-text>
<v-form class="pt-4">
<v-form v-model="formValid" class="pt-4" @submit.prevent>
<v-select
v-model="select"
v-model="satellite"
label="Satellite"
:items="items"
:items="satellites"
item-title="satellite"
:hint="`Recommended for ${select.hint}.`"
:hint="satellite.hint"
persistent-hint
return-object
chips
class="mb-5"
>
<!-- <template v-slot:prepend-inner>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 1C12.6387 1 15.0122 2.13558 16.6584 3.94469C16.2663 3.78243 15.8365 3.69291 15.3858 3.69291C15.2442 3.69291 15.1046 3.70176 14.9676 3.71892C13.6027 2.63772 11.8767 1.99213 10 1.99213C7.56843 1.99213 5.38999 3.07588 3.92135 4.78671L6.65807 4.7867C8.12561 4.7867 9.31529 5.97638 9.31529 7.44392C9.31529 8.89649 8.14976 10.0768 6.7029 10.1008L6.65807 10.1011H5.49148C5.13354 10.1011 4.84338 10.3913 4.84338 10.7492C4.84338 11.0978 5.11847 11.382 5.46337 11.3968L5.49148 11.3974H5.71832C6.97472 11.3974 7.9959 12.4044 8.01869 13.6554L8.01908 13.7305C8.01908 15.0048 6.99757 16.0404 5.72877 16.0633L5.68591 16.0637L4.76948 16.0638C6.17266 17.2752 8.00077 18.0079 10 18.0079C11.5512 18.0079 12.9994 17.5668 14.226 16.8032L13.4719 16.8032C11.8673 16.8032 10.5664 15.5023 10.5664 13.8976C10.5664 12.3093 11.8408 11.0187 13.4229 10.9925L13.4719 10.9921H15.8814C16.2728 10.9921 16.59 10.6748 16.59 10.2835C16.59 10.2329 16.5847 10.1835 16.5747 10.136C16.8942 10.0138 17.1903 9.84377 17.4539 9.63464C17.5366 9.83469 17.5822 10.0539 17.5822 10.2835C17.5822 11.2083 16.8439 11.9608 15.9246 11.9837L15.8814 11.9843L13.4801 11.9842L13.4393 11.9845C12.3966 12.0018 11.5585 12.853 11.5585 13.8976C11.5585 14.9397 12.3916 15.7872 13.428 15.8105L13.4719 15.811H14.8362L15.6005 15.7237C17.086 14.27 18.0079 12.2427 18.0079 10C18.0079 9.70782 17.9922 9.4193 17.9617 9.13523C18.3221 8.69615 18.5725 8.1632 18.6705 7.57862C18.8852 8.34894 19 9.16106 19 10C19 14.9706 14.9706 19 10 19C5.02944 19 1 14.9706 1 10C1 5.02944 5.02944 1 10 1ZM6.65807 5.77883L3.61383 5.77893L3.59538 5.78299C3.43495 5.81918 3.27795 5.85566 3.12436 5.8924C2.40541 7.09338 1.99213 8.49842 1.99213 10C1.99213 11.9246 2.6711 13.6907 3.80259 15.0717L5.7109 15.0713C6.4279 15.0584 7.00634 14.4827 7.02643 13.7738L7.02697 13.7357L7.02673 13.6734C7.01399 12.9741 6.45254 12.4099 5.75642 12.39L5.71832 12.3895H5.49148L5.44224 12.3887L5.42106 12.388C4.54466 12.3506 3.85125 11.6287 3.85125 10.7492C3.85125 9.85731 4.56318 9.13166 5.44979 9.10954L5.49148 9.10902L6.64986 9.10905L6.68649 9.10878C7.59384 9.09377 8.32316 8.35301 8.32316 7.44392C8.32316 6.53847 7.60043 5.80181 6.70038 5.77936L6.65807 5.77883ZM15.3858 5.46457C16.2469 5.46457 16.9449 6.16258 16.9449 7.02362C16.9449 7.88466 16.2469 8.58268 15.3858 8.58268C14.5248 8.58268 13.8268 7.88466 13.8268 7.02362C13.8268 6.16258 14.5248 5.46457 15.3858 5.46457Z" fill="currentColor"/>
</svg>
</template> -->
</v-select>
/>
<v-text-field
v-model="email"
class="mb-2"
label="Email address"
placeholder="Enter your email"
name="email"
type="email"
:rules="emailRules"
flat
clearable
required
>
<!-- <template v-slot:prepend-inner>
<v-icon
icon="mdi-email-outline"
/>
</template> -->
</v-text-field>
/>
<v-text-field
v-model="password"
class="mb-2"
label="Password"
placeholder="Enter your password"
color="secondary"
:type="showPassword ? 'text' : 'password'"
:rules="passwordRules"
required
:append-inner-icon="showPassword ? 'mdi-eye-outline' : 'mdi-eye-off-outline'"
@click:append-inner="showPassword = !showPassword"
>
<!-- <template v-slot:prepend-inner>
<v-icon
icon="mdi-key-outline"
/>
</template> -->
</v-text-field>
/>
<v-btn color="primary" size="large" block router-link to="/login-2fa">
<v-btn
:disabled="!formValid"
color="primary"
size="large"
block
@click="onLoginClick"
>
Continue
</v-btn>
</v-form>
</v-card-text>
</v-card>
<p class="mt-7 text-center text-body-2">Forgot your password? <router-link class="link" to="/password-reset">Reset password</router-link></p>
<login2-f-a
v-else
v-model="useOTP"
v-model:error="isMFAError"
v-model:otp="passcode"
v-model:recovery="recoveryCode"
:loading="isLoading"
@verify="onLoginClick"
/>
<VueHcaptcha
v-if="captchaConfig.hcaptcha.enabled"
ref="hcaptcha"
:sitekey="captchaConfig.hcaptcha.siteKey"
:re-captcha-compat="false"
size="invisible"
@verify="onCaptchaVerified"
@expired="onCaptchaError"
@error="onCaptchaError"
/>
<p v-if="!isMFARequired" class="mt-7 text-center text-body-2">Forgot your password? <router-link class="link" to="/password-reset">Reset password</router-link></p>
<p class="mt-5 text-center text-body-2">Don't have an account? <router-link class="link" to="/signup">Sign Up</router-link></p>
</v-col>
</v-row>
@ -72,18 +128,213 @@
</template>
<script setup lang="ts">
import { VBtn, VCard, VCardText, VCol, VContainer, VForm, VRow, VSelect, VTextField } from 'vuetify/components';
import { ref } from 'vue';
import { VAlert, VBtn, VCard, VCardItem, VCardText, VCol, VContainer, VForm, VRow, VSelect, VTextField } from 'vuetify/components';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import { EmailRule, RequiredRule, ValidationRule } from '@poc/types/common';
import { AuthHttpApi } from '@/api/auth';
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import { useConfigStore } from '@/store/modules/configStore';
import { useAppStore } from '@/store/modules/appStore';
import { useUsersStore } from '@/store/modules/usersStore';
import { useNotify } from '@/utils/hooks';
import { MultiCaptchaConfig } from '@/types/config.gen';
import { LocalData } from '@/utils/localData';
import { TokenInfo } from '@/types/users';
import { ErrorMFARequired } from '@/api/errors/ErrorMFARequired';
import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized';
import { ErrorTooManyRequests } from '@/api/errors/ErrorTooManyRequests';
import { ErrorBadRequest } from '@/api/errors/ErrorBadRequest';
import Login2FA from '@poc/views/Login2FA.vue';
import ErrorIcon from '@/../static/images/register/ErrorInfo.svg';
const auth = new AuthHttpApi();
const analyticsStore = useAnalyticsStore();
const configStore = useConfigStore();
const appStore = useAppStore();
const usersStore = useUsersStore();
const notify = useNotify();
const router = useRouter();
const route = useRoute();
const valid = ref(false);
const checked = ref(false);
const showPassword = ref(false);
const isLoading = ref<boolean>(false);
const isBadLoginMessageShown = ref<boolean>(false);
const formValid = ref<boolean>(false);
const inviteInvalid = ref(true);
const isActivatedBannerShown = ref(false);
const isActivatedError = ref(false);
const captchaError = ref(false);
const useOTP = ref(true);
const isMFARequired = ref(false);
const isMFAError = ref(false);
const captchaResponseToken = ref('');
const email = ref('');
const password = ref('');
const select = ref({ satellite: 'US1', hint: 'North and South America' });
const items = ref([
{ satellite: 'US1', hint: 'North and South America' },
{ satellite: 'EU1', hint: 'Europe and Africa' },
{ satellite: 'AP1', hint: 'Asia and Australia' },
]);
const passcode = ref('');
const recoveryCode = ref('');
const pathEmail = ref<string | null>(null);
const returnURL = ref('/projects');
const hcaptcha = ref<VueHcaptcha | null>(null);
const satellitesHints = [
{ satellite: 'US1', hint: 'Recommended for North and South America' },
{ satellite: 'EU1', hint: 'Recommended for Europe and Africa' },
{ satellite: 'AP1', hint: 'Recommended for Asia and Australia' },
];
const passwordRules: ValidationRule<string>[] = [
RequiredRule,
];
const emailRules: ValidationRule<string>[] = [
RequiredRule,
EmailRule,
];
/**
* Name of the current satellite.
*/
const satellite = computed({
get: () => {
const satName = configStore.state.config.satelliteName;
const item = satellitesHints.find(item => item.satellite === satName);
return item ?? { satellite: satName, hint: '' };
},
set: value => {
const sats = configStore.state.config.partneredSatellites ?? [];
const satellite = sats.find(sat => sat.name === value.satellite);
if (satellite) {
window.location.href = satellite.address + '/v2/login';
}
},
});
/**
* Information about partnered satellites, including name and signup link.
*/
const satellites = computed(() => {
const satellites = configStore.state.config.partneredSatellites ?? [];
return satellites.map(satellite => {
const item = satellitesHints.find(item => item.satellite === satellite.name);
return item ?? { satellite: satellite.name, hint: '' };
});
});
/**
* This component's captcha configuration.
*/
const captchaConfig = computed((): MultiCaptchaConfig => {
return configStore.state.config.captcha.login;
});
/**
* Handles captcha verification response.
*/
function onCaptchaVerified(response: string): void {
captchaResponseToken.value = response;
captchaError.value = false;
login();
}
/**
* Handles captcha error and expiry.
*/
function onCaptchaError(): void {
captchaResponseToken.value = '';
captchaError.value = true;
}
/**
* Holds on login button click logic.
*/
async function onLoginClick(): Promise<void> {
if (isLoading.value) {
return;
}
isLoading.value = true;
if (hcaptcha.value && !captchaResponseToken.value) {
hcaptcha.value?.execute();
return;
}
await login();
}
/**
* Performs login action.
* Then changes location to project dashboard page.
*/
async function login(): Promise<void> {
if (!formValid.value) return;
try {
const tokenInfo: TokenInfo = await auth.token(email.value, password.value, captchaResponseToken.value, passcode.value, recoveryCode.value);
LocalData.setSessionExpirationDate(tokenInfo.expiresAt);
} catch (error) {
if (hcaptcha.value) {
hcaptcha.value?.reset();
captchaResponseToken.value = '';
}
if (error instanceof ErrorMFARequired) {
isMFARequired.value = true;
isLoading.value = false;
return;
}
if (isMFARequired.value && !(error instanceof ErrorTooManyRequests)) {
if (error instanceof ErrorBadRequest || error instanceof ErrorUnauthorized) {
notify.error(error.message);
}
isMFAError.value = true;
isLoading.value = false;
return;
}
if (error instanceof ErrorUnauthorized) {
isBadLoginMessageShown.value = true;
isLoading.value = false;
return;
}
notify.notifyError(error);
isLoading.value = false;
return;
}
usersStore.login();
isLoading.value = false;
analyticsStore.pageVisit(returnURL.value);
await router.push(returnURL.value);
}
/**
* Lifecycle hook after initial render.
* Makes activated banner visible on successful account activation.
*/
onMounted(() => {
inviteInvalid.value = (route.query.invite_invalid as string ?? null) === 'true';
pathEmail.value = route.query.email as string ?? null;
if (pathEmail.value) {
email.value = pathEmail.value.trim();
}
isActivatedBannerShown.value = !!route.query.activated;
isActivatedError.value = route.query.activated === 'false';
if (route.query.return_url) returnURL.value = route.query.return_url as string;
});
</script>

View File

@ -2,49 +2,127 @@
// See LICENSE for copying information.
<template>
<v-container class="fill-height">
<v-row align="top" justify="center">
<v-col cols="12" sm="10" md="7" lg="5">
<v-card title="Enter your 2FA code" class="pa-2 pa-sm-7">
<v-card-text>
<p>Enter the 6 digit code from your two factor authenticator application to continue.</p>
<v-form>
<v-card class="my-4" rounded="lg" color="secondary" variant="outlined">
<v-otp-input v-model="otp" :loading="loading" autofocus class="my-2" />
</v-card>
<v-card :title="model ? 'Enter your 2FA code' : 'Enter your recovery code'" class="pa-2 pa-sm-7">
<v-card-text v-if="model">
<p>Enter the 6 digit code from your two factor authenticator application to continue.</p>
<v-card class="my-4" rounded="lg" color="secondary" variant="outlined">
<v-otp-input
:model-value="otp"
:error="error"
:loading="loading"
autofocus
class="my-2"
@update:modelValue="value => onValueChange(value)"
/>
</v-card>
<v-btn
router-link to="/projects"
:disabled="otp.length < 6"
color="primary"
block
>
<span v-if="otp.length === 0">6 digits left</span>
<v-btn
:disabled="otp.length < 6"
color="primary"
block
@click="verifyCode()"
>
<span v-if="otp.length === 0">6 digits left</span>
<span v-else-if="otp.length < 6">
{{ 6 - otp.length }}
digits left
</span>
<span v-else-if="otp.length < 6">
{{ 6 - otp.length }}
digits left
</span>
<span v-else>
Verify
</span>
</v-btn>
</v-form>
</v-card-text>
</v-card>
<p class="pt-9 text-center text-body-2">Or use a <router-link class="link" to="/login-2fa-recovery">recovery code</router-link></p>
<p class="pt-6 text-center text-body-2">Not a member? <router-link class="link" to="/signup">Signup</router-link></p>
</v-col>
</v-row>
</v-container>
<span v-else>
Verify
</span>
</v-btn>
</v-card-text>
<v-card-text v-else>
<p>Enter one of your recovery codes to continue.</p>
<v-form v-model="formValid" @submit.prevent>
<v-text-field
:model-value="recovery"
:error="error"
:loading="loading"
:rules="[RequiredRule]"
label="Recovery Code"
class="mt-5"
required
@update:modelValue="value => onValueChange(value)"
/>
<v-btn
color="primary"
:disabled="!formValid"
size="large"
block
@click="verifyCode()"
>
Continue
</v-btn>
</v-form>
</v-card-text>
</v-card>
<p class="pt-9 text-center text-body-2">
Or use <span
class="link"
@click="model = !model"
>
{{ model ? 'a recovery code' : 'an OTP code' }}
</span>
</p>
</template>
<script setup lang="ts">
import { VBtn, VCard, VCardText, VCol, VContainer, VForm, VRow } from 'vuetify/components';
import { VBtn, VCard, VCardText, VForm, VTextField } from 'vuetify/components';
import { VOtpInput } from 'vuetify/labs/components';
import { ref } from 'vue';
import { computed, ref } from 'vue';
const loading = ref(false);
const otp = ref('');
import { RequiredRule } from '@poc/types/common';
const props = defineProps<{
modelValue: boolean;
loading: boolean;
error: boolean;
recovery: string;
otp: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'update:error': [value: boolean];
'update:recovery': [value: string];
'update:otp': [value: string];
verify: [];
}>();
const formValid = ref(false);
/**
* Whether to use the OTP code or recovery code.
*/
const model = computed<boolean>({
get: () => props.modelValue,
set: value => {
emit('update:otp', '');
emit('update:recovery', '');
emit('update:error', false);
emit('update:modelValue', value);
},
});
function verifyCode() {
emit('verify');
}
function onValueChange(value: string) {
if (model.value) {
emit('update:otp', value);
if (props.recovery) {
emit('update:recovery', '');
}
} else {
emit('update:recovery', value);
if (props.otp) {
emit('update:otp', '');
}
}
emit('update:error', false);
}
</script>

View File

@ -1,30 +0,0 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<v-container class="fill-height">
<v-row align="top" justify="center">
<v-col cols="12" sm="10" md="7" lg="5">
<v-card title="Enter your recovery code" class="pa-2 pa-sm-7">
<v-card-text>
<p>Enter one of your recovery codes to continue.</p>
<v-form>
<v-text-field
label="Recovery Code"
class="mt-5"
/>
<v-btn color="primary" size="large" block router-link to="/projects">
Continue
</v-btn>
</v-form>
</v-card-text>
</v-card>
<p class="pt-6 text-center text-body-2">Not a member? <router-link class="link" to="/signup">Signup</router-link></p>
</v-col>
</v-row>
</v-container>
</template>
<script setup lang="ts">
import { VBtn, VCard, VCardText, VCol, VContainer, VForm, VRow, VTextField } from 'vuetify/components';
</script>