Repository navigation
[PB-6913]: feat/add the account setup service #2190
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6e47d10
55719db
b30876a
697dd7a
c3ba893
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { CompleteAccountSetupPayload, RegisterResponse } from '@internxt/sdk/dist/auth/types'; | ||
|
|
||
| export const getCompleteAccountSetupResponse = ( | ||
| payload: CompleteAccountSetupPayload, | ||
| overrides: Partial<RegisterResponse> = {}, | ||
| ): 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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AccountSetupStatus>('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 }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cloudflare’s Turnstile was recently added to the login process. I’m just mentioning this so we bear it in mind in case, in future, we prefer to use a single type of captcha for everything
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, I know, but payments still works with captcha token... I have in mind to migrate it to Turnstile |
||
| 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, | ||
| }); | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is quite likely to stop working if there is even the slightest change to the message. Perhaps the status code alone should be enough, with the message included as an extra in the check