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:
parent
116d8cbea1
commit
ab65572af0
@ -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,21 +410,22 @@ export class AuthHttpApi implements UsersApi {
|
||||
};
|
||||
|
||||
const response = await this.http.post(path, JSON.stringify(body));
|
||||
if (!response.ok) {
|
||||
const result = await response.json();
|
||||
const errMsg = result.error || 'Cannot register user';
|
||||
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'),
|
||||
});
|
||||
}
|
||||
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) {
|
||||
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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -8,28 +8,50 @@
|
||||
</div>
|
||||
<div class="register-success-area__container">
|
||||
<MailIcon />
|
||||
<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
|
||||
<p class="register-success-area__container__sub-title__email">{{ userEmail }}</p>
|
||||
exists, a verification email has been sent.
|
||||
</div>
|
||||
<p class="register-success-area__container__sub-title">
|
||||
Check your inbox to activate your account and get started.
|
||||
</p>
|
||||
<p class="register-success-area__container__text">
|
||||
Didn't receive a verification email?
|
||||
<b class="register-success-area__container__verification-cooldown__bold-text">
|
||||
{{ timeToEnableResendEmailButton }}
|
||||
</b>
|
||||
</p>
|
||||
<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
|
||||
<p class="register-success-area__container__sub-title__email">{{ userEmail }}</p>
|
||||
exists, a verification email has been sent.
|
||||
</div>
|
||||
<p class="register-success-area__container__sub-title">
|
||||
Check your inbox to activate your account and get started.
|
||||
</p>
|
||||
<p class="register-success-area__container__text">
|
||||
Didn't receive a verification email?
|
||||
<b class="register-success-area__container__verification-cooldown__bold-text">
|
||||
{{ 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;
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -82,25 +82,27 @@ export class HttpClient {
|
||||
* Call logout and redirect to login.
|
||||
*/
|
||||
private async handleUnauthorized(): Promise<void> {
|
||||
try {
|
||||
const logoutPath = '/api/v0/auth/logout';
|
||||
const request: RequestInit = {
|
||||
method: 'POST',
|
||||
body: null,
|
||||
};
|
||||
const path = window.location.href;
|
||||
if (!path.includes('/login') && !path.includes('/signup')) {
|
||||
try {
|
||||
const logoutPath = '/api/v0/auth/logout';
|
||||
const request: RequestInit = {
|
||||
method: 'POST',
|
||||
body: null,
|
||||
};
|
||||
|
||||
request.headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
request.headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
await fetch(logoutPath, request);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (error) {}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!window.location.href.includes('/login')) {
|
||||
window.location.href = window.location.origin + '/login';
|
||||
await fetch(logoutPath, request);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (error) {
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = window.location.origin + '/login';
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,20 +791,24 @@ 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);
|
||||
|
||||
// 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.RegisterSuccess.path;
|
||||
const configuredRegisterSuccessPath = configStore.state.config.optionalSignupSuccessURL || internalRegisterSuccessPath;
|
||||
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.RegisterSuccess.path;
|
||||
const configuredRegisterSuccessPath = configStore.state.config.optionalSignupSuccessURL || internalRegisterSuccessPath;
|
||||
|
||||
const nonBraveSuccessPath = `${configuredRegisterSuccessPath}?email=${encodeURIComponent(user.value.email)}`;
|
||||
const braveSuccessPath = `${internalRegisterSuccessPath}?email=${encodeURIComponent(user.value.email)}`;
|
||||
const nonBraveSuccessPath = `${configuredRegisterSuccessPath}?email=${encodeURIComponent(user.value.email)}`;
|
||||
const braveSuccessPath = `${internalRegisterSuccessPath}?email=${encodeURIComponent(user.value.email)}`;
|
||||
|
||||
await detectBraveBrowser() ? await router.push(braveSuccessPath) : window.location.href = nonBraveSuccessPath;
|
||||
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();
|
||||
|
Loading…
Reference in New Issue
Block a user