web/satellite/vuetify: add enable MFA dialog

Added enable MFA dialog and functionality.

Issue: https://github.com/storj/storj/issues/6091

Change-Id: Idf2f27937549b9bb709ddcc70ffa4611beea7b78
This commit is contained in:
Wilfred Asomani 2023-08-01 11:51:41 +00:00 committed by Storj Robot
parent f40805763e
commit 034542db8f
3 changed files with 313 additions and 3 deletions

View File

@ -0,0 +1,11 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.4423 0H23.3463C28.8835 0 31.0804 0.613723 33.2353 1.76617C35.3902 2.91861 37.0814 4.60977 38.2338 6.76466L38.3214 6.93055C39.4029 9.00672 39.9845 11.2 40 16.4423V23.3463C40 28.8835 39.3862 31.0805 38.2338 33.2353C37.0814 35.3902 35.3902 37.0814 33.2353 38.2338L33.0694 38.3214C30.9933 39.4029 28.8 39.9846 23.5576 40H16.6536C11.1165 40 8.91953 39.3863 6.76465 38.2338C4.60977 37.0814 2.91861 35.3902 1.76617 33.2353L1.67858 33.0694C0.597073 30.9933 0.0154218 28.8 0 23.5577V16.6537C0 11.1165 0.613723 8.91954 1.76617 6.76466C2.91861 4.60977 4.60977 2.91861 6.76465 1.76617L6.93055 1.67858C9.00671 0.597074 11.2 0.0154219 16.4423 0Z" fill="#0218A7"/>
<path d="M10.2479 20.4955C14.4471 20.4955 17.8513 17.0914 17.8513 12.8923C17.8513 8.69314 14.4471 5.28906 10.2479 5.28906C6.04867 5.28906 2.64453 8.69314 2.64453 12.8923C2.64453 17.0914 6.04867 20.4955 10.2479 20.4955Z" fill="#FFC600"/>
<path d="M30.0826 29.4215C34.2818 29.4215 37.6859 26.0174 37.6859 21.8182C37.6859 17.619 34.2818 14.2148 30.0826 14.2148C25.8834 14.2148 22.4792 17.619 22.4792 21.8182C22.4792 26.0174 25.8834 29.4215 30.0826 29.4215Z" fill="#0149FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0826 3.30566C15.6512 3.30566 20.1654 7.74581 20.1654 13.223C20.1654 18.7002 15.6512 23.1403 10.0826 23.1403C4.84971 23.1403 0.547868 19.2193 0.0483398 14.2013C0.124912 12.445 0.291563 11.1089 0.543965 10.0016C1.90343 6.10568 5.66059 3.30566 10.0826 3.30566ZM10.0826 6.28086C6.14637 6.28086 2.97514 9.40007 2.97514 13.223C2.97514 17.0459 6.14637 20.1651 10.0826 20.1651C14.0189 20.1651 17.1901 17.0459 17.1901 13.223C17.1901 9.40007 14.0189 6.28086 10.0826 6.28086Z" fill="#FF458B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1653 13.2231V28.4296L0.266601 28.4299C0.096082 27.1471 0.00594658 25.579 0 23.5576V16.6536C0 15.3355 0.0347771 14.2067 0.103319 13.2227L20.1653 13.2231Z" fill="#0149FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.248 17.1899C11.3434 17.1899 12.2315 18.0191 12.2315 19.0419C12.2315 19.8161 11.7227 20.4794 11.0003 20.7561L11.0003 22.8098H9.49558L9.49561 20.7561C8.77319 20.4794 8.2644 19.8161 8.2644 19.0419C8.2644 18.0191 9.15247 17.1899 10.248 17.1899Z" fill="#0218A7"/>
<path d="M30.0826 11.9009C35.5598 11.9009 40 16.341 40 21.8181C40 27.2952 35.5598 31.7353 30.0826 31.7353C24.6054 31.7353 20.1653 27.2952 20.1653 21.8181C20.1653 16.341 24.6054 11.9009 30.0826 11.9009ZM30.0826 14.876C26.2486 14.876 23.1405 17.9841 23.1405 21.8181C23.1405 25.6521 26.2486 28.7602 30.0826 28.7602C33.9167 28.7602 37.0248 25.6521 37.0248 21.8181C37.0248 17.9841 33.9167 14.876 30.0826 14.876Z" fill="#FFC600"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40 21.8184V23.3465C40 28.8836 39.3863 31.0806 38.2338 33.2354C37.4345 34.7301 36.3759 36.0016 35.0833 37.025L20.1653 37.0248V21.8184H40Z" fill="#FF458B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.2479 25.7847C31.2521 25.7847 32.0662 26.6138 32.0662 27.6367C32.0662 28.4108 31.5999 29.0739 30.9378 29.3507L30.9376 31.4045H29.5582L29.5581 29.3507C28.896 29.0739 28.4297 28.4108 28.4297 27.6367C28.4297 26.6138 29.2437 25.7847 30.2479 25.7847Z" fill="#0218A7"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,279 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<v-dialog
v-model="model"
width="auto"
min-width="320px"
max-width="460px"
transition="fade-transition"
>
<v-card ref="innerContent" rounded="xlg">
<v-card-item class="pl-7 pr-0 pb-5 pt-0">
<v-row align="start" justify="space-between" class="ma-0">
<v-row align="center" class="ma-0 pt-5">
<img class="flex-shrink-0" src="@poc/assets/icon-mfa.svg" alt="MFA">
<v-card-title class="font-weight-bold ml-4">Setup Two-Factor</v-card-title>
</v-row>
<v-btn
icon="$close"
variant="text"
size="small"
color="default"
:disabled="isLoading"
@click="closeDialog"
/>
</v-row>
</v-card-item>
<v-divider class="mx-8" />
<v-window v-model="step" :class="{ 'overflow-y-auto': step === 0 }">
<!-- QR code step -->
<v-window-item :value="0">
<v-card-item class="px-8 py-4">
<p>Scan this QR code in your two-factor application.</p>
</v-card-item>
<v-card-item align="center" justify="center" class="rounded-lg border mx-8 py-4" style="background: #edeef1;">
<v-col cols="auto">
<canvas ref="canvas" />
</v-col>
</v-card-item>
<v-divider class="mx-8 my-4" />
<v-card-item class="px-8 py-4 pt-0">
<p>Unable to scan? Enter the following code instead.</p>
</v-card-item>
<v-card-item class="rounded-lg border mx-8 pa-0" style="background: #FAFAFB;">
<v-col class="py-2 px-3" cols="auto">
<p class="font-weight-bold"> {{ userMFASecret }}</p>
</v-col>
</v-card-item>
</v-window-item>
<!-- Enter code step -->
<v-window-item :value="1">
<v-card-item class="px-8 py-4">
<p>Enter the authentication code generated in your two-factor application to confirm your setup.</p>
</v-card-item>
<v-divider class="mx-8" />
<v-card-item class="px-8 pt-4 pb-0">
<v-form v-model="formValid" class="pt-1" :onsubmit="enable">
<v-text-field
v-model="confirmPasscode"
variant="outlined"
density="compact"
hint="e.g.: 000000"
:rules="rules"
:error-messages="isError ? 'Invalid code. Please re-enter.' : ''"
label="2FA Code"
required
autofocus
/>
</v-form>
</v-card-item>
</v-window-item>
<!-- Save codes step -->
<v-window-item :value="2">
<v-card-item class="px-8 py-4">
<p>Please save these codes somewhere to be able to recover access to your account.</p>
</v-card-item>
<v-divider class="mx-8" />
<v-card-item class="px-8 py-4">
<p
v-for="(code, index) in userMFARecoveryCodes"
:key="index"
>
{{ code }}
</p>
</v-card-item>
</v-window-item>
</v-window>
<v-divider class="mx-8 my-4" />
<v-card-actions dense class="px-7 pb-5 pt-0">
<v-col v-if="step !== 2" class="pl-0">
<v-btn
variant="outlined"
color="default"
block
:disabled="isLoading"
:loading="isLoading"
@click="closeDialog"
>
Cancel
</v-btn>
</v-col>
<v-col class="px-0">
<v-btn
v-if="step === 0"
color="primary"
variant="flat"
block
:loading="isLoading"
@click="step++"
>
Continue
</v-btn>
<v-btn
v-else-if="step === 1"
color="primary"
variant="flat"
block
:loading="isLoading"
:disabled="!formValid"
@click="enable"
>
Enable
</v-btn>
<v-btn
v-else
color="primary"
variant="flat"
block
@click="closeDialog"
>
Done
</v-btn>
</v-col>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { Component, computed, ref, watch } from 'vue';
import {
VBtn,
VCard,
VCardActions,
VCardItem,
VCardTitle,
VCol,
VDialog,
VDivider,
VForm,
VRow,
VTextField,
VWindow,
VWindowItem,
} from 'vuetify/components';
import QRCode from 'qrcode';
import { useLoading } from '@/composables/useLoading';
import { useConfigStore } from '@/store/modules/configStore';
import { useUsersStore } from '@/store/modules/usersStore';
const rules = [
(value: string) => (!!value || 'Can\'t be empty'),
(value: string) => (!value.includes(' ') || 'Can\'t contain spaces'),
(value: string) => (!!parseInt(value) || 'Can only be numbers'),
(value: string) => (value.length === 6 || 'Can only be 6 numbers long'),
];
const { config } = useConfigStore().state;
const usersStore = useUsersStore();
const { isLoading, withLoading } = useLoading();
const canvas = ref<HTMLCanvasElement>();
const innerContent = ref<Component | null>(null);
const props = defineProps<{
modelValue: boolean,
}>();
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void,
}>();
const step = ref<number>(0);
const confirmPasscode = ref<string>('');
const isError = ref<boolean>(false);
const formValid = ref<boolean>(false);
const model = computed<boolean>({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
});
/**
* Returns pre-generated MFA secret from store.
*/
const userMFASecret = computed((): string => {
return usersStore.state.userMFASecret;
});
/**
* Returns user MFA recovery codes from store.
*/
const userMFARecoveryCodes = computed((): string[] => {
return usersStore.state.userMFARecoveryCodes;
});
/**
* Returns satellite name from store.
*/
const satellite = computed((): string => {
return config.satelliteName;
});
/**
* Returns the 2FA QR link.
*/
const qrLink = computed((): string => {
return `otpauth://totp/${encodeURIComponent(usersStore.state.user.email)}?secret=${userMFASecret.value}&issuer=${encodeURIComponent(`STORJ ${satellite.value}`)}&algorithm=SHA1&digits=6&period=30`;
});
/**
* Enables user MFA and sets view to Recovery Codes state.
*/
function enable(): void {
if (!formValid.value) return;
withLoading(async () => {
try {
await usersStore.enableUserMFA(confirmPasscode.value);
await usersStore.getUser();
await showCodes();
} catch (error) {
isError.value = true;
}
});
}
/**
* Toggles view to MFA Recovery Codes state.
*/
async function showCodes() {
try {
await usersStore.generateUserMFARecoveryCodes();
step.value = 2;
} catch (error) {
/* empty */
}
}
function closeDialog() {
model.value = false;
isError.value = false;
}
watch(canvas, async val => {
if (!val) return;
try {
await QRCode.toCanvas(canvas.value, qrLink.value);
} catch (error) {
/* empty */
}
});
watch(confirmPasscode, () => {
isError.value = false;
});
watch(innerContent, newContent => {
if (newContent) return;
// dialog has been closed
step.value = 0;
confirmPasscode.value = '';
});
</script>

