From 6e47d108a06fe5447e8aede66ca9d9d60a704aad Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 29 Sep 2026 11:24:27 +0200 Subject: [PATCH] feat(account-setup): add the account setup service layer Add the services and hooks to complete the setup of an account paid from checkout using the token from the account setup email, and extract new-account credential generation out of the sign-up flow so it can be reused. --- src/app/core/constants.ts | 1 + src/services/account-setup.service.ts | 19 ++++ src/services/auth.service.ts | 24 +++- .../fixtures/accountSetup.fixtures.ts | 29 +++++ .../AccountSetup/hooks/useAccountSetup.ts | 51 +++++++++ .../hooks/useResendAccountSetupEmail.ts | 31 ++++++ .../services/completeAccountSetup.test.ts | 105 ++++++++++++++++++ .../services/completeAccountSetup.ts | 34 ++++++ src/views/Signup/hooks/useSignup.ts | 41 +------ .../utils/generateNewAccountCredentials.ts | 29 +++++ 10 files changed, 325 insertions(+), 39 deletions(-) create mode 100644 src/services/account-setup.service.ts create mode 100644 src/testUtils/fixtures/accountSetup.fixtures.ts create mode 100644 src/views/AccountSetup/hooks/useAccountSetup.ts create mode 100644 src/views/AccountSetup/hooks/useResendAccountSetupEmail.ts create mode 100644 src/views/AccountSetup/services/completeAccountSetup.test.ts create mode 100644 src/views/AccountSetup/services/completeAccountSetup.ts create mode 100644 src/views/Signup/utils/generateNewAccountCredentials.ts 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/account-setup.service.ts b/src/services/account-setup.service.ts new file mode 100644 index 0000000000..1a63b89492 --- /dev/null +++ b/src/services/account-setup.service.ts @@ -0,0 +1,19 @@ +import { isAccountSetupPendingError } from '@internxt/sdk'; +import { SdkFactory } from 'app/core/factory/sdk'; +import { generateCaptchaToken } from 'utils'; + +/** + * Asks Drive to send again the email to finish the setup of an account paid from the checkout. + * Drive always answers the same way, so the caller can't tell whether the email had a pending setup. + */ +export const resendAccountSetupEmail = async (email: string): Promise => { + const captchaToken = await generateCaptchaToken(); + const authClient = SdkFactory.getNewApiInstance().createAuthClient({ captchaToken }); + await authClient.resendAccountSetupEmail(email.toLowerCase()); +}; + +/** + * True when the error comes from an email whose account was paid but its setup is not finished yet. + * Pass the raw error: `errorService.castError` does not keep the response `code`. + */ +export const isAccountSetupPending = (error: unknown): boolean => isAccountSetupPendingError(error); diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index a96c14dfbd..e431f1c1f9 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -73,6 +73,13 @@ type SignUpParams = { dispatch: AppDispatch; }; +type NewAccountSessionParams = { + registeredUser: Awaited>; + password: string; + redeemCodeObject: boolean; + dispatch: AppDispatch; +}; + type LogInParams = { email: string; password: string; @@ -566,9 +573,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.clear(); @@ -600,6 +611,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..9c81b2f688 --- /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() }, +})); +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, + }; +};