web/satellite/vuetify-poc: add signup functionality

This change implements functionality for signup and account activation.

Issue: #6481

Change-Id: Ide8f743dc3996c8b2a23a494f8d8500e1af658bc
This commit is contained in:
Wilfred Asomani 2023-11-08 10:18:57 +00:00
parent 26b6ab8a3a
commit a3a7df91e3
8 changed files with 579 additions and 47 deletions

View File

@ -13,6 +13,7 @@ export abstract class RouteConfig {
public static Login = new NavigationLink('/login', 'Login');
public static Register = new NavigationLink('/signup', 'Register');
public static RegisterSuccess = new NavigationLink('/signup-success', 'RegisterSuccess');
public static RegisterConfirmation = new NavigationLink('/signup-confirmation', 'RegisterSuccess');
public static Activate = new NavigationLink('/activate', 'Activate');
public static ForgotPassword = new NavigationLink('/forgot-password', 'Forgot Password');
public static ResetPassword = new NavigationLink('/password-recovery', 'Reset Password');

View File

@ -7,9 +7,23 @@
export class Validator {
/**
* Checks string to satisfy email rules.
* @param email - email to check.
* @param strict - if true, checks for stricter email rules.
*/
public static email(email: string): boolean {
const rgx = /.*@.*\..*$/;
public static email(email: string, strict = false): boolean {
let rgx = /.*@.*\..*$/;
if (strict) {
// We'll have this email validation for new users instead of using regular Validator.email method because of backwards compatibility.
// We don't want to block old users who managed to create and verify their accounts with some weird email addresses.
// This regular expression fulfills our needs to validate international emails.
// It was built according to RFC 5322 and then extended to include international characters using these resources
// https://emailregex.com/
// https://awik.io/international-email-address-validation-javascript/
// eslint-disable-next-line no-misleading-character-class
rgx = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9\u0080-\u00FF\u0100-\u017F\u0180-\u024F\u0250-\u02AF\u0300-\u036F\u0370-\u03FF\u0400-\u04FF\u0500-\u052F\u0530-\u058F\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u0780-\u07BF\u07C0-\u07FF\u0900-\u097F\u0980-\u09FF\u0A00-\u0A7F\u0A80-\u0AFF\u0B00-\u0B7F\u0B80-\u0BFF\u0C00-\u0C7F\u0C80-\u0CFF\u0D00-\u0D7F\u0D80-\u0DFF\u0E00-\u0E7F\u0E80-\u0EFF\u0F00-\u0FFF\u1000-\u109F\u10A0-\u10FF\u1100-\u11FF\u1200-\u137F\u1380-\u139F\u13A0-\u13FF\u1400-\u167F\u1680-\u169F\u16A0-\u16FF\u1700-\u171F\u1720-\u173F\u1740-\u175F\u1760-\u177F\u1780-\u17FF\u1800-\u18AF\u1900-\u194F\u1950-\u197F\u1980-\u19DF\u19E0-\u19FF\u1A00-\u1A1F\u1B00-\u1B7F\u1D00-\u1D7F\u1D80-\u1DBF\u1DC0-\u1DFF\u1E00-\u1EFF\u1F00-\u1FFF\u20D0-\u20FF\u2100-\u214F\u2C00-\u2C5F\u2C60-\u2C7F\u2C80-\u2CFF\u2D00-\u2D2F\u2D30-\u2D7F\u2D80-\u2DDF\u2F00-\u2FDF\u2FF0-\u2FFF\u3040-\u309F\u30A0-\u30FF\u3100-\u312F\u3130-\u318F\u3190-\u319F\u31C0-\u31EF\u31F0-\u31FF\u3200-\u32FF\u3300-\u33FF\u3400-\u4DBF\u4DC0-\u4DFF\u4E00-\u9FFF\uA000-\uA48F\uA490-\uA4CF\uA700-\uA71F\uA800-\uA82F\uA840-\uA87F\uAC00-\uD7AF\uF900-\uFAFF]+\.)+[a-zA-Z\u0080-\u00FF\u0100-\u017F\u0180-\u024F\u0250-\u02AF\u0300-\u036F\u0370-\u03FF\u0400-\u04FF\u0500-\u052F\u0530-\u058F\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u0780-\u07BF\u07C0-\u07FF\u0900-\u097F\u0980-\u09FF\u0A00-\u0A7F\u0A80-\u0AFF\u0B00-\u0B7F\u0B80-\u0BFF\u0C00-\u0C7F\u0C80-\u0CFF\u0D00-\u0D7F\u0D80-\u0DFF\u0E00-\u0E7F\u0E80-\u0EFF\u0F00-\u0FFF\u1000-\u109F\u10A0-\u10FF\u1100-\u11FF\u1200-\u137F\u1380-\u139F\u13A0-\u13FF\u1400-\u167F\u1680-\u169F\u16A0-\u16FF\u1700-\u171F\u1720-\u173F\u1740-\u175F\u1760-\u177F\u1780-\u17FF\u1800-\u18AF\u1900-\u194F\u1950-\u197F\u1980-\u19DF\u19E0-\u19FF\u1A00-\u1A1F\u1B00-\u1B7F\u1D00-\u1D7F\u1D80-\u1DBF\u1DC0-\u1DFF\u1E00-\u1EFF\u1F00-\u1FFF\u20D0-\u20FF\u2100-\u214F\u2C00-\u2C5F\u2C60-\u2C7F\u2C80-\u2CFF\u2D00-\u2D2F\u2D30-\u2D7F\u2D80-\u2DDF\u2F00-\u2FDF\u2FF0-\u2FFF\u3040-\u309F\u30A0-\u30FF\u3100-\u312F\u3130-\u318F\u3190-\u319F\u31C0-\u31EF\u31F0-\u31FF\u3200-\u32FF\u3300-\u33FF\u3400-\u4DBF\u4DC0-\u4DFF\u4E00-\u9FFF\uA000-\uA48F\uA490-\uA4CF\uA700-\uA71F\uA800-\uA82F\uA840-\uA87F\uAC00-\uD7AF\uF900-\uFAFF]{2,}))$/;
}
return rgx.test(email);
}

