web/satellite: implement account activation with code

This change implements account activation using OTP code. Based on
whether this activation method is activated, signup will require
    the user to fill the activation code to activate their account.

Issue: #6428

Change-Id: Ifdd3e34ddb8ab299dd24b183f9c484613b2e8888
This commit is contained in:
Wilfred Asomani 2023-11-22 10:34:33 +00:00 committed by Wilfred Asomani
parent 116d8cbea1
commit ab65572af0
7 changed files with 223 additions and 70 deletions

View File

@ -29,13 +29,14 @@ export class AuthHttpApi implements UsersApi {
* Used to resend an registration confirmation email.
*
* @param email - email of newly created user
* @returns requestID to be used for code activation.
* @throws Error
*/
public async resendEmail(email: string): Promise<void> {
public async resendEmail(email: string): Promise<string> {
const path = `${this.ROOT_PATH}/resend-email/${email}`;
const response = await this.http.post(path, email);
if (response.ok) {
return;
return response.headers.get('x-request-id') ?? '';
}
const result = await response.json();
@ -98,6 +99,41 @@ export class AuthHttpApi implements UsersApi {
}
}
/**
* Used to verify signup code.
* @param email
* @param code - the code to verify
* @param signupId - the request ID of the signup request or resend email request
*/
public async verifySignupCode(email: string, code: string, signupId: string): Promise<TokenInfo> {
const path = `${this.ROOT_PATH}/code-activation`;
const body = {
email,
code,
signupId,
};
const response = await this.http.patch(path, JSON.stringify(body));
const result = await response.json();
if (response.ok) {
return new TokenInfo(result.token, new Date(result.expiresAt));
}
const errMsg = result.error || 'Failed to activate account';
switch (response.status) {
case 400:
throw new ErrorBadRequest(errMsg);
case 429:
throw new ErrorTooManyRequests(errMsg);
default:
throw new APIError({
status: response.status,
message: errMsg,
requestID: response.headers.get('x-request-id'),
});
}
}
/**
* Used to logout user and delete auth cookie.
*
@ -351,10 +387,10 @@ export class AuthHttpApi implements UsersApi {
* @param user - stores user information
* @param secret - registration token used in Vanguard release
* @param captchaResponse - captcha response
* @returns id of created user
* @returns requestID to be used for code activation.
* @throws Error
*/
public async register(user: Partial<User & { storageNeeds: string }>, secret: string, captchaResponse: string): Promise<void> {
public async register(user: Partial<User & { storageNeeds: string }>, secret: string, captchaResponse: string): Promise<string> {
const path = `${this.ROOT_PATH}/register`;
const body = {
secret: secret,
@ -374,7 +410,9 @@ export class AuthHttpApi implements UsersApi {
};
const response = await this.http.post(path, JSON.stringify(body));
if (!response.ok) {
if (response.ok) {
return response.headers.get('x-request-id') ?? '';
}
const result = await response.json();
const errMsg = result.error || 'Cannot register user';
switch (response.status) {
@ -390,7 +428,6 @@ export class AuthHttpApi implements UsersApi {
});
}
}
}
/**
* Used to enable user's MFA.

View File

@ -4,7 +4,7 @@
<template>
<div class="confirm-mfa">
<label for="confirm-mfa" class="confirm-mfa__label">
<span class="confirm-mfa__label__info">{{ isRecovery ? 'Recovery Code' : '2FA Code' }}</span>
<span class="confirm-mfa__label__info">{{ label || (isRecovery ? 'Recovery Code' : '2FA Code') }}</span>
<span v-if="isError" class="confirm-mfa__label__error">Invalid code. Please re-enter.</span>
</label>
<input
@ -24,10 +24,12 @@ import { ref } from 'vue';
const props = withDefaults(defineProps<{
onInput: (value: string) => void;
label?: string;
isRecovery?: boolean;
isError: boolean;
}>(), {
onInput: () => {},
label: '',
isRecovery: false,
isError: false,
});

View File

@ -8,6 +8,25 @@
</div>
<div class="register-success-area__container">
<MailIcon />
<template v-if="codeActivationEnabled">
<h2 class="register-success-area__container__title" aria-roledescription="title">Check your inbox</h2>
<p class="register-success-area__container__sub-title">
Enter the 6 digit confirmation code you received in your email to verify your account:
</p>
<div class="register-success-area__container__code-input">
<ConfirmMFAInput label="Activation code" :on-input="onConfirmInput" :is-error="isError" />
</div>
<div v-if="codeActivationEnabled" class="register-success-area__container__button-container">
<VButton
label="Verify"
width="450px"
height="50px"
:on-press="onVerifyClicked"
:is-disabled="code.length !== 6 || isLoading"
/>
</div>
</template>
<template v-else>
<h2 class="register-success-area__container__title" aria-roledescription="title">You're almost there!</h2>
<div v-if="showManualActivationMsg" class="register-success-area__container__sub-title fill">
If an account with the email address
@ -23,13 +42,16 @@
{{ timeToEnableResendEmailButton }}
</b>
</p>
</template>
<div class="register-success-area__container__button-container">
<VButton
label="Resend Email"
:label="resendMailLabel"
width="450px"
height="50px"
:is-white="codeActivationEnabled"
:on-press="onResendEmailButtonClick"
:is-disabled="secondsToWait !== 0"
:is-disabled="secondsToWait !== 0 || isLoading"
/>
</div>
<p class="register-success-area__container__contact">
@ -55,29 +77,47 @@ import { useRoute, useRouter } from 'vue-router';
import { AuthHttpApi } from '@/api/auth';
import { RouteConfig } from '@/types/router';
import { useNotify } from '@/utils/hooks';
import { useConfigStore } from '@/store/modules/configStore';
import { useLoading } from '@/composables/useLoading';
import { LocalData } from '@/utils/localData';
import { useUsersStore } from '@/store/modules/usersStore';
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized';
import ConfirmMFAInput from '@/components/account/mfa/ConfirmMFAInput.vue';
import VButton from '@/components/common/VButton.vue';
import LogoIcon from '@/../static/images/logo.svg';
import MailIcon from '@/../static/images/register/mail.svg';
import LogoIcon from '@/../static/images/logo.svg';
const props = withDefaults(defineProps<{
email?: string;
signupReqId?: string;
showManualActivationMsg?: boolean;
}>(), {
email: '',
signupReqId: '',
showManualActivationMsg: true,
});
const analyticsStore = useAnalyticsStore();
const configStore = useConfigStore();
const usersStore = useUsersStore();
const router = useRouter();
const route = useRoute();
const notify = useNotify();
const { isLoading, withLoading } = useLoading();
const auth: AuthHttpApi = new AuthHttpApi();
const loginPath: string = RouteConfig.Login.path;
const secondsToWait = ref<number>(30);
const intervalId = ref<ReturnType<typeof setInterval>>();
const isError = ref<boolean>(false);
const code = ref<string>('');
const signupId = ref<string>(props.signupReqId || '');
const userEmail = computed((): string => {
return props.email || route.query.email?.toString() || '';
@ -90,6 +130,21 @@ 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;
});
/**
* Returns the text for the resend email button.
*/
const resendMailLabel = computed((): string => {
return !codeActivationEnabled.value ? 'Resend Email' : `Resend Email${secondsToWait.value !== 0 ? ' in ' + timeToEnableResendEmailButton.value : ''}`;
});
/**
* Reloads page.
*/
@ -120,14 +175,46 @@ async function onResendEmailButtonClick(): Promise<void> {
}
try {
await auth.resendEmail(email);
signupId.value = await auth.resendEmail(email);
} catch (error) {
notify.notifyError(error, null);
notify.notifyError(error);
}
startResendEmailCountdown();
}
/**
* Handles code verification.
*/
function onVerifyClicked(): void {
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(RouteConfig.AllProjectsDashboard.path);
await router.push(RouteConfig.AllProjectsDashboard.path);
});
}
/**
* Sets confirmation passcode value from input.
*/
function onConfirmInput(value: string): void {
isError.value = false;
code.value = value;
}
/**
* Lifecycle hook after initial render.
* Starts resend email button availability countdown.
@ -228,6 +315,15 @@ onBeforeUnmount(() => {
margin-top: 15px;
}
&__code-input {
width: 450px;
display: flex;
justify-content: center;
align-items: center;
margin-top: 10px;
margin-bottom: 10px;
}
&__contact {
margin-top: 20px;

View File

@ -78,7 +78,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
addNotification(notification);
}
function notifyError(message: NotificationMessage, source?: AnalyticsErrorEventSource): void {
function notifyError(message: NotificationMessage, source: AnalyticsErrorEventSource | null = null): void {
if (source) {
state.analytics.errorEventTriggered(source);
}

View File

@ -82,6 +82,8 @@ export class HttpClient {
* Call logout and redirect to login.
*/
private async handleUnauthorized(): Promise<void> {
const path = window.location.href;
if (!path.includes('/login') && !path.includes('/signup')) {
try {
const logoutPath = '/api/v0/auth/logout';
const request: RequestInit = {
@ -95,12 +97,12 @@ export class HttpClient {
await fetch(logoutPath, request);
// eslint-disable-next-line no-empty
} catch (error) {}
} catch (error) {
}
setTimeout(() => {
if (!window.location.href.includes('/login')) {
window.location.href = window.location.origin + '/login';
}
}, 2000);
}
}
}

