diff --git a/src/app/core/constants.ts b/src/app/core/constants.ts index 3241a66277..7df40ba2d9 100644 --- a/src/app/core/constants.ts +++ b/src/app/core/constants.ts @@ -3,6 +3,7 @@ export const HTTP_STATUS_CODES = { PAYMENT_REQUIRED: 402, FORBIDDEN: 403, NOT_FOUND: 404, + CONFLICT: 409, MAX_SPACE_USED: 420, INTERNAL_SERVER_ERROR: 500, }; diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 8f798deb21..4150ae8ab7 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -74,6 +74,13 @@ type SignUpParams = { dispatch: AppDispatch; }; +type NewAccountSessionParams = { + registeredUser: Awaited>; + password: string; + redeemCodeObject: boolean; + dispatch: AppDispatch; +}; + type LogInParams = { email: string; password: string; @@ -567,9 +574,13 @@ export const unblockAccount = (token: string): Promise => { return authClient.unblockAccount(token); }; -export const signUp = async (params: SignUpParams) => { - const { doSignUp, email, password, token, redeemCodeObject, dispatch } = params; - const { xUser, xNewToken, mnemonic } = await doSignUp(email, password, token); +export const startNewAccountSession = async ({ + registeredUser, + password, + redeemCodeObject, + dispatch, +}: NewAccountSessionParams) => { + const { xUser, xNewToken, mnemonic } = registeredUser; localStorageService.clearExcept([...CHECKOUT_LOCAL_STORAGE_ITEMS, ...ATTRIBUTION_LOCAL_STORAGE_ITEMS]); @@ -601,6 +612,13 @@ export const signUp = async (params: SignUpParams) => { return { user: xUser, mnemonic, newToken: xNewToken }; }; +export const signUp = async (params: SignUpParams) => { + const { doSignUp, email, password, token, redeemCodeObject, dispatch } = params; + const registeredUser = await doSignUp(email, password, token); + + return startNewAccountSession({ registeredUser, password, redeemCodeObject, dispatch }); +}; + export const logIn = async (params: LogInParams): Promise => { const { email, password, twoFactorCode, dispatch, loginType = 'web', turnstileToken, knownSecurityDetails } = params; const { newToken, user, mnemonic } = await doLogin( diff --git a/src/testUtils/fixtures/accountSetup.fixtures.ts b/src/testUtils/fixtures/accountSetup.fixtures.ts new file mode 100644 index 0000000000..02edb1caf4 --- /dev/null +++ b/src/testUtils/fixtures/accountSetup.fixtures.ts @@ -0,0 +1,29 @@ +import { CompleteAccountSetupPayload, RegisterResponse } from '@internxt/sdk/dist/auth/types'; + +export const getCompleteAccountSetupResponse = ( + payload: CompleteAccountSetupPayload, + overrides: Partial = {}, +): RegisterResponse => + ({ + token: 'legacy-session-token', + newToken: 'session-token', + uuid: 'user-uuid', + user: { + uuid: 'user-uuid', + userId: 'user-id', + email: 'paid-user@internxt.com', + name: payload.name, + lastname: payload.lastname, + mnemonic: payload.mnemonic, + rootFolderId: 'root-folder-uuid', + referralCode: 'referral-code', + keys: { + ecc: { publicKey: payload.keys.ecc.publicKey, privateKey: payload.keys.ecc.privateKeyEncrypted }, + kyber: { + publicKey: payload.keys.kyber.publicKey ?? '', + privateKey: payload.keys.kyber.privateKeyEncrypted ?? '', + }, + }, + }, + ...overrides, + }) as RegisterResponse; diff --git a/src/views/AccountSetup/hooks/useAccountSetup.ts b/src/views/AccountSetup/hooks/useAccountSetup.ts new file mode 100644 index 0000000000..b3ab9335d3 --- /dev/null +++ b/src/views/AccountSetup/hooks/useAccountSetup.ts @@ -0,0 +1,51 @@ +import { HTTP_STATUS_CODES } from 'app/core/constants'; +import { AppView } from 'app/core/types'; +import { useAppDispatch } from 'app/store/hooks'; +import { useState } from 'react'; +import errorService from 'services/error.service'; +import navigationService from 'services/navigation.service'; +import { completeAccountSetup } from '../services/completeAccountSetup'; + +export type AccountSetupStatus = 'form' | 'invalidLink' | 'accountAlreadyExists'; + +/** + * Messages the backend sends when the link can no longer be used. Other 403s (e.g. a failed + * captcha) must not be shown as an invalid link. + */ +const INVALID_LINK_MESSAGES = new Set(['Invalid token', 'Token expired']); + +export const useAccountSetup = (setupToken: string) => { + const dispatch = useAppDispatch(); + const [status, setStatus] = useState('form'); + const [isSubmitting, setIsSubmitting] = useState(false); + const [hasSubmitFailed, setHasSubmitFailed] = useState(false); + + const handleSetupError = (error: unknown) => { + const { status: httpStatus, message } = errorService.castError(error); + const isInvalidLink = httpStatus === HTTP_STATUS_CODES.FORBIDDEN && INVALID_LINK_MESSAGES.has(message); + + if (isInvalidLink) { + setStatus('invalidLink'); + } else if (httpStatus === HTTP_STATUS_CODES.CONFLICT) { + setStatus('accountAlreadyExists'); + } else { + errorService.reportError(error); + setHasSubmitFailed(true); + } + }; + + const setUpAccount = async (password: string) => { + setIsSubmitting(true); + setHasSubmitFailed(false); + + try { + await completeAccountSetup({ setupToken, password, dispatch }); + navigationService.push(AppView.Drive); + } catch (error) { + handleSetupError(error); + setIsSubmitting(false); + } + }; + + return { status, isSubmitting, hasSubmitFailed, setUpAccount }; +}; diff --git a/src/views/AccountSetup/hooks/useResendAccountSetupEmail.ts b/src/views/AccountSetup/hooks/useResendAccountSetupEmail.ts new file mode 100644 index 0000000000..4c2db5e02e --- /dev/null +++ b/src/views/AccountSetup/hooks/useResendAccountSetupEmail.ts @@ -0,0 +1,31 @@ +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { useState } from 'react'; +import { resendAccountSetupEmail } from 'services/account-setup.service'; +import errorService from 'services/error.service'; + +export const useResendAccountSetupEmail = () => { + const { translate } = useTranslationContext(); + const [isSending, setIsSending] = useState(false); + const [isEmailSent, setIsEmailSent] = useState(false); + + const resendEmail = async (email: string) => { + setIsSending(true); + + try { + await resendAccountSetupEmail(email); + setIsEmailSent(true); + } catch (error) { + errorService.reportError(error); + notificationsService.show({ + text: translate('accountSetup.invalidLink.resendError'), + type: ToastType.Error, + requestId: errorService.castError(error).requestId, + }); + } finally { + setIsSending(false); + } + }; + + return { isSending, isEmailSent, resendEmail }; +}; diff --git a/src/views/AccountSetup/services/completeAccountSetup.test.ts b/src/views/AccountSetup/services/completeAccountSetup.test.ts new file mode 100644 index 0000000000..6322e71fe3 --- /dev/null +++ b/src/views/AccountSetup/services/completeAccountSetup.test.ts @@ -0,0 +1,105 @@ +import { CompleteAccountSetupPayload } from '@internxt/sdk/dist/auth/types'; +import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; +import { decryptText, decryptTextWithKey, passToHash } from 'app/crypto/services/utils'; +import { planThunks } from 'app/store/slices/plan'; +import { userThunks } from 'app/store/slices/user'; +import { validateMnemonic } from 'bip39'; +import { Buffer } from 'node:buffer'; +import encryptedStorageService from 'services/encrypted-storage.service'; +import envService from 'services/env.service'; +import { getCompleteAccountSetupResponse } from 'testUtils/fixtures/accountSetup.fixtures'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { completeAccountSetup } from './completeAccountSetup'; + +const { authClient, createAuthClient } = vi.hoisted(() => { + const authClient = { completeAccountSetup: vi.fn() }; + return { authClient, createAuthClient: vi.fn() }; +}); + +vi.mock('app/core/factory/sdk', () => ({ + SdkFactory: { getNewApiInstance: () => ({ createAuthClient }) }, +})); +vi.mock('services/encrypted-storage.service', () => ({ + default: { setToken: vi.fn(), getToken: vi.fn(), getUser: vi.fn(), clear: vi.fn() }, +})); +vi.mock('services/local-storage.service', () => ({ + default: { get: vi.fn(), set: vi.fn(), clear: vi.fn(), clearExcept: vi.fn() }, +})); +vi.mock('app/store/slices/user', () => ({ + initializeUserThunk: vi.fn(), + userThunks: { setUserThunk: vi.fn(), initializeUserThunk: vi.fn() }, +})); +vi.mock('app/store/slices/plan', () => ({ planThunks: { initializeThunk: vi.fn() } })); +vi.mock('app/store/slices/workspaces/workspacesStore', () => ({ + workspaceThunks: { fetchWorkspaces: vi.fn(), checkAndSetLocalWorkspace: vi.fn() }, +})); +vi.mock('services/navigation.service', () => ({ default: { push: vi.fn(), isCurrentPath: vi.fn() } })); +vi.mock('app/analytics/impact.service', () => ({ trackSignUp: vi.fn() })); +vi.mock('app/analytics/meta.service', () => ({ trackLead: vi.fn() })); + +const SETUP_TOKEN = 'setup-token-from-email'; +const PASSWORD = 'Correct-Horse-Battery-9!'; +const CAPTCHA_TOKEN = 'captcha-token'; + +const sentPayload = (): CompleteAccountSetupPayload => authClient.completeAccountSetup.mock.calls[0][0]; + +describe('Completing the setup of a paid account', () => { + beforeEach(() => { + vi.clearAllMocks(); + globalThis.Buffer = Buffer; + globalThis.grecaptcha = { + ready: (callback: () => void) => callback(), + execute: vi.fn().mockResolvedValue(CAPTCHA_TOKEN), + } as never; + vi.spyOn(envService, 'getVariable').mockImplementation((key) => (key === 'secret' ? 'crypto-secret' : 'value')); + createAuthClient.mockReturnValue(authClient); + authClient.completeAccountSetup.mockImplementation(async (payload: CompleteAccountSetupPayload) => + getCompleteAccountSetupResponse(payload), + ); + }); + + test('When the account is set up, then the chosen password never leaves the browser in plain text', async () => { + await completeAccountSetup({ setupToken: SETUP_TOKEN, password: PASSWORD, dispatch: vi.fn() }); + + const payload = sentPayload(); + expect(JSON.stringify(payload)).not.toContain(PASSWORD); + const salt = decryptText(payload.salt); + expect(decryptText(payload.password)).toBe(passToHash({ password: PASSWORD, salt }).hash); + }); + + test('When the account is set up, then a new recovery phrase and key pair protected by the password are sent with the link token', async () => { + await completeAccountSetup({ setupToken: SETUP_TOKEN, password: PASSWORD, dispatch: vi.fn() }); + + const payload = sentPayload(); + expect(payload.token).toBe(SETUP_TOKEN); + expect(payload).toMatchObject({ name: 'My', lastname: 'Internxt' }); + expect(validateMnemonic(decryptTextWithKey(payload.mnemonic, PASSWORD))).toBe(true); + expect(Buffer.from(payload.keys.ecc.publicKey, 'base64').toString()).toContain('BEGIN PGP PUBLIC KEY'); + expect(payload.keys.kyber.publicKey).toBeTruthy(); + expect(createAuthClient).toHaveBeenCalledWith({ captchaToken: CAPTCHA_TOKEN }); + }); + + test('When the account is set up, then the user is logged in with their decrypted keys and paid plan', async () => { + const dispatch = vi.fn(); + + const session = await completeAccountSetup({ setupToken: SETUP_TOKEN, password: PASSWORD, dispatch }); + + expect(encryptedStorageService.setToken).toHaveBeenCalledWith('session-token'); + expect(validateMnemonic(session.mnemonic)).toBe(true); + const loggedUser: UserSettings = vi.mocked(userThunks.setUserThunk).mock.calls[0][0]; + expect(loggedUser.mnemonic).toBe(session.mnemonic); + expect(Buffer.from(loggedUser.keys.ecc.privateKey, 'base64').toString()).toContain('BEGIN PGP PRIVATE KEY'); + expect(planThunks.initializeThunk).toHaveBeenCalled(); + }); + + test('When the link is rejected, then the error reaches the caller and nobody is logged in', async () => { + const expiredLinkError = Object.assign(new Error('Token expired'), { status: 403 }); + authClient.completeAccountSetup.mockRejectedValue(expiredLinkError); + + await expect(completeAccountSetup({ setupToken: SETUP_TOKEN, password: PASSWORD, dispatch: vi.fn() })).rejects.toBe( + expiredLinkError, + ); + expect(encryptedStorageService.setToken).not.toHaveBeenCalled(); + expect(userThunks.setUserThunk).not.toHaveBeenCalled(); + }); +}); diff --git a/src/views/AccountSetup/services/completeAccountSetup.ts b/src/views/AccountSetup/services/completeAccountSetup.ts new file mode 100644 index 0000000000..17df26a60c --- /dev/null +++ b/src/views/AccountSetup/services/completeAccountSetup.ts @@ -0,0 +1,34 @@ +import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; +import { SdkFactory } from 'app/core/factory/sdk'; +import { decryptTextWithKey } from 'app/crypto/services/utils'; +import { AppDispatch } from 'app/store'; +import { startNewAccountSession } from 'services/auth.service'; +import { generateCaptchaToken } from 'utils'; +import { generateNewAccountCredentials } from 'views/Signup/utils/generateNewAccountCredentials'; + +interface CompleteAccountSetupParams { + setupToken: string; + password: string; + dispatch: AppDispatch; +} + +export const completeAccountSetup = async ({ setupToken, password, dispatch }: CompleteAccountSetupParams) => { + const captchaToken = await generateCaptchaToken(); + const authClient = SdkFactory.getNewApiInstance().createAuthClient({ captchaToken }); + const credentials = await generateNewAccountCredentials(password); + + const { user, token, newToken } = await authClient.completeAccountSetup({ token: setupToken, ...credentials }); + const mnemonic = decryptTextWithKey(user.mnemonic, password); + + return startNewAccountSession({ + registeredUser: { + xUser: { ...user, mnemonic } as unknown as UserSettings, + xToken: token, + xNewToken: newToken, + mnemonic, + }, + password, + redeemCodeObject: false, + dispatch, + }); +}; diff --git a/src/views/Signup/hooks/useSignup.ts b/src/views/Signup/hooks/useSignup.ts index c4a710e4da..f86c8c2975 100644 --- a/src/views/Signup/hooks/useSignup.ts +++ b/src/views/Signup/hooks/useSignup.ts @@ -1,12 +1,11 @@ import { RegisterDetails } from '@internxt/sdk'; import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; -import * as bip39 from 'bip39'; import { readReferalCookie, RegisterFunction } from 'services/auth.service'; import { SdkFactory } from '../../../app/core/factory/sdk'; -import { getKeys } from '../../../app/crypto/services/keys.service'; -import { decryptTextWithKey, encryptText, encryptTextWithKey, passToHash } from '../../../app/crypto/services/utils'; +import { decryptTextWithKey } from '../../../app/crypto/services/utils'; import { generateCaptchaToken } from 'utils'; +import { generateNewAccountCredentials } from '../utils/generateNewAccountCredentials'; type RegisterPreCreatedUser = ( email: string, @@ -25,28 +24,9 @@ export function useSignUp(referrer?: string): { doRegisterPreCreatedUser: RegisterPreCreatedUser; } { const doRegister = async (email: string, password: string, captcha: string) => { - const hashObj = passToHash({ password }); - const encPass = encryptText(hashObj.hash); - const encSalt = encryptText(hashObj.salt); - const mnemonic = bip39.generateMnemonic(256); - const encMnemonic = encryptTextWithKey(mnemonic, password); - const authClient = SdkFactory.getNewApiInstance().createAuthClient(); - const keys = await getKeys(password); - - const registerDetails: RegisterDetails = { - name: 'My', - lastname: 'Internxt', - email: email.toLowerCase(), - password: encPass, - salt: encSalt, - mnemonic: encMnemonic, - keys: keys, - captcha: captcha, - referral: readReferalCookie(), - referrer: referrer, - }; + const registerDetails = await generateRegisterDetails(email, password, captcha); const data = await authClient.register(registerDetails); const { token, newToken } = data; @@ -91,21 +71,10 @@ export function useSignUp(referrer?: string): { password: string, captcha: string, ): Promise => { - const hashObj = passToHash({ password }); - const encPass = encryptText(hashObj.hash); - const encSalt = encryptText(hashObj.salt); - const mnemonic = bip39.generateMnemonic(256); - const encMnemonic = encryptTextWithKey(mnemonic, password); - - const keys = await getKeys(password); + const credentials = await generateNewAccountCredentials(password); const registerDetails: RegisterDetails = { - name: 'My', - lastname: 'Internxt', + ...credentials, email: email.toLowerCase(), - password: encPass, - salt: encSalt, - mnemonic: encMnemonic, - keys: keys, captcha: captcha, referral: readReferalCookie(), referrer: referrer, diff --git a/src/views/Signup/utils/generateNewAccountCredentials.ts b/src/views/Signup/utils/generateNewAccountCredentials.ts new file mode 100644 index 0000000000..35a09b22d8 --- /dev/null +++ b/src/views/Signup/utils/generateNewAccountCredentials.ts @@ -0,0 +1,29 @@ +import { RegisterDetails } from '@internxt/sdk'; +import * as bip39 from 'bip39'; + +import { getKeys } from 'app/crypto/services/keys.service'; +import { encryptText, encryptTextWithKey, passToHash } from 'app/crypto/services/utils'; + +export type NewAccountCredentials = Pick< + RegisterDetails, + 'name' | 'lastname' | 'password' | 'salt' | 'mnemonic' | 'keys' +>; + +const NEW_ACCOUNT_NAME = 'My'; +const NEW_ACCOUNT_LASTNAME = 'Internxt'; +const MNEMONIC_STRENGTH_BITS = 256; + +export const generateNewAccountCredentials = async (password: string): Promise => { + const hashObj = passToHash({ password }); + const mnemonic = bip39.generateMnemonic(MNEMONIC_STRENGTH_BITS); + const keys = await getKeys(password); + + return { + name: NEW_ACCOUNT_NAME, + lastname: NEW_ACCOUNT_LASTNAME, + password: encryptText(hashObj.hash), + salt: encryptText(hashObj.salt), + mnemonic: encryptTextWithKey(mnemonic, password), + keys, + }; +};