View File

@ -290,6 +290,7 @@ import { MultiCaptchaConfig, PartneredSatellite } from '@/types/config';
import { User } from '@/types/users';
import { useNotify } from '@/utils/hooks';
import { useConfigStore } from '@/store/modules/configStore';
import { Validator } from '@/utils/validation';
import SelectInput from '@/components/common/SelectInput.vue';
import PasswordStrength from '@/components/common/PasswordStrength.vue';
@ -753,17 +754,11 @@ async function detectBraveBrowser(): Promise<boolean> {
/**
* Validates email string.
* We'll have this email validation for new users instead of using regular Validator.email method because of backwards compatibility.
* We'll use strict email validation for new users instead of using regular Validator.email method because of backwards compatibility.
* We don't want to block old users who managed to create and verify their accounts with some weird email addresses.
*/
function isEmailValid(): boolean {
// This regular expression fulfills our needs to validate international emails.
// It was built according to RFC 5322 and then extended to include international characters using these resources
// https://emailregex.com/
// https://awik.io/international-email-address-validation-javascript/
// eslint-disable-next-line no-misleading-character-class
const regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9\u0080-\u00FF\u0100-\u017F\u0180-\u024F\u0250-\u02AF\u0300-\u036F\u0370-\u03FF\u0400-\u04FF\u0500-\u052F\u0530-\u058F\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u0780-\u07BF\u07C0-\u07FF\u0900-\u097F\u0980-\u09FF\u0A00-\u0A7F\u0A80-\u0AFF\u0B00-\u0B7F\u0B80-\u0BFF\u0C00-\u0C7F\u0C80-\u0CFF\u0D00-\u0D7F\u0D80-\u0DFF\u0E00-\u0E7F\u0E80-\u0EFF\u0F00-\u0FFF\u1000-\u109F\u10A0-\u10FF\u1100-\u11FF\u1200-\u137F\u1380-\u139F\u13A0-\u13FF\u1400-\u167F\u1680-\u169F\u16A0-\u16FF\u1700-\u171F\u1720-\u173F\u1740-\u175F\u1760-\u177F\u1780-\u17FF\u1800-\u18AF\u1900-\u194F\u1950-\u197F\u1980-\u19DF\u19E0-\u19FF\u1A00-\u1A1F\u1B00-\u1B7F\u1D00-\u1D7F\u1D80-\u1DBF\u1DC0-\u1DFF\u1E00-\u1EFF\u1F00-\u1FFF\u20D0-\u20FF\u2100-\u214F\u2C00-\u2C5F\u2C60-\u2C7F\u2C80-\u2CFF\u2D00-\u2D2F\u2D30-\u2D7F\u2D80-\u2DDF\u2F00-\u2FDF\u2FF0-\u2FFF\u3040-\u309F\u30A0-\u30FF\u3100-\u312F\u3130-\u318F\u3190-\u319F\u31C0-\u31EF\u31F0-\u31FF\u3200-\u32FF\u3300-\u33FF\u3400-\u4DBF\u4DC0-\u4DFF\u4E00-\u9FFF\uA000-\uA48F\uA490-\uA4CF\uA700-\uA71F\uA800-\uA82F\uA840-\uA87F\uAC00-\uD7AF\uF900-\uFAFF]+\.)+[a-zA-Z\u0080-\u00FF\u0100-\u017F\u0180-\u024F\u0250-\u02AF\u0300-\u036F\u0370-\u03FF\u0400-\u04FF\u0500-\u052F\u0530-\u058F\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u0780-\u07BF\u07C0-\u07FF\u0900-\u097F\u0980-\u09FF\u0A00-\u0A7F\u0A80-\u0AFF\u0B00-\u0B7F\u0B80-\u0BFF\u0C00-\u0C7F\u0C80-\u0CFF\u0D00-\u0D7F\u0D80-\u0DFF\u0E00-\u0E7F\u0E80-\u0EFF\u0F00-\u0FFF\u1000-\u109F\u10A0-\u10FF\u1100-\u11FF\u1200-\u137F\u1380-\u139F\u13A0-\u13FF\u1400-\u167F\u1680-\u169F\u16A0-\u16FF\u1700-\u171F\u1720-\u173F\u1740-\u175F\u1760-\u177F\u1780-\u17FF\u1800-\u18AF\u1900-\u194F\u1950-\u197F\u1980-\u19DF\u19E0-\u19FF\u1A00-\u1A1F\u1B00-\u1B7F\u1D00-\u1D7F\u1D80-\u1DBF\u1DC0-\u1DFF\u1E00-\u1EFF\u1F00-\u1FFF\u20D0-\u20FF\u2100-\u214F\u2C00-\u2C5F\u2C60-\u2C7F\u2C80-\u2CFF\u2D00-\u2D2F\u2D30-\u2D7F\u2D80-\u2DDF\u2F00-\u2FDF\u2FF0-\u2FFF\u3040-\u309F\u30A0-\u30FF\u3100-\u312F\u3130-\u318F\u3190-\u319F\u31C0-\u31EF\u31F0-\u31FF\u3200-\u32FF\u3300-\u33FF\u3400-\u4DBF\u4DC0-\u4DFF\u4E00-\u9FFF\uA000-\uA48F\uA490-\uA4CF\uA700-\uA71F\uA800-\uA82F\uA840-\uA87F\uAC00-\uD7AF\uF900-\uFAFF]{2,}))$/;
return regex.test(user.value.email);
return Validator.email(user.value.email, true);
}
/**

View File

@ -0,0 +1,18 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<svg :width="size" :height="size" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.1058 0H58.3658C72.2088 0 77.7012 1.53431 83.0884 4.41542C88.4756 7.29653 92.7035 11.5244 95.5846 16.9116L95.8036 17.3264C98.5073 22.5168 99.9614 27.9999 100 41.1058V58.3658C100 72.2088 98.4657 77.7012 95.5846 83.0884C92.7035 88.4756 88.4756 92.7035 83.0884 95.5846L82.6736 95.8036C77.4832 98.5073 72.0001 99.9614 58.8942 100H41.6342C27.7912 100 22.2988 98.4657 16.9116 95.5846C11.5244 92.7035 7.29653 88.4756 4.41542 83.0884L4.19645 82.6736C1.49268 77.4832 0.0385546 72.0001 0 58.8942V41.6342C0 27.7912 1.53431 22.2988 4.41542 16.9116C7.29653 11.5244 11.5244 7.29653 16.9116 4.41542L17.3264 4.19645C22.5168 1.49268 27.9999 0.0385546 41.1058 0Z" fill="#0218A7" />
<path d="M50.4131 85.9507C70.4962 85.9507 86.7768 69.6701 86.7768 49.5869C86.7768 29.5038 70.4962 13.2231 50.4131 13.2231C30.33 13.2231 14.0494 29.5038 14.0494 49.5869C14.0494 69.6701 30.33 85.9507 50.4131 85.9507Z" fill="#0149FF" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.9042 43.5618C29.9451 41.5211 33.254 41.5211 35.2948 43.5618L45.5942 53.8602L65.2273 34.229C67.2681 32.1883 70.577 32.1883 72.6179 34.229C74.6587 36.2696 74.6587 39.5782 72.6179 41.6189L49.2895 64.9451C47.2487 66.9857 43.9398 66.9857 41.8989 64.9451L27.9042 50.9517C25.8634 48.911 25.8634 45.6024 27.9042 43.5618Z" fill="#E6EDF7" />
</svg>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
size: number | string;
}>(), {
size: 100,
});
</script>

View File

@ -8,8 +8,8 @@ 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 function EmailRule(value: string, strict = false): string | boolean {
return Validator.email(value, strict) || 'E-mail must be valid.';
}
export interface DialogStepComponent {

View File

@ -90,7 +90,6 @@
/>
<v-btn
:disabled="!formValid"
color="primary"
size="large"
block
@ -220,7 +219,7 @@ const satellite = computed({
});
/**
* Information about partnered satellites, including name and signup link.
* Information about partnered satellites.
*/
const satellites = computed(() => {
const satellites = configStore.state.config.partneredSatellites ?? [];
@ -258,7 +257,7 @@ function onCaptchaError(): void {
* Holds on login button click logic.
*/
async function onLoginClick(): Promise<void> {
if (isLoading.value) {
if (!formValid.value || isLoading.value) {
return;
}
@ -276,7 +275,6 @@ async function onLoginClick(): Promise<void> {
* 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);

View File

@ -2,18 +2,34 @@
// See LICENSE for copying information.
<template>
<v-container class="fill-height">
<signup-confirmation v-if="codeActivationEnabled && confirmCode" :email="email" :signup-req-id="signupID" />
<v-container v-else class="fill-height">
<v-row align="top" justify="center">
<v-col cols="12" sm="10" md="7" lg="5">
<v-card title="Create your free account" subtitle="Get 25GB storage and 25GB download per month" class="pa-2 pa-sm-7">
<v-card-item>
<v-alert
v-if="isInvited"
variant="tonal"
color="info"
rounded="lg"
density="comfortable"
border
>
<template #text>
{{ inviterEmail }} has invited you to a project on Storj. Create an account on the {{ satellite.satellite }} region to join it.
</template>
</v-alert>
</v-card-item>
<v-card-text>
<v-form class="pt-4">
<v-form v-model="formValid" class="pt-4">
<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
@ -21,11 +37,29 @@
/>
<v-text-field
v-if="isInvited"
:model-value="queryEmail"
class="mb-2"
label="Email address"
placeholder="Enter your email"
name="email"
type="email"
:rules="emailRules"
flat
disabled
required
/>
<v-text-field
v-else
v-model="email"
class="mb-2"
label="Email address"
placeholder="Enter your email"
maxlength="72"
name="email"
type="email"
:rules="emailRules"
flat
clearable
required
@ -33,17 +67,61 @@
<v-text-field
v-model="password"
class="mb-2"
label="Password"
placeholder="Enter a password"
color="secondary"
:type="showPassword ? 'text' : 'password'"
:rules="passwordRules"
:append-inner-icon="showPassword ? 'mdi-eye-outline' : 'mdi-eye-off-outline'"
@click:append-inner="showPassword = !showPassword"
/>
<v-checkbox density="compact" hide-details="true" required>
<v-text-field
v-model="repPassword"
class="mb-2"
label="Password"
placeholder="Enter a password"
color="secondary"
:type="showPassword ? 'text' : 'password'"
:rules="repeatPasswordRules"
:append-inner-icon="showPassword ? 'mdi-eye-outline' : 'mdi-eye-off-outline'"
@click:append-inner="showPassword = !showPassword"
/>
<v-alert
v-if="isBetaSatellite"
class="mb-2"
variant="tonal"
color="warning"
rounded="lg"
density="comfortable"
border
>
<template #title>
<v-checkbox v-model="acceptedBetaTerms" :rules="[RequiredRule]" density="compact" hide-details="auto" required>
<template #label>
This is a BETA satellite
</template>
</v-checkbox>
</template>
<template #text>
This means any data you upload to this satellite can be
deleted at any time and your storage/egress limits
can fluctuate. To use our production service please
create an account on one of our production Satellites.
<a href="https://storj.io/v2/signup/" target="_blank" rel="noopener noreferrer">https://storj.io/v2/signup/</a>
</template>
</v-alert>
<v-checkbox v-model="acceptedTerms" :rules="[RequiredRule]" density="compact" hide-details="auto" required>
<template #label>
<p class="text-body-2">I agree to the <a class="link">Terms of Service</a> and <a class="link">Privacy Policy</a>.</p>
<p class="text-body-2">
I agree to the
<a class="link" href="https://storj.io/terms-of-service/" target="_blank" rel="noopener">Terms of Service</a>
and
<a class="link" href="https://storj.io/privacy-policy/" target="_blank" rel="noopener">Privacy Policy</a>.
</p>
</template>
</v-checkbox>
@ -53,12 +131,29 @@
</template>
</v-checkbox>
<v-btn color="primary" size="large" block router-link to="/signup-confirmation">
<v-btn
:loading="isLoading"
color="primary"
size="large"
block
@click="onSignupClick"
>
Create your account
</v-btn>
</v-form>
</v-card-text>
</v-card>
<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 class="pt-9 text-center text-body-2">Already have an account? <router-link class="link" to="/login">Login</router-link></p>
</v-col>
</v-row>
@ -67,8 +162,10 @@
<script setup lang="ts">
import {
VAlert,
VBtn,
VCard,
VCardItem,
VCardText,
VCheckbox,
VCol,
@ -78,17 +175,232 @@ import {
VSelect,
VTextField,
} from 'vuetify/components';
import { ref } from 'vue';
import { computed, ComputedRef, onBeforeMount, ref } from 'vue';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import { useRoute, useRouter } from 'vue-router';
const valid = ref(false);
const checked = ref(false);
import { useConfigStore } from '@/store/modules/configStore';
import { useAppStore } from '@/store/modules/appStore';
import { useUsersStore } from '@/store/modules/usersStore';
import { EmailRule, RequiredRule, ValidationRule } from '@poc/types/common';
import { MultiCaptchaConfig } from '@/types/config.gen';
import { AuthHttpApi } from '@/api/auth';
import { RouteConfig } from '@/types/router';
import { useNotify } from '@/utils/hooks';
import SignupConfirmation from '@poc/views/SignupConfirmation.vue';
const auth = new AuthHttpApi();
const configStore = useConfigStore();
const appStore = useAppStore();
const usersStore = useUsersStore();
const router = useRouter();
const notify = useNotify();
const route = useRoute();
const isLoading = ref<boolean>(false);
const formValid = ref<boolean>(false);
const acceptedBetaTerms = ref(false);
const acceptedTerms = ref(false);
const showPassword = ref(false);
const captchaError = ref(false);
const confirmCode = ref(false);
const signupID = ref('');
const partner = ref('');
const signupPromoCode = ref('');
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 repPassword = ref('');
const secret = queryRef('token');
const queryEmail = queryRef('email');
const inviterEmail = queryRef('inviter_email');
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,
(value) => EmailRule(value, true),
];
const repeatPasswordRules = computed<ValidationRule<string>[]>(() => [
...passwordRules,
(value: string) => {
if (password.value !== value) {
return 'Passwords do not match';
}
return true;
},
]);
/**
* 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.
*/
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: '' };
});
});
/**
* Returns true if signup activation code is enabled.
*/
const codeActivationEnabled = computed((): boolean => {
return configStore.state.config.signupActivationCodeEnabled;
});
/**
* Indicates if satellite is in beta.
*/
const isBetaSatellite = computed((): boolean => {
return configStore.state.config.isBetaSatellite;
});
/**
* Returns whether the current URL's query parameters indicate that the user was
* redirected from a project invitation link.
*/
const isInvited = computed((): boolean => {
return !!inviterEmail.value && !!queryEmail.value;
});
/**
* This component's captcha configuration.
*/
const captchaConfig = computed((): MultiCaptchaConfig => {
return configStore.state.config.captcha.registration;
});
/**
* queryRef returns a computed reference to a query parameter.
* Nonexistent keys or keys with no value produce an empty string.
*/
function queryRef(key: string): ComputedRef<string> {
return computed((): string => {
const param = route.query[key] || '';
return (typeof param === 'string') ? param : (param[0] || '');
});
}
/**
* Handles captcha verification response.
*/
function onCaptchaVerified(response: string): void {
captchaResponseToken.value = response;
captchaError.value = false;
signup();
}
/**
* Handles captcha error and expiry.
*/
function onCaptchaError(): void {
captchaResponseToken.value = '';
captchaError.value = true;
}
/**
* Holds on login button click logic.
*/
async function onSignupClick(): Promise<void> {
if (!formValid.value || isLoading.value) {
return;
}
isLoading.value = true;
if (hcaptcha.value && !captchaResponseToken.value) {
hcaptcha.value?.execute();
return;
}
await signup();
}
/**
* Creates user.
*/
async function signup(): Promise<void> {
const finalEmail = isInvited.value ? queryEmail.value : email.value;
try {
signupID.value = await auth.register({
email: finalEmail,
password: password.value,
partner: partner.value,
signupPromoCode: signupPromoCode.value,
}, '', captchaResponseToken.value);
if (!codeActivationEnabled.value) {
// Brave browser conversions are tracked via the RegisterSuccess path in the satellite app
// signups outside of the brave browser may use a configured URL to track conversions
// if the URL is not configured, the RegisterSuccess path will be used for non-Brave browsers
const internalRegisterSuccessPath = RouteConfig.RegisterConfirmation.path;
const configuredRegisterSuccessPath = configStore.state.config.optionalSignupSuccessURL || internalRegisterSuccessPath;
const nonBraveSuccessPath = `${configuredRegisterSuccessPath}?email=${encodeURIComponent(email.value)}`;
const braveSuccessPath = `${internalRegisterSuccessPath}?email=${encodeURIComponent(email.value)}`;
await detectBraveBrowser() ? await router.push(braveSuccessPath) : window.location.href = nonBraveSuccessPath;
} else {
confirmCode.value = true;
}
} catch (error) {
notify.notifyError(error);
}
hcaptcha.value?.reset();
captchaResponseToken.value = '';
isLoading.value = false;
}
/**
* Detect if user uses Brave browser
*/
async function detectBraveBrowser(): Promise<boolean> {
return (navigator['brave'] && await navigator['brave'].isBrave() || false);
}
onBeforeMount(() => {
if (route.query.partner) {
partner.value = route.query.partner.toString();
}
if (route.query.promo) {
signupPromoCode.value = route.query.promo.toString();
}
});
</script>