View File

@ -33,7 +33,7 @@ export class Notificator {
notificationsStore.notifyError(msg, source);
}
public error(message: NotificationMessage, source?: AnalyticsErrorEventSource): void {
public error(message: NotificationMessage, source: AnalyticsErrorEventSource | null = null): void {
const notificationsStore = useNotificationsStore();
notificationsStore.notifyError(message, source);
}

View File

@ -2,7 +2,8 @@
// See LICENSE for copying information.
<template>
<div class="register-area" @keyup.enter="onCreateClick">
<registration-success v-if="codeActivationEnabled && confirmCode" :email="user.email" :signup-req-id="signupID" />
<div v-else class="register-area" @keyup.enter="onCreateClick">
<div
class="register-area__container"
:class="{'professional-container': isProfessional}"
@ -295,6 +296,7 @@ import PasswordStrength from '@/components/common/PasswordStrength.vue';
import VButton from '@/components/common/VButton.vue';
import VInput from '@/components/common/VInput.vue';
import AddCouponCodeInput from '@/components/common/AddCouponCodeInput.vue';
import RegistrationSuccess from '@/components/common/RegistrationSuccess.vue';
import LogoWithPartnerIcon from '@/../static/images/partnerStorjLogo.svg';
import LogoIcon from '@/../static/images/logo.svg';
@ -347,6 +349,9 @@ const isTermsAcceptedError = ref(false);
const isLoading = ref(false);
const isProfessional = ref(true);
const haveSalesContact = ref(false);
const confirmCode = ref(false);
const signupID = ref('');
const captchaError = ref(false);
const captchaResponseToken = ref('');
@ -537,6 +542,13 @@ const isInvited = computed((): boolean => {
return !!inviterEmail.value && !!email.value;
});
/**
* Returns true if signup activation code is enabled.
*/
const codeActivationEnabled = computed((): boolean => {
return configStore.state.config.signupActivationCodeEnabled;
});
/**
* Indicates if satellite is in beta.
*/
@ -779,8 +791,9 @@ async function createUser(): Promise<void> {
user.value.haveSalesContact = haveSalesContact.value;
try {
await auth.register({ ...user.value, storageNeeds: storageNeeds.value }, secret.value, captchaResponseToken.value);
signupID.value = await auth.register({ ...user.value, storageNeeds: storageNeeds.value }, secret.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
@ -791,8 +804,11 @@ async function createUser(): Promise<void> {
const braveSuccessPath = `${internalRegisterSuccessPath}?email=${encodeURIComponent(user.value.email)}`;
await detectBraveBrowser() ? await router.push(braveSuccessPath) : window.location.href = nonBraveSuccessPath;
} else {
confirmCode.value = true;
}
} catch (error) {
notify.notifyError(error, null);
notify.notifyError(error);
}
captcha.value?.reset();