View File

@ -87,7 +87,8 @@
<template #append>
<v-list-item-action>
<v-btn size="small">Enable Two-factor</v-btn>
<v-btn v-if="!user.isMFAEnabled" size="small" @click="toggleEnableMFADialog">Enable Two-factor</v-btn>
<v-btn v-else variant="outlined" color="default" size="small">Disable Two-factor</v-btn>
</v-list-item-action>
</template>
</v-list-item>
@ -144,10 +145,14 @@
<ChangeNameDialog
v-model="isChangeNameDialogShown"
/>
<EnableMFADialog
v-model="isEnableMFADialogShown"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import {
VContainer,
VCard,
@ -162,16 +167,18 @@ import {
VCheckboxBtn,
} from 'vuetify/components';
import { useUsersStore } from '@/store/modules/usersStore';
import { User } from '@/types/users';
import { useUsersStore } from '@/store/modules/usersStore';
import ChangePasswordDialog from '@poc/components/dialogs/ChangePasswordDialog.vue';
import ChangeNameDialog from '@poc/components/dialogs/ChangeNameDialog.vue';
import EnableMFADialog from '@poc/components/dialogs/EnableMFADialog.vue';
const usersStore = useUsersStore();
const isChangePasswordDialogShown = ref<boolean>(false);
const isChangeNameDialogShown = ref<boolean>(false);
const isEnableMFADialogShown = ref<boolean>(false);
/**
* Returns user entity from store.
@ -179,4 +186,17 @@ const isChangeNameDialogShown = ref<boolean>(false);
const user = computed((): User => {
return usersStore.state.user;
});
async function toggleEnableMFADialog() {
try {
await usersStore.generateUserMFASecret();
isEnableMFADialogShown.value = true;
} catch (error) {
/* empty */
}
}
onMounted(() => {
usersStore.getUser();
});
</script>