View File

@ -2,35 +2,229 @@
// See LICENSE for copying information.
<template>
<v-container class="fill-height">
<v-container v-if="!codeActivationEnabled" class="fill-height" fluid>
<v-row justify="center" align="center">
<v-col class="text-center py-5" cols="12">
<icon-blue-checkmark />
<h2 class="mb-3">You are almost ready to use Storj</h2>
<p>
If an account with the email address
<span class="font-weight-bold">{{ userEmail }}</span>
exists, a verification email has been sent.
</p>
<p>
Check your inbox to activate your account and get started.
</p>
<v-btn
class="mt-7"
size="large"
:disabled="secondsToWait !== 0"
:loading="isLoading"
@click="resendMail"
>
<template v-if="secondsToWait !== 0">
Resend in {{ timeToEnableResendEmailButton }}
</template>
<template v-else>
Resend
</template>
</v-btn>
</v-col>
<v-col cols="12">
<p class="text-center text-body-2">
Or <a
class="link"
href="https://supportdcs.storj.io/hc/en-us/requests/new"
target="_blank"
rel="noopener noreferrer"
>contact our support team</a>
</p>
</v-col>
<v-col cols="12">
<p class="text-center text-body-2">Not a member? <router-link class="link" to="/signup">Signup</router-link></p>
</v-col>
<v-col cols="12">
<p class="text-center text-body-2"><router-link class="link" to="/login">Go to login page</router-link></p>
</v-col>
</v-row>
</v-container>
<v-container v-else class="fill-height">
<v-row align="top" justify="center">
<v-col cols="12" sm="10" md="7" lg="5">
<v-card title="Check your inbox" class="pa-2 pa-sm-7">
<v-card-text>
<p>Enter the 6 digit confirmation code you received in your email to verify your account:</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 class="my-4" rounded="lg" color="secondary" variant="outlined">
<v-otp-input v-model="code" :loading="isLoading" :error="isError" autofocus class="my-2" />
</v-card>
<v-btn :disabled="otp.length < 6 || loading" color="primary" size="large" block router-link to="/projects">
Verify Account
</v-btn>
</v-form>
<v-btn :disabled="code.length < 6 || isLoading" color="primary" size="large" block @click="verifyCode">
Verify Account
</v-btn>
</v-card-text>
</v-card>
<p class="pt-9 text-center text-body-2">Didn't receive a verification email? <router-link class="link" to="/signup">Resend</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>
<p class="pt-9 text-center text-body-2">
Didn't receive a verification email?
<a class="link" @click="resendMail">
<template v-if="secondsToWait !== 0">
Resend in {{ timeToEnableResendEmailButton }}
</template>
<template v-else>
Resend
</template>
</a>
</p>
<p class="pt-6 text-center text-body-2">Not a member? <a class="link" @click="reloadPage">Signup</a></p>
</v-col>
</v-row>
</v-container>
</template>
<script setup lang="ts">
import { VBtn, VCard, VCardText, VCol, VContainer, VForm, VRow } from 'vuetify/components';
import { VBtn, VCard, VCardText, VCol, VContainer, VRow } from 'vuetify/components';
import { VOtpInput } from 'vuetify/labs/components';
import { ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const loading = ref(false);
const otp = ref('');
import { useNotify } from '@/utils/hooks';
import { AuthHttpApi } from '@/api/auth';
import { useLoading } from '@/composables/useLoading';
import { useConfigStore } from '@/store/modules/configStore';
import { useUsersStore } from '@/store/modules/usersStore';
import { LocalData } from '@/utils/localData';
import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized';
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import IconBlueCheckmark from '@poc/components/icons/IconBlueCheckmark.vue';
const props = withDefaults(defineProps<{
email?: string;
signupReqId?: string;
}>(), {
email: '',
signupReqId: '',
});
const auth: AuthHttpApi = new AuthHttpApi();
const analyticsStore = useAnalyticsStore();
const configStore = useConfigStore();
const usersStore = useUsersStore();
const router = useRouter();
const route = useRoute();
const notify = useNotify();
const { isLoading, withLoading } = useLoading();
const code = ref('');
const signupId = ref<string>(props.signupReqId || '');
const isError = ref(false);
const secondsToWait = ref<number>(30);
const intervalId = ref<ReturnType<typeof setInterval>>();
const userEmail = computed((): string => {
return props.email || route.query.email?.toString() || '';
});
/**
* Returns the time left until the Resend Email button is enabled in mm:ss form.
*/
const timeToEnableResendEmailButton = computed((): string => {
return `${Math.floor(secondsToWait.value / 60).toString().padStart(2, '0')}:${(secondsToWait.value % 60).toString().padStart(2, '0')}`;
});
/**
* Returns true if signup activation code is enabled.
*/
const codeActivationEnabled = computed((): boolean => {
// code activation is not available if this page was arrived at via a link.
return configStore.state.config.signupActivationCodeEnabled && !!props.email;
});
/**
* Resets timer blocking email resend button spamming.
*/
function startResendEmailCountdown(): void {
secondsToWait.value = 30;
intervalId.value = setInterval(() => {
if (--secondsToWait.value <= 0) {
clearInterval(intervalId.value);
}
}, 1000);
}
/**
* Resend email if interval timer is expired.
*/
function resendMail(): void {
withLoading(async () => {
const email = userEmail.value;
if (secondsToWait.value !== 0 || !email) {
return;
}
try {
signupId.value = await auth.resendEmail(email);
} catch (error) {
notify.notifyError(error);
}
startResendEmailCountdown();
});
}
/**
* Handles code verification.
*/
function verifyCode(): void {
isError.value = false;
if (code.value.length < 6 || code.value.length > 6) {
isError.value = true;
return;
}
withLoading(async () => {
try {
const tokenInfo = await auth.verifySignupCode(props.email, code.value, signupId.value);
LocalData.setSessionExpirationDate(tokenInfo.expiresAt);
} catch (error) {
if (error instanceof ErrorUnauthorized) {
notify.notifyError(new Error('Invalid code'));
return;
}
notify.notifyError(error);
isError.value = true;
return;
}
usersStore.login();
analyticsStore.pageVisit('/projects');
await router.push('/projects');
});
}
/**
* Reloads page to reset back to sign up form for e.g.
*/
function reloadPage() {
location.reload();
}
/**
* Lifecycle hook after initial render.
* Starts resend email button availability countdown.
*/
onMounted(() => {
startResendEmailCountdown();
});
/**
* Lifecycle hook before component destroying.
* Resets interval.
*/
onBeforeUnmount(() => {
clearInterval(intervalId.value);
});
</script>