From 483906cecada8f4c2b5db7e479d9370271820cef Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 29 Sep 2026 10:58:49 +0200 Subject: [PATCH 01/13] feat(auth): ask users with a paid pending account setup to finish it from their email --- src/app/i18n/locales/de.json | 7 ++ src/app/i18n/locales/en.json | 7 ++ src/app/i18n/locales/es.json | 7 ++ src/app/i18n/locales/fr.json | 7 ++ src/app/i18n/locales/it.json | 7 ++ src/app/i18n/locales/ru.json | 7 ++ src/app/i18n/locales/tw.json | 7 ++ src/app/i18n/locales/zh.json | 7 ++ src/services/account-setup.service.ts | 19 +++ src/services/auth.service.test.ts | 31 +++++ src/services/auth.service.ts | 2 + .../AccountSetupPendingNotice.test.tsx | 65 ++++++++++ .../components/AccountSetupPendingNotice.tsx | 60 ++++++++++ src/views/Login/components/LogIn.test.tsx | 113 ++++++++++++++++++ src/views/Login/components/LogIn.tsx | 14 +++ .../Signup/ShareGuestSignUpView.test.tsx | 48 +++++++- src/views/Signup/ShareGuestSignUpView.tsx | 4 + .../Signup/WorkspaceGuestSignUpView.test.tsx | 33 ++++- src/views/Signup/WorkspaceGuestSignUpView.tsx | 4 + .../Signup/components/CreateAccountForm.tsx | 5 + .../Signup/components/SignupForm.test.tsx | 98 +++++++++++++++ src/views/Signup/components/SignupForm.tsx | 20 +++- src/views/Signup/hooks/useGuestSignupState.ts | 3 + .../Signup/utils/guestSignupOnSubmit.test.ts | 33 +++++ src/views/Signup/utils/guestSignupOnSubmit.ts | 15 ++- 25 files changed, 614 insertions(+), 9 deletions(-) create mode 100644 src/services/account-setup.service.ts create mode 100644 src/views/Login/components/AccountSetupPendingNotice.test.tsx create mode 100644 src/views/Login/components/AccountSetupPendingNotice.tsx create mode 100644 src/views/Login/components/LogIn.test.tsx create mode 100644 src/views/Signup/components/SignupForm.test.tsx diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index c9d96dafa4..2326b330bf 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -54,6 +54,13 @@ "valentines": "Valentinstag" }, "auth": { + "accountSetupPending": { + "title": "Schließen Sie die Einrichtung Ihres Kontos ab", + "message": "Ihr Konto ist bereits bezahlt. Öffnen Sie die E-Mail, die wir Ihnen gesendet haben, um Ihr Passwort festzulegen und die Einrichtung abzuschließen.", + "resend": "E-Mail erneut senden", + "emailSent": "E-Mail gesendet. Sehen Sie in Ihrem Posteingang nach.", + "resendFailed": "Wir konnten die E-Mail nicht senden. Bitte versuchen Sie es erneut." + }, "emailFloatingLabel": "E-Mail-Adresse", "email": "E-Mail", "passwordFloatingLabel": "Passwort", diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index a3ff41dff0..c3cb4920b1 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -75,6 +75,13 @@ "environment": "World Environment Day" }, "auth": { + "accountSetupPending": { + "title": "Finish setting up your account", + "message": "Your account is already paid. Open the email we sent you to set your password and finish setting it up.", + "resend": "Resend the email", + "emailSent": "Email sent. Check your inbox.", + "resendFailed": "We couldn't send the email. Please try again." + }, "emailFloatingLabel": "Email address", "email": "Email address", "passwordFloatingLabel": "Password", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 4d5e445830..05f0174480 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -54,6 +54,13 @@ "environment": "Día Mundial del Medio Ambiente" }, "auth": { + "accountSetupPending": { + "title": "Termina de configurar tu cuenta", + "message": "Tu cuenta ya está pagada. Abre el correo que te enviamos para crear tu contraseña y terminar de configurarla.", + "resend": "Reenviar el correo", + "emailSent": "Correo enviado. Revisa tu bandeja de entrada.", + "resendFailed": "No hemos podido enviar el correo. Inténtalo de nuevo." + }, "emailFloatingLabel": "Correo electrónico", "email": "Correo electrónico", "passwordFloatingLabel": "Contraseña", diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 4e256855c7..2778db0339 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -54,6 +54,13 @@ "environment": "Journée Mondiale de l'Environnement" }, "auth": { + "accountSetupPending": { + "title": "Terminez la configuration de votre compte", + "message": "Votre compte est déjà payé. Ouvrez l'e-mail que nous vous avons envoyé pour définir votre mot de passe et terminer sa configuration.", + "resend": "Renvoyer l'e-mail", + "emailSent": "E-mail envoyé. Consultez votre boîte de réception.", + "resendFailed": "Nous n'avons pas pu envoyer l'e-mail. Veuillez réessayer." + }, "emailFloatingLabel": "Adresse e-mail", "email": "Email", "passwordFloatingLabel": "Mot de passe", diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 90629a42e3..2b9eb0f353 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -75,6 +75,13 @@ "environment": "Giornata Mondiale dell'Ambiente" }, "auth": { + "accountSetupPending": { + "title": "Completa la configurazione del tuo account", + "message": "Il tuo account è già pagato. Apri l'email che ti abbiamo inviato per impostare la password e completare la configurazione.", + "resend": "Invia di nuovo l'email", + "emailSent": "Email inviata. Controlla la tua casella di posta.", + "resendFailed": "Non è stato possibile inviare l'email. Riprova." + }, "emailFloatingLabel": "Indirizzo email", "email": "Email", "passwordFloatingLabel": "Password", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 2a554d0a15..eb316dfb75 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -54,6 +54,13 @@ "environment": "World Environment Day" }, "auth": { + "accountSetupPending": { + "title": "Завершите настройку аккаунта", + "message": "Ваш аккаунт уже оплачен. Откройте письмо, которое мы вам отправили, чтобы задать пароль и завершить настройку.", + "resend": "Отправить письмо ещё раз", + "emailSent": "Письмо отправлено. Проверьте почту.", + "resendFailed": "Не удалось отправить письмо. Попробуйте ещё раз." + }, "emailFloatingLabel": "Электронная почта", "email": "Email", "passwordFloatingLabel": "Пароль", diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index bb8c139ed8..d74e7efd36 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -75,6 +75,13 @@ "environment": "世界環境日" }, "auth": { + "accountSetupPending": { + "title": "完成帳戶設定", + "message": "您的帳戶已付款。請開啟我們寄給您的電子郵件,設定密碼並完成帳戶設定。", + "resend": "重新寄送電子郵件", + "emailSent": "電子郵件已寄出,請查看收件匣。", + "resendFailed": "無法寄送電子郵件,請再試一次。" + }, "emailFloatingLabel": "電子郵件", "email": "電子郵件", "passwordFloatingLabel": "密碼", diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index 3a3c5ae7da..05e01668f6 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -75,6 +75,13 @@ "environment": "世界环境日" }, "auth": { + "accountSetupPending": { + "title": "完成账户设置", + "message": "您的账户已付款。请打开我们发送给您的邮件,设置密码并完成账户设置。", + "resend": "重新发送邮件", + "emailSent": "邮件已发送,请查收收件箱。", + "resendFailed": "无法发送邮件,请重试。" + }, "emailFloatingLabel": "电子邮件", "email": "电子邮件", "passwordFloatingLabel": "密码", 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.test.ts b/src/services/auth.service.test.ts index ac6e028788..87cef2eb35 100644 --- a/src/services/auth.service.test.ts +++ b/src/services/auth.service.test.ts @@ -17,6 +17,8 @@ import envService from 'services/env.service'; import localStorageService from 'services/local-storage.service'; import navigationService from 'services/navigation.service'; import { BackupData } from 'utils/backupKeyUtils'; +import { AxiosResponseError } from '@internxt/sdk/dist/shared/types/errors'; +import { isAccountSetupPending } from 'services/account-setup.service'; import { beforeAll, beforeEach, describe, expect, it, test, vi } from 'vitest'; import * as authService from './auth.service'; import { PasswordMismatchError } from './errors/auth.errors'; @@ -912,6 +914,35 @@ describe('Security and validation', () => { }); }); + describe('Pending account setup on login', () => { + const mockSecurityDetailsFailure = (error: unknown) => { + vi.spyOn(SdkFactory, 'getNewApiInstance').mockReturnValue({ + createAuthClient: vi.fn().mockReturnValue({ securityDetails: vi.fn().mockRejectedValue(error) }), + } as unknown as ReturnType); + }; + + test('When the email belongs to a paid account whose setup is not finished, then the error still says the setup is pending', async () => { + const pendingSetupError = new AxiosResponseError('Forbidden', '', { + status: 403, + data: { message: 'Account setup pending', code: 'AccountSetupPending' }, + headers: {}, + } as never); + mockSecurityDetailsFailure(pendingSetupError); + + const loginAttempt = authService.getSecurityDetails('pending@example.com'); + + await expect(loginAttempt).rejects.toSatisfy(isAccountSetupPending); + }); + + test('When the login fails for any other reason, then the usual error is thrown', async () => { + mockSecurityDetailsFailure(new Error('Wrong login credentials')); + + const loginAttempt = authService.getSecurityDetails('user@example.com'); + + await expect(loginAttempt).rejects.toThrow('Wrong login credentials'); + }); + }); + describe('readReferalCookie', () => { it('reads referral code from cookies', () => { const originalCookie = document.cookie; diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index a96c14dfbd..6154c646ac 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -35,6 +35,7 @@ import { planThunks } from 'app/store/slices/plan'; import { initializeUserThunk, userThunks } from 'app/store/slices/user'; import { workspaceThunks } from 'app/store/slices/workspaces/workspacesStore'; import { generateMnemonic, validateMnemonic } from 'bip39'; +import { isAccountSetupPending } from 'services/account-setup.service'; import errorService from 'services/error.service'; import localStorageService from 'services/local-storage.service'; import navigationService from 'services/navigation.service'; @@ -148,6 +149,7 @@ export function cancelAccount(): Promise { export const getSecurityDetails = async (email: string, turnstileToken?: string): Promise => { const authClient = SdkFactory.getNewApiInstance().createAuthClient({ turnstileToken }); return authClient.securityDetails(email).catch((error) => { + if (isAccountSetupPending(error)) throw error; throw errorService.castError(error); }); }; diff --git a/src/views/Login/components/AccountSetupPendingNotice.test.tsx b/src/views/Login/components/AccountSetupPendingNotice.test.tsx new file mode 100644 index 0000000000..5b7b7d777c --- /dev/null +++ b/src/views/Login/components/AccountSetupPendingNotice.test.tsx @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { SdkFactory } from 'app/core/factory/sdk'; +import { AccountSetupPendingNotice } from './AccountSetupPendingNotice'; + +vi.mock('app/i18n/provider/TranslationProvider', () => ({ + useTranslationContext: () => ({ translate: (key: string) => key }), +})); + +vi.mock('app/core/factory/sdk', () => ({ + SdkFactory: { getNewApiInstance: vi.fn() }, +})); + +vi.mock('services/error.service', () => ({ + default: { reportError: vi.fn() }, +})); + +const PENDING_EMAIL = 'Pending@Example.com'; + +const mockResendEndpoint = (resendAccountSetupEmail: ReturnType) => { + vi.mocked(SdkFactory.getNewApiInstance).mockReturnValue({ + createAuthClient: () => ({ resendAccountSetupEmail }), + } as unknown as ReturnType); +}; + +describe('Pending account setup notice', () => { + beforeEach(() => { + globalThis.grecaptcha = { + ready: (callback: () => void) => callback(), + execute: async () => 'captcha-token', + } as unknown as typeof globalThis.grecaptcha; + mockResendEndpoint(vi.fn().mockResolvedValue(undefined)); + }); + + test('When the notice is shown, then the user is asked to finish the setup and can resend the email', () => { + render(); + + expect(screen.getByText('auth.accountSetupPending.title')).toBeInTheDocument(); + expect(screen.getByText('auth.accountSetupPending.message')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'auth.accountSetupPending.resend' })).toBeInTheDocument(); + }); + + test('When the user resends the email, then it is sent to their address and a confirmation replaces the button', async () => { + const resendAccountSetupEmail = vi.fn().mockResolvedValue(undefined); + mockResendEndpoint(resendAccountSetupEmail); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'auth.accountSetupPending.resend' })); + + expect(await screen.findByRole('status')).toHaveTextContent('auth.accountSetupPending.emailSent'); + expect(resendAccountSetupEmail).toHaveBeenCalledWith('pending@example.com'); + expect(screen.queryByRole('button', { name: 'auth.accountSetupPending.resend' })).not.toBeInTheDocument(); + }); + + test('When resending the email fails, then an error is shown and the user can try again', async () => { + mockResendEndpoint(vi.fn().mockRejectedValue(new Error('Network error'))); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'auth.accountSetupPending.resend' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('auth.accountSetupPending.resendFailed'); + expect(screen.getByRole('button', { name: 'auth.accountSetupPending.resend' })).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); +}); diff --git a/src/views/Login/components/AccountSetupPendingNotice.tsx b/src/views/Login/components/AccountSetupPendingNotice.tsx new file mode 100644 index 0000000000..5d686e5bbd --- /dev/null +++ b/src/views/Login/components/AccountSetupPendingNotice.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { Button } from '@internxt/ui'; +import { CheckCircle, WarningCircle } from '@phosphor-icons/react'; + +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { resendAccountSetupEmail } from 'services/account-setup.service'; +import errorService from 'services/error.service'; + +type ResendStatus = 'idle' | 'sending' | 'sent' | 'failed'; + +interface AccountSetupPendingNoticeProps { + email: string; +} + +export const AccountSetupPendingNotice = ({ email }: AccountSetupPendingNoticeProps): JSX.Element => { + const { translate } = useTranslationContext(); + const [resendStatus, setResendStatus] = useState('idle'); + + const resendEmail = async () => { + setResendStatus('sending'); + try { + await resendAccountSetupEmail(email); + setResendStatus('sent'); + } catch (error) { + errorService.reportError(error); + setResendStatus('failed'); + } + }; + + return ( +
+
+
+ +
+
+ {translate('auth.accountSetupPending.title')} + {translate('auth.accountSetupPending.message')} +
+
+ + {resendStatus === 'sent' ? ( +
+ + {translate('auth.accountSetupPending.emailSent')} +
+ ) : ( + + )} + + {resendStatus === 'failed' && ( + + {translate('auth.accountSetupPending.resendFailed')} + + )} +
+ ); +}; diff --git a/src/views/Login/components/LogIn.test.tsx b/src/views/Login/components/LogIn.test.tsx new file mode 100644 index 0000000000..181da8465f --- /dev/null +++ b/src/views/Login/components/LogIn.test.tsx @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AxiosResponseError } from '@internxt/sdk/dist/shared/types/errors'; +import { SdkFactory } from 'app/core/factory/sdk'; +import LogIn from './LogIn'; + +vi.mock('app/i18n/provider/TranslationProvider', () => ({ + useTranslationContext: () => ({ translate: (key: string) => key }), +})); + +vi.mock('react-helmet-async', () => ({ + Helmet: () => null, +})); + +vi.mock('react-redux', async () => { + const actual = await vi.importActual('react-redux'); + return { + ...actual, + useSelector: (selector: (state: unknown) => unknown) => selector({ user: { user: undefined } }), + }; +}); + +vi.mock('app/store/hooks', () => ({ + useAppDispatch: () => vi.fn(), +})); + +vi.mock('app/core/factory/sdk', () => ({ + SdkFactory: { getNewApiInstance: vi.fn() }, +})); + +const PENDING_EMAIL = 'pending@example.com'; +const PASSWORD = 'SomePassword123!'; + +const backendError = (status: number, data: Record) => + new AxiosResponseError('Request failed', '', { status, data, headers: {} } as never); + +const mockAuthBackend = (authClient: Record) => { + vi.mocked(SdkFactory.getNewApiInstance).mockReturnValue({ + createAuthClient: () => authClient, + createDesktopAuthClient: () => authClient, + } as unknown as ReturnType); +}; + +const submitCredentials = (container: HTMLElement, email: string) => { + fireEvent.change(container.querySelector('input[name="email"]') as HTMLInputElement, { target: { value: email } }); + fireEvent.change(container.querySelector('input[name="password"]') as HTMLInputElement, { + target: { value: PASSWORD }, + }); + fireEvent.click(screen.getByRole('button', { name: 'auth.button.loginAction' })); +}; + +const renderLogin = () => + render( + + + , + ); + +describe('Login', () => { + beforeEach(() => { + globalThis.history.replaceState({}, '', '/login'); + globalThis.grecaptcha = { + ready: (callback: () => void) => callback(), + execute: async () => 'captcha-token', + } as unknown as typeof globalThis.grecaptcha; + }); + + test('When the email has a paid account whose setup is not finished, then the user is asked to finish the setup instead of seeing an error', async () => { + mockAuthBackend({ + securityDetails: vi + .fn() + .mockRejectedValue(backendError(403, { message: 'Account setup pending', code: 'AccountSetupPending' })), + }); + const { container } = renderLogin(); + + submitCredentials(container, PENDING_EMAIL); + + expect(await screen.findByText('auth.accountSetupPending.title')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'auth.accountSetupPending.resend' })).toBeInTheDocument(); + expect(screen.queryByText('Account setup pending')).not.toBeInTheDocument(); + }); + + test('When the user resends the setup email from the login, then a confirmation is shown', async () => { + const resendAccountSetupEmail = vi.fn().mockResolvedValue(undefined); + mockAuthBackend({ + securityDetails: vi + .fn() + .mockRejectedValue(backendError(403, { message: 'Account setup pending', code: 'AccountSetupPending' })), + resendAccountSetupEmail, + }); + const { container } = renderLogin(); + submitCredentials(container, PENDING_EMAIL); + + fireEvent.click(await screen.findByRole('button', { name: 'auth.accountSetupPending.resend' })); + + expect(await screen.findByRole('status')).toHaveTextContent('auth.accountSetupPending.emailSent'); + expect(resendAccountSetupEmail).toHaveBeenCalledWith(PENDING_EMAIL); + }); + + test('When the credentials are wrong, then the usual error is shown and no setup message appears', async () => { + mockAuthBackend({ + securityDetails: vi.fn().mockResolvedValue({ tfaEnabled: false, encryptedSalt: 'encrypted-salt' }), + login: vi.fn().mockRejectedValue(backendError(401, { error: 'Wrong login credentials' })), + }); + const { container } = renderLogin(); + + submitCredentials(container, 'user@example.com'); + + expect(await screen.findByText('Wrong login credentials')).toBeInTheDocument(); + expect(screen.queryByText('auth.accountSetupPending.title')).not.toBeInTheDocument(); + }); +}); diff --git a/src/views/Login/components/LogIn.tsx b/src/views/Login/components/LogIn.tsx index 4e6bbc7fb7..443a9457bd 100644 --- a/src/views/Login/components/LogIn.tsx +++ b/src/views/Login/components/LogIn.tsx @@ -27,6 +27,8 @@ import { useOAuthFlow } from 'views/Login/hooks/useOAuthFlow'; import useLoginRedirections from '../hooks/useLoginRedirections'; import TurnstileWidget, { TurnstileWidgetHandle } from 'components/TurnstileWidget'; import encryptedStorageService from 'services/encrypted-storage.service'; +import { isAccountSetupPending } from 'services/account-setup.service'; +import { AccountSetupPendingNotice } from './AccountSetupPendingNotice'; const showNotification = ({ text, isError }: { text: string; isError: boolean }) => { notificationsService.show({ @@ -44,6 +46,7 @@ export default function LogIn(): JSX.Element { const [showTwoFactor, setShowTwoFactor] = useState(false); const [loginError, setLoginError] = useState([]); const [showErrors, setShowErrors] = useState(false); + const [pendingSetupEmail, setPendingSetupEmail] = useState(null); const user = useSelector((state: RootState) => state.user.user); const mnemonic = user?.mnemonic; @@ -131,6 +134,7 @@ export default function LogIn(): JSX.Element { useEffect(() => { setShowTwoFactor(false); + setPendingSetupEmail(null); }, [email]); const sendUnblockAccountEmail = async (email: string) => { @@ -142,6 +146,14 @@ export default function LogIn(): JSX.Element { }; const handleAuthenticationError = async (err: unknown, email: string): Promise => { + if (isAccountSetupPending(err)) { + setLoginError([]); + setShowErrors(false); + setPendingSetupEmail(email); + return; + } + + setPendingSetupEmail(null); const castedError = errorService.castError(err); if (castedError.message.includes('not activated') && auth.isValidEmail(email)) { @@ -283,6 +295,8 @@ export default function LogIn(): JSX.Element { /> )} + {pendingSetupEmail && } + {loginError && showErrors && (
diff --git a/src/views/Signup/ShareGuestSignUpView.test.tsx b/src/views/Signup/ShareGuestSignUpView.test.tsx index bf8afb3652..b970808c5e 100644 --- a/src/views/Signup/ShareGuestSignUpView.test.tsx +++ b/src/views/Signup/ShareGuestSignUpView.test.tsx @@ -1,11 +1,12 @@ -import { beforeEach, describe, expect, it, vi, Mock } from 'vitest'; -import { fireEvent, render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, test, vi, Mock } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; import ShareGuestSignUpView from './ShareGuestSignUpView'; import { userThunks } from 'app/store/slices/user'; import * as keysService from 'app/crypto/services/keys.service'; import { encryptTextWithKey } from 'app/crypto/services/utils'; import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; import { useSignUp } from './hooks/useSignup'; +import { useGuestSignupState } from './hooks/useGuestSignupState'; import { Buffer } from 'node:buffer'; import { generateMnemonic } from 'bip39'; import envService from 'services/env.service'; @@ -93,6 +94,8 @@ vi.mock('./hooks/useGuestSignupState', () => ({ setInvitationId: vi.fn(), showPasswordIndicator: true, setShowPasswordIndicator: vi.fn(), + pendingSetupEmail: null, + setPendingSetupEmail: vi.fn(), user: null, mnemonic: null, })), @@ -181,6 +184,7 @@ vi.mock('react-redux', () => ({ vi.mock('../../utils', () => ({ onChangePasswordHandler: vi.fn(), + generateCaptchaToken: vi.fn(), })); vi.mock('services/workspace.service', () => ({ @@ -330,3 +334,43 @@ describe('onSubmit', () => { expect(spy).toHaveBeenCalledWith(mockClearUser); }); }); + +describe('Pending account setup', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(envService, 'getVariable').mockImplementation((key) => { + if (key === 'newApi') return mockApi; + if (key === 'secret') return mockSecret; + if (key === 'hostname') return mockHostname; + else return 'no mock implementation'; + }); + }); + + test('when the invited email already has a paid account waiting to be set up, then the user is asked to finish the setup and can resend the email', () => { + (useGuestSignupState as Mock).mockReturnValueOnce({ + isValidPassword: true, + setIsValidPassword: vi.fn(), + signupError: undefined, + setSignupError: vi.fn(), + showError: false, + setShowError: vi.fn(), + isLoading: false, + setIsLoading: vi.fn(), + passwordState: { tag: 'success', label: 'Password is strong' }, + setPasswordState: vi.fn(), + invitationId: 'test-invitation', + setInvitationId: vi.fn(), + showPasswordIndicator: true, + setShowPasswordIndicator: vi.fn(), + pendingSetupEmail: mockEmal, + setPendingSetupEmail: vi.fn(), + user: null, + mnemonic: null, + }); + + render(); + + expect(screen.getByText('auth.accountSetupPending.title')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'auth.accountSetupPending.resend' })).toBeInTheDocument(); + }); +}); diff --git a/src/views/Signup/ShareGuestSignUpView.tsx b/src/views/Signup/ShareGuestSignUpView.tsx index 5232116178..d22be3d141 100644 --- a/src/views/Signup/ShareGuestSignUpView.tsx +++ b/src/views/Signup/ShareGuestSignUpView.tsx @@ -60,6 +60,8 @@ function ShareGuestSingUpView(): JSX.Element { setInvitationId, showPasswordIndicator, setShowPasswordIndicator, + pendingSetupEmail, + setPendingSetupEmail, user, mnemonic, } = useGuestSignupState(); @@ -127,6 +129,7 @@ function ShareGuestSingUpView(): JSX.Element { setIsLoading, setSignupError, setShowError, + setPendingSetupEmail, redirectTo: AppView.Shared, }); }; @@ -153,6 +156,7 @@ function ShareGuestSingUpView(): JSX.Element { isLoading={isLoading} isValidPassword={isValidPassword} isValid={isValid} + pendingSetupEmail={pendingSetupEmail} /> ); } diff --git a/src/views/Signup/WorkspaceGuestSignUpView.test.tsx b/src/views/Signup/WorkspaceGuestSignUpView.test.tsx index 4144b6ef32..367fb281d6 100644 --- a/src/views/Signup/WorkspaceGuestSignUpView.test.tsx +++ b/src/views/Signup/WorkspaceGuestSignUpView.test.tsx @@ -1,4 +1,4 @@ -import { beforeEach, beforeAll, describe, expect, it, vi, Mock } from 'vitest'; +import { beforeEach, beforeAll, describe, expect, it, test, vi, Mock } from 'vitest'; import { screen, fireEvent, render } from '@testing-library/react'; import WorkspaceGuestSingUpView from './WorkspaceGuestSignUpView'; import { userThunks } from '../../app/store/slices/user'; @@ -6,6 +6,7 @@ import * as keysService from '../../app/crypto/services/keys.service'; import { encryptTextWithKey } from '../../app/crypto/services/utils'; import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; import { useSignUp } from './hooks/useSignup'; +import { useGuestSignupState } from './hooks/useGuestSignupState'; import { Buffer } from 'node:buffer'; import { generateMnemonic } from 'bip39'; import envService from 'services/env.service'; @@ -72,6 +73,8 @@ describe('onSubmit', () => { setInvitationId: vi.fn(), showPasswordIndicator: false, setShowPasswordIndicator: vi.fn(), + pendingSetupEmail: null, + setPendingSetupEmail: vi.fn(), user: null, mnemonic: null, }), @@ -323,4 +326,32 @@ describe('onSubmit', () => { }; expect(spy).toBeCalledWith(mockClearUser); }); + + test('when the invited email already has a paid account waiting to be set up, then the user is asked to finish the setup and can resend the email', () => { + (useGuestSignupState as Mock).mockReturnValueOnce({ + isValidPassword: true, + setIsValidPassword: vi.fn(), + signupError: undefined, + setSignupError: vi.fn(), + showError: false, + setShowError: vi.fn(), + isLoading: false, + setIsLoading: vi.fn(), + passwordState: { tag: 'success', label: '' }, + setPasswordState: vi.fn(), + invitationId: 'test-invitation', + setInvitationId: vi.fn(), + showPasswordIndicator: false, + setShowPasswordIndicator: vi.fn(), + pendingSetupEmail: mockEmal, + setPendingSetupEmail: vi.fn(), + user: null, + mnemonic: null, + }); + + render(); + + expect(screen.getByText('auth.accountSetupPending.title')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'auth.accountSetupPending.resend' })).toBeInTheDocument(); + }); }); diff --git a/src/views/Signup/WorkspaceGuestSignUpView.tsx b/src/views/Signup/WorkspaceGuestSignUpView.tsx index a1a1208a9b..92d28e72a0 100644 --- a/src/views/Signup/WorkspaceGuestSignUpView.tsx +++ b/src/views/Signup/WorkspaceGuestSignUpView.tsx @@ -38,6 +38,8 @@ function WorkspaceGuestSingUpView(): JSX.Element { setInvitationId, showPasswordIndicator, setShowPasswordIndicator, + pendingSetupEmail, + setPendingSetupEmail, user, mnemonic, } = useGuestSignupState(); @@ -100,6 +102,7 @@ function WorkspaceGuestSingUpView(): JSX.Element { setIsLoading, setSignupError, setShowError, + setPendingSetupEmail, redirectTo: AppView.Drive, }); }; @@ -127,6 +130,7 @@ function WorkspaceGuestSingUpView(): JSX.Element { isLoading={isLoading} isValidPassword={isValidPassword} isValid={isValid} + pendingSetupEmail={pendingSetupEmail} /> ); } diff --git a/src/views/Signup/components/CreateAccountForm.tsx b/src/views/Signup/components/CreateAccountForm.tsx index e5b6d3cb08..6a156e8c85 100644 --- a/src/views/Signup/components/CreateAccountForm.tsx +++ b/src/views/Signup/components/CreateAccountForm.tsx @@ -9,6 +9,7 @@ import { FieldErrors, UseFormHandleSubmit, UseFormRegister } from 'react-hook-fo import { IFormValues } from '../../../app/core/types'; import { PasswordState } from '../hooks/useGuestSignupState'; import { Translate } from 'app/i18n/types'; +import { AccountSetupPendingNotice } from 'views/Login/components/AccountSetupPendingNotice'; interface CreateAccountFormProps { handleSubmit: UseFormHandleSubmit; @@ -24,6 +25,7 @@ interface CreateAccountFormProps { isLoading: boolean; isValidPassword: boolean; isValid: boolean; + pendingSetupEmail: string | null; } const CreateAccountForm: React.FC = ({ @@ -40,6 +42,7 @@ const CreateAccountForm: React.FC = ({ isLoading, isValidPassword, isValid, + pendingSetupEmail, }) => { return (
@@ -79,6 +82,8 @@ const CreateAccountForm: React.FC = ({ bottomInfoError={bottomInfoError} /> + {pendingSetupEmail && } +
) : ( - + )} + {isPasswordlessSignUp && pendingAccountSetupEmail ? ( +
+

+ {translate('checkout.accountSetup.pendingSetup', { email: pendingAccountSetupEmail })} +

+ +
+ ) : undefined}
{isNotUserSignedIn ? (
diff --git a/src/views/Checkout/components/InputsComponent.tsx b/src/views/Checkout/components/InputsComponent.tsx index c064fee46f..c75ff92be1 100644 --- a/src/views/Checkout/components/InputsComponent.tsx +++ b/src/views/Checkout/components/InputsComponent.tsx @@ -11,9 +11,16 @@ interface InputsComponentProps { authError?: string; authMethod: AuthMethodTypes; register: UseFormRegister; + isPasswordFieldVisible?: boolean; } -export const InputsComponent = ({ register, errors, authError, authMethod }: InputsComponentProps) => { +export const InputsComponent = ({ + register, + errors, + authError, + authMethod, + isPasswordFieldVisible = true, +}: InputsComponentProps) => { const { translate } = useTranslationContext(); return ( @@ -31,19 +38,23 @@ export const InputsComponent = ({ register, errors, authError, authMethod }: Inp />
-
-

{translate(`checkout.authComponent.${authMethod}.password`)}

- -
+ {isPasswordFieldVisible ? ( +
+

{translate(`checkout.authComponent.${authMethod}.password`)}

+ +
+ ) : ( +

{translate('checkout.accountSetup.passwordAfterPayment')}

+ )} {authError && (
{authError} diff --git a/src/views/Checkout/components/ResendAccountSetupEmailButton.tsx b/src/views/Checkout/components/ResendAccountSetupEmailButton.tsx new file mode 100644 index 0000000000..c1190415e1 --- /dev/null +++ b/src/views/Checkout/components/ResendAccountSetupEmailButton.tsx @@ -0,0 +1,34 @@ +import { useState } from 'react'; +import { Button } from '@internxt/ui'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { resendAccountSetupEmail } from 'services/account-setup.service'; +import errorService from 'services/error.service'; + +interface ResendAccountSetupEmailButtonProps { + email: string; +} + +export const ResendAccountSetupEmailButton = ({ email }: ResendAccountSetupEmailButtonProps) => { + const { translate } = useTranslationContext(); + const [isSending, setIsSending] = useState(false); + + const onResendClicked = async () => { + setIsSending(true); + try { + await resendAccountSetupEmail(email); + notificationsService.show({ text: translate('checkout.accountSetup.emailResent'), type: ToastType.Success }); + } catch (error) { + errorService.reportError(error); + notificationsService.show({ text: translate('checkout.accountSetup.resendEmailError'), type: ToastType.Error }); + } finally { + setIsSending(false); + } + }; + + return ( + + ); +}; diff --git a/src/views/Checkout/hooks/useAuthCheckout.test.ts b/src/views/Checkout/hooks/useAuthCheckout.test.ts index 6b3968dd67..a5c9dbd7de 100644 --- a/src/views/Checkout/hooks/useAuthCheckout.test.ts +++ b/src/views/Checkout/hooks/useAuthCheckout.test.ts @@ -271,4 +271,16 @@ describe('Authentication Checkout Custom hook', () => { }); expect(hookState.current.authError).toBeNull(); }); + + test('When a buyer paying without a password uses an email that already has an account, then the checkout switches to log in and explains why', () => { + const changeAuthMethod = vi.fn(); + const { result: hookState } = renderHook(() => useAuthCheckout({ changeAuthMethod })); + + act(() => { + hookState.current.onEmailAlreadyHasAccount(); + }); + + expect(changeAuthMethod).toHaveBeenCalledWith('signIn'); + expect(hookState.current.authError).toBe('checkout.accountSetup.emailHasAccount'); + }); }); diff --git a/src/views/Checkout/hooks/useAuthCheckout.ts b/src/views/Checkout/hooks/useAuthCheckout.ts index d2963b6abb..3bbe62d63f 100644 --- a/src/views/Checkout/hooks/useAuthCheckout.ts +++ b/src/views/Checkout/hooks/useAuthCheckout.ts @@ -116,6 +116,11 @@ export const useAuthCheckout = ({ changeAuthMethod }: Pick { + setAuthError(translate('checkout.accountSetup.emailHasAccount')); + changeAuthMethod('signIn'); + }; + const onLogOut = async () => { await databaseService.clear(); localStorageService.clear(); @@ -126,6 +131,7 @@ export const useAuthCheckout = ({ changeAuthMethod }: Pick ({ + useTranslationContext: () => ({ translate: (key: string) => key }), +})); + +const createCustomerWithoutAccount = vi.fn(); + +const customerPayload: CreateCustomerWithoutAccountPayload = { + email: ' New.Buyer@Internxt.com ', + confirmationTokenId: 'ctoken_123', + country: 'ES', + captchaToken: 'captcha_token', +}; + +const responseError = (status: number, data: Record = {}) => + new AxiosResponseError('Request failed', 'POST /checkout/customer', { status, data, headers: {} } as never); + +const mockPasswordlessFlag = (value: string) => { + vi.spyOn(envService, 'getVariable').mockImplementation((key) => + key === 'passwordlessCheckoutEnabled' ? value : 'no mock implementation', + ); +}; + +const renderPasswordlessCheckout = ({ + authMethod = 'signUp' as AuthMethodTypes, + isUrgentCheckout = false, + onEmailAlreadyHasAccount = vi.fn(), +} = {}) => renderHook(() => usePasswordlessCheckout({ authMethod, isUrgentCheckout, onEmailAlreadyHasAccount })); + +describe('Paying without creating a password first', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mockPasswordlessFlag('true'); + vi.spyOn(SdkFactory, 'getNewApiInstance').mockReturnValue({ + createCheckoutClientWithoutSession: () => ({ createCustomerWithoutAccount }), + } as unknown as SdkFactory); + createCustomerWithoutAccount.mockResolvedValue({ customerId: 'cus_123', token: 'payments_token' }); + }); + + test('When the feature is enabled and a new buyer is creating an account, then the checkout asks only for the email', () => { + const { result } = renderPasswordlessCheckout(); + + expect(result.current.isPasswordlessSignUp).toBe(true); + }); + + test('When the feature is disabled, then new buyers keep creating their account with a password', () => { + mockPasswordlessFlag('false'); + + const { result } = renderPasswordlessCheckout(); + + expect(result.current.isPasswordlessSignUp).toBe(false); + }); + + test('When the buyer chooses to log in, then the checkout keeps asking for the password', () => { + const { result } = renderPasswordlessCheckout({ authMethod: 'signIn' }); + + expect(result.current.isPasswordlessSignUp).toBe(false); + }); + + test('When the buyer is in the urgent checkout, then the new account keeps being created with a password', () => { + const { result } = renderPasswordlessCheckout({ isUrgentCheckout: true }); + + expect(result.current.isPasswordlessSignUp).toBe(false); + }); + + test('When the customer is created for the new buyer, then the payment can continue and the email is kept for the success screen', async () => { + const { result } = renderPasswordlessCheckout(); + + const customer = await result.current.createCustomerWithoutAccount(customerPayload); + + expect(customer).toStrictEqual({ customerId: 'cus_123', token: 'payments_token' }); + expect(createCustomerWithoutAccount).toHaveBeenCalledWith({ ...customerPayload, email: 'new.buyer@internxt.com' }); + expect(localStorageService.get(LocalStorageItem.CheckoutAccountSetupEmail)).toBe('new.buyer@internxt.com'); + }); + + test('When the customer cannot be created, then no email is kept for the success screen', async () => { + createCustomerWithoutAccount.mockRejectedValue(responseError(403)); + const { result } = renderPasswordlessCheckout(); + + await expect(result.current.createCustomerWithoutAccount(customerPayload)).rejects.toThrow(); + + expect(localStorageService.get(LocalStorageItem.CheckoutAccountSetupEmail)).toBeNull(); + }); + + test('When the email already has an account, then the buyer is sent to log in', () => { + const onEmailAlreadyHasAccount = vi.fn(); + const { result } = renderPasswordlessCheckout({ onEmailAlreadyHasAccount }); + + const isHandled = result.current.handlePasswordlessPurchaseError(responseError(409), customerPayload.email); + + expect(isHandled).toBe(true); + expect(onEmailAlreadyHasAccount).toHaveBeenCalled(); + expect(result.current.pendingAccountSetupEmail).toBeUndefined(); + }); + + test('When the email already paid and its account setup is pending, then the buyer is asked to finish it from the email', () => { + const onEmailAlreadyHasAccount = vi.fn(); + const { result } = renderPasswordlessCheckout({ onEmailAlreadyHasAccount }); + + act(() => { + result.current.handlePasswordlessPurchaseError( + responseError(409, { code: 'AccountSetupPending' }), + customerPayload.email, + ); + }); + + expect(result.current.pendingAccountSetupEmail).toBe('new.buyer@internxt.com'); + expect(onEmailAlreadyHasAccount).not.toHaveBeenCalled(); + }); + + test('When the payment details are rejected, then a generic error is shown so the buyer can try again', () => { + const showNotificationSpy = vi.spyOn(notificationsService, 'show').mockReturnValue(''); + const { result } = renderPasswordlessCheckout(); + + const isHandled = result.current.handlePasswordlessPurchaseError(responseError(403), customerPayload.email); + + expect(isHandled).toBe(true); + expect(showNotificationSpy).toHaveBeenCalledWith({ + text: 'notificationMessages.errorCreatingSubscription', + type: ToastType.Error, + }); + }); + + test('When the payment fails for any other reason, then the regular checkout error is shown instead', () => { + const onEmailAlreadyHasAccount = vi.fn(); + const { result } = renderPasswordlessCheckout({ onEmailAlreadyHasAccount }); + + const isHandled = result.current.handlePasswordlessPurchaseError( + new Error('Your card was declined'), + customerPayload.email, + ); + + expect(isHandled).toBe(false); + expect(onEmailAlreadyHasAccount).not.toHaveBeenCalled(); + }); +}); diff --git a/src/views/Checkout/hooks/usePasswordlessCheckout.ts b/src/views/Checkout/hooks/usePasswordlessCheckout.ts new file mode 100644 index 0000000000..3380998a72 --- /dev/null +++ b/src/views/Checkout/hooks/usePasswordlessCheckout.ts @@ -0,0 +1,79 @@ +import { useState } from 'react'; +import { CreateCustomerWithoutAccountPayload, CreatedCustomer } from '@internxt/sdk/dist/payments/types'; +import { LocalStorageItem } from 'app/core/types'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { isAccountSetupPending } from 'services/account-setup.service'; +import errorService from 'services/error.service'; +import localStorageService from 'services/local-storage.service'; +import { STATUS_CODE_ERROR } from '../constants'; +import { checkoutService } from '../services'; +import { AuthMethodTypes } from '../types'; + +interface UsePasswordlessCheckoutProps { + authMethod: AuthMethodTypes; + isUrgentCheckout: boolean; + onEmailAlreadyHasAccount: () => void; +} + +export const usePasswordlessCheckout = ({ + authMethod, + isUrgentCheckout, + onEmailAlreadyHasAccount, +}: UsePasswordlessCheckoutProps) => { + const { translate } = useTranslationContext(); + const [pendingAccountSetupEmail, setPendingAccountSetupEmail] = useState(); + + const isPasswordlessSignUp = + checkoutService.isPasswordlessCheckoutEnabled() && !isUrgentCheckout && authMethod === 'signUp'; + + const createCustomerWithoutAccount = async ( + payload: CreateCustomerWithoutAccountPayload, + ): Promise => { + setPendingAccountSetupEmail(undefined); + const email = payload.email.trim().toLowerCase(); + + const customer = await checkoutService.createCustomerWithoutAccount({ ...payload, email }); + localStorageService.set(LocalStorageItem.CheckoutAccountSetupEmail, email); + + return customer; + }; + + const forgetAccountSetupEmail = () => localStorageService.removeItem(LocalStorageItem.CheckoutAccountSetupEmail); + + /** + * Handles the answers that only a purchase without account can get. Returns false for any other error, + * which the caller handles as in the regular checkout. + */ + const handlePasswordlessPurchaseError = (error: unknown, email: string): boolean => { + if (isAccountSetupPending(error)) { + setPendingAccountSetupEmail(email.trim().toLowerCase()); + return true; + } + + const { status } = errorService.castError(error); + + if (status === STATUS_CODE_ERROR.USER_EXISTS) { + onEmailAlreadyHasAccount(); + return true; + } + + if (status === STATUS_CODE_ERROR.FORBIDDEN) { + notificationsService.show({ + text: translate('notificationMessages.errorCreatingSubscription'), + type: ToastType.Error, + }); + return true; + } + + return false; + }; + + return { + isPasswordlessSignUp, + pendingAccountSetupEmail, + createCustomerWithoutAccount, + forgetAccountSetupEmail, + handlePasswordlessPurchaseError, + }; +}; diff --git a/src/views/Checkout/views/CheckoutView.tsx b/src/views/Checkout/views/CheckoutView.tsx index 0f9ed118ce..2c2d0b3aa4 100644 --- a/src/views/Checkout/views/CheckoutView.tsx +++ b/src/views/Checkout/views/CheckoutView.tsx @@ -38,6 +38,8 @@ interface CheckoutViewProps { couponCodeData?: CouponCodeData; currentSelectedPlan: PriceWithTax | null; selectedCurrency: string; + isPasswordlessSignUp?: boolean; + pendingAccountSetupEmail?: string; }; checkoutViewManager: CheckoutViewManager; availableCryptoCurrencies?: CryptoCurrency[]; @@ -63,8 +65,17 @@ const CheckoutView = ({ const stripeSDK = useStripe(); const elements = useElements(); const [isCryptoDropdownOpen, setIsCryptoDropdownOpen] = useState(false); - const { isPaying, couponCodeError, authError, authMethod, couponCodeData, currentSelectedPlan, selectedCurrency } = - checkoutViewVariables; + const { + isPaying, + couponCodeError, + authError, + authMethod, + couponCodeData, + currentSelectedPlan, + selectedCurrency, + isPasswordlessSignUp, + pendingAccountSetupEmail, + } = checkoutViewVariables; const onCryptoDropdownToggle = () => { if (!isCryptoDropdownOpen) { @@ -128,6 +139,8 @@ const CheckoutView = ({ register={register} authMethod={authMethod} authError={authError} + isPasswordlessSignUp={isPasswordlessSignUp} + pendingAccountSetupEmail={pendingAccountSetupEmail} onAuthMethodToggled={onAuthMethodToggled} userData={userInfo} onLogOut={checkoutViewManager.onLogOut} diff --git a/src/views/Checkout/views/CheckoutViewWrapper.tsx b/src/views/Checkout/views/CheckoutViewWrapper.tsx index 57f84ffe09..11db4a6fc8 100644 --- a/src/views/Checkout/views/CheckoutViewWrapper.tsx +++ b/src/views/Checkout/views/CheckoutViewWrapper.tsx @@ -44,12 +44,21 @@ import { import { useBillingDetails } from '../hooks/useBillingDetails'; import { usePromotionalCode } from '../hooks/usePromotionalCode'; import { useAuthCheckout } from '../hooks/useAuthCheckout'; +import { usePasswordlessCheckout } from '../hooks/usePasswordlessCheckout'; import { checkoutReducer, initialStateForCheckout } from '../store'; import { CheckoutLoader } from '../components/CheckoutLoader'; import UrgentCheckoutView from 'views/UrgentCheckout/views/UrgentCheckoutView'; import { getUrgentCheckoutVariant } from 'views/UrgentCheckout/constants'; import { useUrgentStripeAppearance } from 'views/UrgentCheckout/hooks/useUrgentStripeAppearance'; +interface CustomerDetails { + email: string; + companyName: string; + companyVatId: string; + country: string; + postalCode?: string; +} + const CheckoutViewWrapper = () => { const { translate } = useTranslationContext(); const { checkoutTheme } = useThemeContext(); @@ -103,10 +112,22 @@ const CheckoutViewWrapper = () => { [], ); - const { onAuthenticateUser, onLogOut, authError } = useAuthCheckout({ + const { onAuthenticateUser, onEmailAlreadyHasAccount, onLogOut, authError } = useAuthCheckout({ changeAuthMethod: setAuthMethod, }); + const { + isPasswordlessSignUp, + pendingAccountSetupEmail, + createCustomerWithoutAccount, + forgetAccountSetupEmail, + handlePasswordlessPurchaseError, + } = usePasswordlessCheckout({ + authMethod, + isUrgentCheckout: isUrgentCheckoutRoute, + onEmailAlreadyHasAccount, + }); + const dispatch = useAppDispatch(); const { address, @@ -296,6 +317,85 @@ const CheckoutViewWrapper = () => { } }; + const createCustomerForBuyerWithoutAccount = async ( + { email, companyName, companyVatId, country, postalCode }: CustomerDetails, + confirmationTokenId: string | undefined, + ) => { + if (!confirmationTokenId) { + throw new Error(translate('checkout.error.missingPaymentDetails')); + } + + const ucc = referralService.getStoredUcc(); + const customerName = getCustomerName({ companyName, authenticatedUser: undefined, email }); + + return createCustomerWithoutAccount({ + email, + confirmationTokenId, + customerName: customerName || undefined, + lineAddress1: address?.line1, + lineAddress2: address?.line2 ?? undefined, + country, + postalCode, + city: address?.city, + companyVatId, + captchaToken: await generateCaptchaToken(), + metadata: ucc ? { cello_ucc: ucc } : undefined, + }); + }; + + const createCustomerForAccount = async ( + { email, companyName, companyVatId, country, postalCode }: CustomerDetails, + password: string, + captchaToken: string, + ) => { + forgetAccountSetupEmail(); + + let authenticatedUser = user; + + if (authMethod !== 'userIsSignedIn') { + const result = await onAuthenticateUser({ + email, + password, + authMethod, + dispatch, + authCaptcha: captchaToken, + doRegister, + onAuthenticationFail: () => { + userAuthComponentRef.current?.scrollIntoView(); + setIsUserPaying(false); + }, + }); + + if (result) { + authenticatedUser = result; + } + } + + const customerToken = await generateCaptchaToken(); + const ucc = referralService.getStoredUcc(); + const userUuid = authenticatedUser?.uuid; + const hasMetadata = ucc || userUuid; + const metadata = hasMetadata + ? { + ...(ucc && { cello_ucc: ucc }), + ...(userUuid && { new_user_id: userUuid }), + } + : undefined; + const customerName = getCustomerName({ companyName, authenticatedUser, email }); + + return checkoutService.createCustomer({ + customerName: customerName || undefined, + lineAddress1: address?.line1, + lineAddress2: address?.line2 ?? undefined, + country, + postalCode, + city: address?.city, + companyVatId, + captchaToken: customerToken, + metadata, + }); + }; + const onCheckoutButtonClicked = async ( formData: IFormValues, event: BaseSyntheticEvent | undefined, @@ -324,6 +424,10 @@ const CheckoutViewWrapper = () => { try { const isCryptoPurchase = currencyType === PaymentType['CRYPTO']; + if (isCryptoPurchase && isPasswordlessSignUp) { + throw new Error(translate('checkout.accountSetup.cryptoRequiresSignIn')); + } + if (isCryptoPurchase && isCryptoAddressIncomplete) { throw new Error(translate('checkout.error.addressRequired')); } @@ -359,51 +463,17 @@ const CheckoutViewWrapper = () => { } const captchaToken = await generateCaptchaToken(); - - let authenticatedUser = user; - - if (authMethod !== 'userIsSignedIn') { - const result = await onAuthenticateUser({ - email, - password, - authMethod, - dispatch, - authCaptcha: captchaToken, - doRegister, - onAuthenticationFail: () => { - userAuthComponentRef.current?.scrollIntoView(); - setIsUserPaying(false); - }, - }); - - if (result) { - authenticatedUser = result; - } - } - - const customerToken = await generateCaptchaToken(); - const ucc = referralService.getStoredUcc(); - const userUuid = authenticatedUser?.uuid; - const hasMetadata = ucc || userUuid; - const metadata = hasMetadata - ? { - ...(ucc && { cello_ucc: ucc }), - ...(userUuid && { new_user_id: userUuid }), - } - : undefined; - const customerName = getCustomerName({ companyName, authenticatedUser, email }); - - const { customerId, token } = await checkoutService.createCustomer({ - customerName: customerName || undefined, - lineAddress1: address?.line1, - lineAddress2: address?.line2 ?? undefined, + const customerDetails: CustomerDetails = { + email, + companyName, + companyVatId, country: billingCountry, postalCode: paymentPostalCode, - city: address?.city, - companyVatId, - captchaToken: customerToken, - metadata, - }); + }; + + const { customerId, token } = isPasswordlessSignUp + ? await createCustomerForBuyerWithoutAccount(customerDetails, confirmationTokenId) + : await createCustomerForAccount(customerDetails, password, captchaToken); await handleUserPayment({ confirmPayment: stripeSDK.confirmPayment, @@ -420,11 +490,17 @@ const CheckoutViewWrapper = () => { captchaToken, openCryptoPaymentDialog, userAddress: userLocationData?.ip as string, + isFirstPurchase: isPasswordlessSignUp ? true : undefined, + isPasswordlessSignUp, }); } catch (err) { const statusCode = (err as any).status; const castedError = errorService.castError(err); + if (isPasswordlessSignUp && handlePasswordlessPurchaseError(err, email)) { + return; + } + if (statusCode === STATUS_CODE_ERROR.USER_EXISTS) { setIsUpdateSubscriptionDialogOpen(true); } else if (statusCode === STATUS_CODE_ERROR.COUPON_NOT_VALID) { @@ -487,13 +563,15 @@ const CheckoutViewWrapper = () => { authError: authError ?? undefined, currentSelectedPlan: selectedPlan, selectedCurrency, + isPasswordlessSignUp, + pendingAccountSetupEmail, }} userAuthComponentRef={userAuthComponentRef} showCouponCode={!paramMobileToken} userInfo={userInfo} isUserAuthenticated={isAuthenticated} checkoutViewManager={checkoutViewManager} - availableCryptoCurrencies={availableCryptoCurrencies} + availableCryptoCurrencies={isPasswordlessSignUp ? undefined : availableCryptoCurrencies} onCurrencyTypeChanges={onCurrencyTypeChanges} /> )} From 693faedec95e8619465da48dfa82780fbcf15838 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 29 Sep 2026 11:19:41 +0200 Subject: [PATCH 04/13] feat(checkout): add the check-your-email screen for passwordless buyers Show a dedicated success screen for buyers who paid without an account, asking them to check their email to finish setting it up (with a resend option), and open the checkout success route to unauthenticated buyers. --- src/app/i18n/locales/de.json | 7 +- src/app/i18n/locales/en.json | 7 +- src/app/i18n/locales/es.json | 7 +- src/app/i18n/locales/fr.json | 7 +- src/app/i18n/locales/it.json | 7 +- src/app/i18n/locales/ru.json | 7 +- src/app/i18n/locales/tw.json | 7 +- src/app/i18n/locales/zh.json | 7 +- src/app/routes/paths.json | 2 +- .../views/CheckoutSuccessView.test.tsx | 79 +++++++++++++++++++ .../Checkout/views/CheckoutSuccessView.tsx | 41 +++++++++- 11 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 src/views/Checkout/views/CheckoutSuccessView.test.tsx diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 3f9edfadac..f2dfa1a565 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -278,7 +278,12 @@ "cryptoRequiresSignIn": "Melde dich an, um mit Kryptowährung zu bezahlen.", "resendEmail": "E-Mail erneut senden", "emailResent": "Wir haben dir die E-Mail erneut gesendet", - "resendEmailError": "Wir konnten die E-Mail nicht senden. Bitte versuche es später erneut." + "resendEmailError": "Wir konnten die E-Mail nicht senden. Bitte versuche es später erneut.", + "checkEmail": { + "title": "Prüfe deine E-Mails", + "description": "Deine Zahlung war erfolgreich. Wir haben einen Link zum Abschließen der Kontoeinrichtung gesendet an:", + "spamHint": "Nicht gefunden? Sieh in deinem Spam-Ordner nach." + } } }, "urgentCheckout": { diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 9054c04f91..6ad63a5b63 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -299,7 +299,12 @@ "cryptoRequiresSignIn": "Log in to pay with crypto.", "resendEmail": "Resend email", "emailResent": "We've sent you the email again", - "resendEmailError": "We couldn't send the email. Please try again later." + "resendEmailError": "We couldn't send the email. Please try again later.", + "checkEmail": { + "title": "Check your email", + "description": "Your payment was successful. We've sent a link to finish setting up your account to:", + "spamHint": "Can't find it? Check your spam folder." + } } }, "urgentCheckout": { diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index a55ae66bb7..bf257c2c92 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -278,7 +278,12 @@ "cryptoRequiresSignIn": "Inicia sesión para pagar con criptomonedas.", "resendEmail": "Reenviar correo", "emailResent": "Te hemos vuelto a enviar el correo", - "resendEmailError": "No hemos podido enviar el correo. Inténtalo de nuevo más tarde." + "resendEmailError": "No hemos podido enviar el correo. Inténtalo de nuevo más tarde.", + "checkEmail": { + "title": "Revisa tu correo", + "description": "El pago se ha completado. Hemos enviado un enlace para terminar de configurar tu cuenta a:", + "spamHint": "¿No lo encuentras? Revisa la carpeta de spam." + } } }, "urgentCheckout": { diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 5781558573..8fd33edf0e 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -278,7 +278,12 @@ "cryptoRequiresSignIn": "Connectez-vous pour payer en cryptomonnaie.", "resendEmail": "Renvoyer l'e-mail", "emailResent": "Nous vous avons renvoyé l'e-mail", - "resendEmailError": "Nous n'avons pas pu envoyer l'e-mail. Veuillez réessayer plus tard." + "resendEmailError": "Nous n'avons pas pu envoyer l'e-mail. Veuillez réessayer plus tard.", + "checkEmail": { + "title": "Consultez vos e-mails", + "description": "Votre paiement a été effectué. Nous avons envoyé un lien pour finaliser la configuration de votre compte à :", + "spamHint": "Vous ne le trouvez pas ? Vérifiez votre dossier de spam." + } } }, "urgentCheckout": { diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index faca128f93..bf6bc9799b 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -299,7 +299,12 @@ "cryptoRequiresSignIn": "Accedi per pagare con criptovalute.", "resendEmail": "Invia di nuovo l'email", "emailResent": "Ti abbiamo inviato di nuovo l'email", - "resendEmailError": "Non siamo riusciti a inviare l'email. Riprova più tardi." + "resendEmailError": "Non siamo riusciti a inviare l'email. Riprova più tardi.", + "checkEmail": { + "title": "Controlla la tua email", + "description": "Il pagamento è andato a buon fine. Abbiamo inviato un link per completare la configurazione del tuo account a:", + "spamHint": "Non la trovi? Controlla la cartella dello spam." + } } }, "urgentCheckout": { diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 04daa5c429..f31a91f1aa 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -278,7 +278,12 @@ "cryptoRequiresSignIn": "Войдите, чтобы оплатить криптовалютой.", "resendEmail": "Отправить письмо ещё раз", "emailResent": "Мы отправили письмо ещё раз", - "resendEmailError": "Не удалось отправить письмо. Повторите попытку позже." + "resendEmailError": "Не удалось отправить письмо. Повторите попытку позже.", + "checkEmail": { + "title": "Проверьте почту", + "description": "Оплата прошла успешно. Мы отправили ссылку для завершения настройки аккаунта на адрес:", + "spamHint": "Не нашли письмо? Проверьте папку «Спам»." + } } }, "urgentCheckout": { diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index c09c660aef..a4a5d44cec 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -299,7 +299,12 @@ "cryptoRequiresSignIn": "請登入後使用加密貨幣付款。", "resendEmail": "重新寄送郵件", "emailResent": "我們已重新寄送郵件", - "resendEmailError": "無法寄送郵件,請稍後再試。" + "resendEmailError": "無法寄送郵件,請稍後再試。", + "checkEmail": { + "title": "請查看你的電子郵件", + "description": "付款成功。我們已將完成帳戶設定的連結寄送至:", + "spamHint": "找不到郵件?請檢查垃圾郵件資料夾。" + } } }, "urgentCheckout": { diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index 3243adbb08..d56a5243e9 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -299,7 +299,12 @@ "cryptoRequiresSignIn": "请登录后使用加密货币付款。", "resendEmail": "重新发送邮件", "emailResent": "我们已重新发送邮件", - "resendEmailError": "无法发送邮件,请稍后重试。" + "resendEmailError": "无法发送邮件,请稍后重试。", + "checkEmail": { + "title": "请查看你的电子邮件", + "description": "付款成功。我们已将完成账户设置的链接发送至:", + "spamHint": "找不到邮件?请检查垃圾邮件文件夹。" + } } }, "urgentCheckout": { diff --git a/src/app/routes/paths.json b/src/app/routes/paths.json index 7ab0f7164d..14cd8e8171 100644 --- a/src/app/routes/paths.json +++ b/src/app/routes/paths.json @@ -147,7 +147,7 @@ "layout": "empty", "path": "/checkout/success", "exact": false, - "auth": true + "auth": false }, { "id": "checkout-cancel", diff --git a/src/views/Checkout/views/CheckoutSuccessView.test.tsx b/src/views/Checkout/views/CheckoutSuccessView.test.tsx new file mode 100644 index 0000000000..47d8275d1b --- /dev/null +++ b/src/views/Checkout/views/CheckoutSuccessView.test.tsx @@ -0,0 +1,79 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { AppView, LocalStorageItem } from 'app/core/types'; +import enTranslations from 'app/i18n/locales/en.json'; +import encryptedStorageService from 'services/encrypted-storage.service'; +import localStorageService from 'services/local-storage.service'; +import navigationService from 'services/navigation.service'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import CheckoutSuccessView from './CheckoutSuccessView'; + +const translate = (key: string) => { + const value = key.split('.').reduce((accumulator, part) => accumulator?.[part], enTranslations); + + if (typeof value !== 'string') { + throw new Error(`Missing translation for "${key}"`); + } + + return value; +}; + +vi.mock('app/i18n/provider/TranslationProvider', () => ({ + useTranslationContext: () => ({ translate }), +})); + +vi.mock('app/store/hooks', () => ({ + useAppDispatch: () => vi.fn(), +})); + +vi.mock('app/analytics/ga.service', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, default: { ...original.default, trackPurchase: vi.fn() } }; +}); +vi.mock('app/analytics/meta.service', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, default: { ...original.default, trackPurchase: vi.fn() } }; +}); +vi.mock('app/analytics/impact.service', async (importOriginal) => ({ + ...(await importOriginal()), + trackPaymentConversion: vi.fn(), +})); + +vi.mock('utils/userStoragePolling.utils', () => ({ + userStoragePolling: vi.fn(), +})); + +const { checkEmail } = enTranslations.checkout.accountSetup; + +describe('Checkout success', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + vi.spyOn(navigationService, 'push').mockImplementation(() => undefined); + }); + + test('When a buyer without an account finishes paying, then they are asked to check the email the setup link was sent to', async () => { + vi.spyOn(encryptedStorageService, 'getToken').mockReturnValue(undefined); + localStorageService.set(LocalStorageItem.CheckoutAccountSetupEmail, 'new.buyer@internxt.com'); + localStorageService.set(LocalStorageItem.SubscriptionID, 'sub_123'); + + render(); + + expect(await screen.findByText(checkEmail.title)).toBeInTheDocument(); + expect(screen.getByText('new.buyer@internxt.com')).toBeInTheDocument(); + expect(screen.getByText(checkEmail.spamHint)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: enTranslations.checkout.accountSetup.resendEmail })).toBeInTheDocument(); + await waitFor(() => expect(localStorageService.get(LocalStorageItem.SubscriptionID)).toBeNull()); + expect(navigationService.push).not.toHaveBeenCalled(); + }); + + test('When a logged-in user finishes paying, then they are taken to Drive', async () => { + vi.spyOn(encryptedStorageService, 'getToken').mockReturnValue('session_token'); + localStorageService.set(LocalStorageItem.CheckoutAccountSetupEmail, 'old.buyer@internxt.com'); + + render(); + + await waitFor(() => expect(navigationService.push).toHaveBeenCalledWith(AppView.Drive)); + expect(screen.queryByText(checkEmail.title)).not.toBeInTheDocument(); + expect(localStorageService.get(LocalStorageItem.CheckoutAccountSetupEmail)).toBeNull(); + }); +}); diff --git a/src/views/Checkout/views/CheckoutSuccessView.tsx b/src/views/Checkout/views/CheckoutSuccessView.tsx index 3f1ba79bc5..bf4cecfdb5 100644 --- a/src/views/Checkout/views/CheckoutSuccessView.tsx +++ b/src/views/Checkout/views/CheckoutSuccessView.tsx @@ -2,12 +2,16 @@ import useEffectAsync from 'hooks/useEffectAsync'; import navigationService from 'services/navigation.service'; import { AppView, LocalStorageItem } from 'app/core/types'; import { useAppDispatch } from 'app/store/hooks'; -import { useCallback, useRef } from 'react'; +import { useCallback, useRef, useState } from 'react'; +import { CheckCircle } from '@phosphor-icons/react'; import localStorageService from 'services/local-storage.service'; +import encryptedStorageService from 'services/encrypted-storage.service'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { trackPaymentConversion } from 'app/analytics/impact.service'; import gaService from 'app/analytics/ga.service'; import metaService from 'app/analytics/meta.service'; import { userStoragePolling } from 'utils/userStoragePolling.utils'; +import { ResendAccountSetupEmailButton } from '../components/ResendAccountSetupEmailButton'; export function removePaymentsStorage() { localStorageService.removeItem(LocalStorageItem.SubscriptionID); @@ -19,9 +23,32 @@ export function removePaymentsStorage() { localStorageService.removeItem(LocalStorageItem.CouponCode); } +const CheckYourEmail = ({ email }: { email: string }) => { + const { translate } = useTranslationContext(); + + return ( +
+
+ +
+

{translate('checkout.accountSetup.checkEmail.title')}

+

{translate('checkout.accountSetup.checkEmail.description')}

+

{email}

+
+ +

{translate('checkout.accountSetup.checkEmail.spamHint')}

+
+
+ ); +}; + const CheckoutSuccessView = (): JSX.Element => { const dispatch = useAppDispatch(); const hasTrackedRef = useRef(false); + const hasSession = Boolean(encryptedStorageService.getToken()); + const [accountSetupEmail] = useState(() => + hasSession ? null : localStorageService.get(LocalStorageItem.CheckoutAccountSetupEmail), + ); const onCheckoutSuccess = useCallback(async () => { if (hasTrackedRef.current) { @@ -40,6 +67,14 @@ const CheckoutSuccessView = (): JSX.Element => { console.error('Analytics error:', err); } + if (accountSetupEmail) { + return; + } + + if (hasSession) { + localStorageService.removeItem(LocalStorageItem.CheckoutAccountSetupEmail); + } + userStoragePolling(); navigationService.push(AppView.Drive); @@ -47,6 +82,10 @@ const CheckoutSuccessView = (): JSX.Element => { useEffectAsync(onCheckoutSuccess, []); + if (accountSetupEmail) { + return ; + } + return
; }; From 6e47d108a06fe5447e8aede66ca9d9d60a704aad Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 29 Sep 2026 11:24:27 +0200 Subject: [PATCH 05/13] 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, + }; +}; From df9052339901133a83451a2acc724dc363eda21e Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 29 Sep 2026 11:26:38 +0200 Subject: [PATCH 06/13] feat(account-setup): add the account setup screen from the email link Add the screen, its route and translations for finishing the setup of an account paid from checkout when the user follows the link sent by email. --- src/app/core/config/views.ts | 2 + src/app/core/types.ts | 1 + src/app/i18n/locales/de.json | 24 +++ src/app/i18n/locales/en.json | 24 +++ src/app/i18n/locales/es.json | 24 +++ src/app/i18n/locales/fr.json | 24 +++ src/app/i18n/locales/it.json | 24 +++ src/app/i18n/locales/ru.json | 24 +++ src/app/i18n/locales/tw.json | 24 +++ src/app/i18n/locales/zh.json | 24 +++ src/app/routes/paths.json | 7 + .../AccountSetup/AccountSetupView.test.tsx | 177 ++++++++++++++++++ src/views/AccountSetup/AccountSetupView.tsx | 19 ++ .../AccountAlreadyExistsMessage.tsx | 20 ++ .../components/AccountSetupForm.tsx | 109 +++++++++++ .../components/AccountSetupLayout.tsx | 39 ++++ .../components/InvalidLinkMessage.tsx | 46 +++++ src/views/AccountSetup/index.ts | 1 + 18 files changed, 613 insertions(+) create mode 100644 src/views/AccountSetup/AccountSetupView.test.tsx create mode 100644 src/views/AccountSetup/AccountSetupView.tsx create mode 100644 src/views/AccountSetup/components/AccountAlreadyExistsMessage.tsx create mode 100644 src/views/AccountSetup/components/AccountSetupForm.tsx create mode 100644 src/views/AccountSetup/components/AccountSetupLayout.tsx create mode 100644 src/views/AccountSetup/components/InvalidLinkMessage.tsx create mode 100644 src/views/AccountSetup/index.ts diff --git a/src/app/core/config/views.ts b/src/app/core/config/views.ts index 4ea4b9e42d..6c0f78304f 100644 --- a/src/app/core/config/views.ts +++ b/src/app/core/config/views.ts @@ -15,6 +15,7 @@ import { UniversalLinkOkView, } from '../../../views/Login'; import SignUpView, { ShareGuestSignUpView, WorkspaceGuestSignUpView } from '../../../views/Signup'; +import AccountSetupView from 'views/AccountSetup'; import DeactivationView from '../../core/views/DeactivationView/DeactivationView'; import DriveView from 'views/Drive'; import FavoritesView from 'views/Favorites'; @@ -46,6 +47,7 @@ const views: Array<{ { id: AppView.Auth, component: AuthView }, { id: AppView.ButtonAuth, component: ButtonAuth }, { id: AppView.RecoverAccount, component: RecoverAccountView }, + { id: AppView.CompleteAccount, component: AccountSetupView }, { id: AppView.Favorites, component: FavoritesView }, { id: AppView.Recents, component: RecentsView }, { id: AppView.Trash, component: TrashView }, diff --git a/src/app/core/types.ts b/src/app/core/types.ts index 3a2b414300..2a54382417 100644 --- a/src/app/core/types.ts +++ b/src/app/core/types.ts @@ -90,6 +90,7 @@ export enum AppView { Auth = 'auth', ButtonAuth = 'buttonAuth', RecoverAccount = 'recover-account', + CompleteAccount = 'complete-account', Drive = 'drive', Favorites = 'favorites', Recents = 'recents', diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index c9d96dafa4..1ace252cee 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -1540,6 +1540,30 @@ } } }, + "accountSetup": { + "form": { + "title": "Richte dein Konto ein", + "description": "Erstelle ein Passwort, um die Einrichtung deines Internxt-Kontos abzuschließen.", + "confirmPassword": "Passwort bestätigen", + "passwordsDoNotMatch": "Die Passwörter stimmen nicht überein", + "submit": "Konto erstellen", + "genericError": "Bei der Einrichtung deines Kontos ist ein Fehler aufgetreten. Bitte versuche es erneut." + }, + "invalidLink": { + "title": "Dieser Link ist nicht mehr gültig", + "description": "Der Link ist abgelaufen oder wurde bereits verwendet. Gib deine E-Mail-Adresse ein und wir senden dir einen neuen.", + "emailLabel": "E-Mail", + "resendButton": "Neuen Link senden", + "resendError": "Die E-Mail konnte nicht gesendet werden. Bitte versuche es erneut.", + "emailSentTitle": "Prüfe deine E-Mails", + "emailSentDescription": "Wenn für {{email}} ein Konto auf die Einrichtung wartet, erhältst du in Kürze einen neuen Link." + }, + "accountAlreadyExists": { + "title": "Dein Konto ist bereits eingerichtet", + "description": "Dieses Konto existiert bereits. Melde dich mit deiner E-Mail-Adresse und deinem Passwort an.", + "loginButton": "Zur Anmeldung" + } + }, "blockedAccount": { "title": "Überprüfen Sie, dass Sie es sind", "text1": "Es wurden zu viele fehlgeschlagene Anmeldeversuche unternommen.", diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index a3ff41dff0..8ecf11bd03 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -1564,6 +1564,30 @@ } } }, + "accountSetup": { + "form": { + "title": "Set up your account", + "description": "Create a password to finish setting up your Internxt account.", + "confirmPassword": "Confirm password", + "passwordsDoNotMatch": "Passwords don't match", + "submit": "Create account", + "genericError": "Something went wrong while setting up your account. Please try again." + }, + "invalidLink": { + "title": "This link is no longer valid", + "description": "The link has expired or has already been used. Enter your email and we will send you a new one.", + "emailLabel": "Email", + "resendButton": "Send me a new link", + "resendError": "We couldn't send the email. Please try again.", + "emailSentTitle": "Check your email", + "emailSentDescription": "If {{email}} has an account waiting to be set up, you will receive a new link shortly." + }, + "accountAlreadyExists": { + "title": "Your account is already set up", + "description": "This account already exists. Log in with your email and password to access it.", + "loginButton": "Go to log in" + } + }, "blockedAccount": { "title": "Verify it’s you", "text1": "Too many failed logins were attempted.", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 4d5e445830..c94a1a6860 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -1544,6 +1544,30 @@ } } }, + "accountSetup": { + "form": { + "title": "Configura tu cuenta", + "description": "Crea una contraseña para terminar de configurar tu cuenta de Internxt.", + "confirmPassword": "Confirmar contraseña", + "passwordsDoNotMatch": "Las contraseñas no coinciden", + "submit": "Crear cuenta", + "genericError": "Algo ha ido mal al configurar tu cuenta. Inténtalo de nuevo." + }, + "invalidLink": { + "title": "Este enlace ya no es válido", + "description": "El enlace ha caducado o ya se ha utilizado. Introduce tu email y te enviaremos uno nuevo.", + "emailLabel": "Email", + "resendButton": "Envíame un nuevo enlace", + "resendError": "No hemos podido enviar el email. Inténtalo de nuevo.", + "emailSentTitle": "Revisa tu email", + "emailSentDescription": "Si {{email}} tiene una cuenta pendiente de configurar, recibirás un nuevo enlace en breve." + }, + "accountAlreadyExists": { + "title": "Tu cuenta ya está configurada", + "description": "Esta cuenta ya existe. Inicia sesión con tu email y contraseña para acceder.", + "loginButton": "Ir a iniciar sesión" + } + }, "blockedAccount": { "title": "Verifica que eres tú", "text1": "Se han detectado demasiados inicios de sesion fallidos.", diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 4e256855c7..b4565ea19f 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -1544,6 +1544,30 @@ } } }, + "accountSetup": { + "form": { + "title": "Configurez votre compte", + "description": "Créez un mot de passe pour terminer la configuration de votre compte Internxt.", + "confirmPassword": "Confirmer le mot de passe", + "passwordsDoNotMatch": "Les mots de passe ne correspondent pas", + "submit": "Créer un compte", + "genericError": "Une erreur s'est produite lors de la configuration de votre compte. Veuillez réessayer." + }, + "invalidLink": { + "title": "Ce lien n'est plus valide", + "description": "Le lien a expiré ou a déjà été utilisé. Saisissez votre e-mail et nous vous en enverrons un nouveau.", + "emailLabel": "E-mail", + "resendButton": "M'envoyer un nouveau lien", + "resendError": "Nous n'avons pas pu envoyer l'e-mail. Veuillez réessayer.", + "emailSentTitle": "Vérifiez vos e-mails", + "emailSentDescription": "Si {{email}} a un compte en attente de configuration, vous recevrez bientôt un nouveau lien." + }, + "accountAlreadyExists": { + "title": "Votre compte est déjà configuré", + "description": "Ce compte existe déjà. Connectez-vous avec votre e-mail et votre mot de passe pour y accéder.", + "loginButton": "Aller à la connexion" + } + }, "blockedAccount": { "title": "Vérifier que c'est bien vous", "text1": "Trop d'échecs de connexion ont été tentés", diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 90629a42e3..7473d58bf3 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -1564,6 +1564,30 @@ } } }, + "accountSetup": { + "form": { + "title": "Configura il tuo account", + "description": "Crea una password per completare la configurazione del tuo account Internxt.", + "confirmPassword": "Conferma password", + "passwordsDoNotMatch": "Le password non corrispondono", + "submit": "Crea account", + "genericError": "Si è verificato un errore durante la configurazione dell'account. Riprova." + }, + "invalidLink": { + "title": "Questo link non è più valido", + "description": "Il link è scaduto o è già stato utilizzato. Inserisci la tua email e te ne invieremo uno nuovo.", + "emailLabel": "Email", + "resendButton": "Inviami un nuovo link", + "resendError": "Non è stato possibile inviare l'email. Riprova.", + "emailSentTitle": "Controlla la tua email", + "emailSentDescription": "Se {{email}} ha un account in attesa di configurazione, riceverai a breve un nuovo link." + }, + "accountAlreadyExists": { + "title": "Il tuo account è già configurato", + "description": "Questo account esiste già. Accedi con la tua email e password.", + "loginButton": "Vai all'accesso" + } + }, "blockedAccount": { "title": "Verifica di essere tu", "text1": "Sono stati tentati troppi login non riusciti", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 2a554d0a15..901f337708 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -1543,6 +1543,30 @@ } } }, + "accountSetup": { + "form": { + "title": "Настройте свой аккаунт", + "description": "Создайте пароль, чтобы завершить настройку аккаунта Internxt.", + "confirmPassword": "Подтвердите пароль", + "passwordsDoNotMatch": "Пароли не совпадают", + "submit": "Создать аккаунт", + "genericError": "При настройке аккаунта произошла ошибка. Попробуйте ещё раз." + }, + "invalidLink": { + "title": "Эта ссылка больше недействительна", + "description": "Срок действия ссылки истёк или она уже была использована. Введите свой email, и мы отправим вам новую.", + "emailLabel": "Email", + "resendButton": "Отправить новую ссылку", + "resendError": "Не удалось отправить письмо. Попробуйте ещё раз.", + "emailSentTitle": "Проверьте почту", + "emailSentDescription": "Если для {{email}} есть аккаунт, ожидающий настройки, вы скоро получите новую ссылку." + }, + "accountAlreadyExists": { + "title": "Ваш аккаунт уже настроен", + "description": "Этот аккаунт уже существует. Войдите, используя email и пароль.", + "loginButton": "Перейти ко входу" + } + }, "blockedAccount": { "title": "Проверьте, что это вы", "text1": "Было предпринято слишком много неудачных попыток входа в систему", diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index bb8c139ed8..32e3c9ee80 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -1567,6 +1567,30 @@ } } }, + "accountSetup": { + "form": { + "title": "設定您的帳戶", + "description": "建立密碼以完成 Internxt 帳戶的設定。", + "confirmPassword": "確認密碼", + "passwordsDoNotMatch": "密碼不相符", + "submit": "建立帳戶", + "genericError": "設定帳戶時發生錯誤,請再試一次。" + }, + "invalidLink": { + "title": "此連結已失效", + "description": "此連結已過期或已被使用。輸入您的電子郵件,我們會寄送新的連結給您。", + "emailLabel": "電子郵件", + "resendButton": "寄送新連結給我", + "resendError": "無法寄送電子郵件,請再試一次。", + "emailSentTitle": "請查看您的電子郵件", + "emailSentDescription": "如果 {{email}} 有待設定的帳戶,您很快就會收到新的連結。" + }, + "accountAlreadyExists": { + "title": "您的帳戶已設定完成", + "description": "此帳戶已存在。請使用您的電子郵件和密碼登入。", + "loginButton": "前往登入" + } + }, "blockedAccount": { "title": "驗證您的身份", "text1": "嘗試了太多次失敗的登錄。", diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index 3a3c5ae7da..3205995fd4 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -1564,6 +1564,30 @@ } } }, + "accountSetup": { + "form": { + "title": "设置您的账户", + "description": "创建密码以完成 Internxt 账户的设置。", + "confirmPassword": "确认密码", + "passwordsDoNotMatch": "密码不匹配", + "submit": "创建账户", + "genericError": "设置账户时出错,请重试。" + }, + "invalidLink": { + "title": "此链接已失效", + "description": "该链接已过期或已被使用。输入您的邮箱,我们将向您发送新链接。", + "emailLabel": "邮箱", + "resendButton": "给我发送新链接", + "resendError": "无法发送邮件,请重试。", + "emailSentTitle": "请查收邮件", + "emailSentDescription": "如果 {{email}} 有待设置的账户,您将很快收到新链接。" + }, + "accountAlreadyExists": { + "title": "您的账户已设置完成", + "description": "该账户已存在。请使用您的邮箱和密码登录。", + "loginButton": "前往登录" + } + }, "blockedAccount": { "title": "确认是你", "text1": "尝试登录失败次数过多", diff --git a/src/app/routes/paths.json b/src/app/routes/paths.json index 7ab0f7164d..f21ed58f5e 100644 --- a/src/app/routes/paths.json +++ b/src/app/routes/paths.json @@ -74,6 +74,13 @@ "path": "/recover-account/:token", "exact": true }, + { + "id": "complete-account", + "layout": "empty", + "path": "/complete-account/:token", + "exact": true, + "auth": false + }, { "id": "drive", "layout": "header-and-sidenav", diff --git a/src/views/AccountSetup/AccountSetupView.test.tsx b/src/views/AccountSetup/AccountSetupView.test.tsx new file mode 100644 index 0000000000..cfbe145a9b --- /dev/null +++ b/src/views/AccountSetup/AccountSetupView.test.tsx @@ -0,0 +1,177 @@ +import { CompleteAccountSetupPayload } from '@internxt/sdk/dist/auth/types'; +import { AxiosResponseError } from '@internxt/sdk/dist/shared/types/errors'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { AppView } from 'app/core/types'; +import { planThunks } from 'app/store/slices/plan'; +import { Buffer } from 'node:buffer'; +import { MemoryRouter, Route } from 'react-router-dom'; +import envService from 'services/env.service'; +import navigationService from 'services/navigation.service'; +import { getCompleteAccountSetupResponse } from 'testUtils/fixtures/accountSetup.fixtures'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import AccountSetupView from './AccountSetupView'; + +const { authClient } = vi.hoisted(() => ({ + authClient: { completeAccountSetup: vi.fn(), resendAccountSetupEmail: vi.fn() }, +})); + +vi.mock('app/core/factory/sdk', () => ({ + SdkFactory: { getNewApiInstance: () => ({ createAuthClient: () => authClient }) }, +})); +vi.mock('app/i18n/provider/TranslationProvider', () => ({ + useTranslationContext: () => ({ translate: (key: string) => key }), +})); +vi.mock('app/store/hooks', () => ({ useAppDispatch: () => vi.fn() })); +vi.mock('services/navigation.service', () => ({ default: { push: vi.fn(), isCurrentPath: vi.fn() } })); +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('app/analytics/impact.service', () => ({ trackSignUp: vi.fn() })); +vi.mock('app/analytics/meta.service', () => ({ trackLead: vi.fn() })); + +const SETUP_TOKEN = 'setup-token-from-email'; +const STRONG_PASSWORD = 'Correct-Horse-Battery-9!'; + +const backendError = (status: number, message: string) => + new AxiosResponseError(message, 'POST /users/pre-created-users/complete-setup', { + status, + data: { message }, + headers: {}, + } as never); + +const renderSetupLink = () => + render( + + + + + , + ); + +const typePasswords = (password: string, confirmation: string) => { + const passwordField = screen.getByPlaceholderText('auth.password'); + fireEvent.focus(passwordField); + fireEvent.change(passwordField, { target: { value: password } }); + fireEvent.change(screen.getByPlaceholderText('accountSetup.form.confirmPassword'), { + target: { value: confirmation }, + }); +}; + +const submitButton = () => screen.getByRole('button', { name: 'accountSetup.form.submit' }); + +const submitPassword = async (password = STRONG_PASSWORD) => { + typePasswords(password, password); + await waitFor(() => expect(submitButton()).toBeEnabled()); + fireEvent.click(submitButton()); +}; + +describe('Account setup from the email link', () => { + beforeEach(() => { + vi.clearAllMocks(); + globalThis.Buffer = Buffer; + globalThis.grecaptcha = { ready: (callback: () => void) => callback(), execute: async () => 'captcha' } as never; + vi.spyOn(envService, 'getVariable').mockImplementation((key) => (key === 'secret' ? 'crypto-secret' : 'value')); + authClient.completeAccountSetup.mockImplementation(async (payload: CompleteAccountSetupPayload) => + getCompleteAccountSetupResponse(payload), + ); + authClient.resendAccountSetupEmail.mockResolvedValue(undefined); + }); + + test('When the user sets and confirms a valid password, then they land in Drive with their plan loaded', async () => { + renderSetupLink(); + + await submitPassword(); + + await waitFor(() => expect(navigationService.push).toHaveBeenCalledWith(AppView.Drive)); + expect(authClient.completeAccountSetup).toHaveBeenCalledWith(expect.objectContaining({ token: SETUP_TOKEN })); + expect(planThunks.initializeThunk).toHaveBeenCalled(); + }); + + test('When the confirmation does not match the password, then the form warns and cannot be submitted', async () => { + renderSetupLink(); + + typePasswords(STRONG_PASSWORD, `${STRONG_PASSWORD}x`); + + expect(await screen.findByText('accountSetup.form.passwordsDoNotMatch')).toBeInTheDocument(); + expect(submitButton()).toBeDisabled(); + fireEvent.submit(submitButton().closest('form') as HTMLFormElement); + expect(authClient.completeAccountSetup).not.toHaveBeenCalled(); + }); + + test('When the password does not meet the requirements, then the form cannot be submitted', async () => { + renderSetupLink(); + + typePasswords('short', 'short'); + + expect(await screen.findByText('Password has to be at least 8 characters long')).toBeInTheDocument(); + expect(submitButton()).toBeDisabled(); + fireEvent.submit(submitButton().closest('form') as HTMLFormElement); + expect(authClient.completeAccountSetup).not.toHaveBeenCalled(); + }); + + test('When the link has expired, then the user is told it is no longer valid and can ask for a new one', async () => { + authClient.completeAccountSetup.mockRejectedValue(backendError(403, 'Token expired')); + renderSetupLink(); + + await submitPassword(); + expect(await screen.findByText('accountSetup.invalidLink.title')).toBeInTheDocument(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Paid-User@Internxt.com' } }); + fireEvent.click(screen.getByRole('button', { name: 'accountSetup.invalidLink.resendButton' })); + + expect(await screen.findByText('accountSetup.invalidLink.emailSentTitle')).toBeInTheDocument(); + expect(authClient.resendAccountSetupEmail).toHaveBeenCalledWith('paid-user@internxt.com'); + }); + + test('When the link was already used, then the user is told it is no longer valid', async () => { + authClient.completeAccountSetup.mockRejectedValue(backendError(403, 'Invalid token')); + renderSetupLink(); + + await submitPassword(); + + expect(await screen.findByText('accountSetup.invalidLink.title')).toBeInTheDocument(); + expect(navigationService.push).not.toHaveBeenCalled(); + }); + + test('When the captcha is rejected, then the user sees a generic error instead of an invalid link', async () => { + authClient.completeAccountSetup.mockRejectedValue(backendError(403, 'Forbidden resource')); + renderSetupLink(); + + await submitPassword(); + + expect(await screen.findByText('accountSetup.form.genericError')).toBeInTheDocument(); + expect(screen.queryByText('accountSetup.invalidLink.title')).not.toBeInTheDocument(); + }); + + test('When the account already exists, then the user is offered to go to log in', async () => { + authClient.completeAccountSetup.mockRejectedValue(backendError(409, 'User already registered')); + renderSetupLink(); + + await submitPassword(); + fireEvent.click(await screen.findByRole('button', { name: 'accountSetup.accountAlreadyExists.loginButton' })); + + expect(navigationService.push).toHaveBeenCalledWith(AppView.Login); + }); + + test('When the setup fails for another reason, then a generic error is shown and the user can retry', async () => { + authClient.completeAccountSetup.mockRejectedValueOnce(backendError(500, 'Internal Server Error')); + renderSetupLink(); + + await submitPassword(); + + expect(await screen.findByText('accountSetup.form.genericError')).toBeInTheDocument(); + await waitFor(() => expect(submitButton()).toBeEnabled()); + fireEvent.click(submitButton()); + await waitFor(() => expect(navigationService.push).toHaveBeenCalledWith(AppView.Drive)); + }); +}); diff --git a/src/views/AccountSetup/AccountSetupView.tsx b/src/views/AccountSetup/AccountSetupView.tsx new file mode 100644 index 0000000000..47ff89d095 --- /dev/null +++ b/src/views/AccountSetup/AccountSetupView.tsx @@ -0,0 +1,19 @@ +import { useParams } from 'react-router-dom'; +import { AccountAlreadyExistsMessage } from './components/AccountAlreadyExistsMessage'; +import { AccountSetupForm } from './components/AccountSetupForm'; +import { AccountSetupLayout } from './components/AccountSetupLayout'; +import { InvalidLinkMessage } from './components/InvalidLinkMessage'; +import { AccountSetupStatus, useAccountSetup } from './hooks/useAccountSetup'; + +export default function AccountSetupView(): JSX.Element { + const { token } = useParams<{ token: string }>(); + const { status, isSubmitting, hasSubmitFailed, setUpAccount } = useAccountSetup(token); + + const contentByStatus: Record = { + form: , + invalidLink: , + accountAlreadyExists: , + }; + + return {contentByStatus[status]}; +} diff --git a/src/views/AccountSetup/components/AccountAlreadyExistsMessage.tsx b/src/views/AccountSetup/components/AccountAlreadyExistsMessage.tsx new file mode 100644 index 0000000000..721fd36965 --- /dev/null +++ b/src/views/AccountSetup/components/AccountAlreadyExistsMessage.tsx @@ -0,0 +1,20 @@ +import { Button } from '@internxt/ui'; +import { AppView } from 'app/core/types'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import navigationService from 'services/navigation.service'; + +export const AccountAlreadyExistsMessage = (): JSX.Element => { + const { translate } = useTranslationContext(); + + return ( +
+
+

{translate('accountSetup.accountAlreadyExists.title')}

+

{translate('accountSetup.accountAlreadyExists.description')}

+
+ +
+ ); +}; diff --git a/src/views/AccountSetup/components/AccountSetupForm.tsx b/src/views/AccountSetup/components/AccountSetupForm.tsx new file mode 100644 index 0000000000..114d6ee1d6 --- /dev/null +++ b/src/views/AccountSetup/components/AccountSetupForm.tsx @@ -0,0 +1,109 @@ +import { Button } from '@internxt/ui'; +import { WarningCircle } from '@phosphor-icons/react'; +import { IFormValues } from 'app/core/types'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import PasswordInput from 'components/PasswordInput'; +import { MAX_PASSWORD_LENGTH } from 'components/ValidPassword'; +import { useEffect, useState } from 'react'; +import { isMobile } from 'react-device-detect'; +import { useForm, useWatch } from 'react-hook-form'; +import PasswordFieldWithInfo from 'views/Signup/components/PasswordFieldWithInfo'; +import { PasswordState } from 'views/Signup/hooks/useGuestSignupState'; +import { onChangePasswordHandler } from 'views/Signup/utils'; + +interface AccountSetupFormProps { + isSubmitting: boolean; + hasSubmitFailed: boolean; + onSubmit: (password: string) => void; +} + +const FieldError = ({ message }: Readonly<{ message: string }>): JSX.Element => ( +
+
+ +
+ {message} +
+); + +export const AccountSetupForm = ({ + isSubmitting, + hasSubmitFailed, + onSubmit, +}: Readonly): JSX.Element => { + const { translate } = useTranslationContext(); + const [isValidPassword, setIsValidPassword] = useState(false); + const [passwordState, setPasswordState] = useState(null); + const [showPasswordIndicator, setShowPasswordIndicator] = useState(false); + + const { + register, + handleSubmit, + control, + formState: { errors }, + } = useForm({ mode: 'onChange', defaultValues: { password: '', confirmPassword: '' } }); + const password = useWatch({ control, name: 'password', defaultValue: '' }); + const confirmPassword = useWatch({ control, name: 'confirmPassword', defaultValue: '' }); + + useEffect(() => { + if (password.length > 0) onChangePasswordHandler({ password, setIsValidPassword, setPasswordState }); + }, [password]); + + const doPasswordsMatch = password === confirmPassword; + const isMismatchVisible = confirmPassword.length > 0 && !doPasswordsMatch; + const canSubmit = isValidPassword && doPasswordsMatch && !isSubmitting; + + const submitPassword = (formData: IFormValues) => { + if (canSubmit) onSubmit(formData.password); + }; + + return ( +
+
+

{translate('accountSetup.form.title')}

+

{translate('accountSetup.form.description')}

+
+ +
+ + + + + + + {hasSubmitFailed && } +
+ + + {translate('auth.terms1')}{' '} + {isMobile ? ( + {translate('auth.terms2')} + ) : ( + + {translate('auth.terms2')} + + )} + +
+ ); +}; diff --git a/src/views/AccountSetup/components/AccountSetupLayout.tsx b/src/views/AccountSetup/components/AccountSetupLayout.tsx new file mode 100644 index 0000000000..19ef0fb6fe --- /dev/null +++ b/src/views/AccountSetup/components/AccountSetupLayout.tsx @@ -0,0 +1,39 @@ +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import InternxtLogo from 'assets/icons/big-logo.svg?react'; +import { ReactNode } from 'react'; +import { isMobile } from 'react-device-detect'; + +export const AccountSetupLayout = ({ children }: Readonly<{ children: ReactNode }>): JSX.Element => { + const { translate } = useTranslationContext(); + + return ( +
+
+ +
+ +
+
{children}
+
+ + +
+ ); +}; diff --git a/src/views/AccountSetup/components/InvalidLinkMessage.tsx b/src/views/AccountSetup/components/InvalidLinkMessage.tsx new file mode 100644 index 0000000000..8bf16ffef5 --- /dev/null +++ b/src/views/AccountSetup/components/InvalidLinkMessage.tsx @@ -0,0 +1,46 @@ +import isValidEmail from '@internxt/lib/dist/auth/isValidEmail'; +import { Button, Input } from '@internxt/ui'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { FormEvent, useState } from 'react'; +import { useResendAccountSetupEmail } from '../hooks/useResendAccountSetupEmail'; + +export const InvalidLinkMessage = (): JSX.Element => { + const { translate } = useTranslationContext(); + const [email, setEmail] = useState(''); + const { isSending, isEmailSent, resendEmail } = useResendAccountSetupEmail(); + + const submitEmail = (event: FormEvent) => { + event.preventDefault(); + resendEmail(email); + }; + + if (isEmailSent) { + return ( +
+

{translate('accountSetup.invalidLink.emailSentTitle')}

+

+ {translate('accountSetup.invalidLink.emailSentDescription', { email })} +

+
+ ); + } + + return ( +
+
+

{translate('accountSetup.invalidLink.title')}

+

{translate('accountSetup.invalidLink.description')}

+
+ + +
+ ); +}; diff --git a/src/views/AccountSetup/index.ts b/src/views/AccountSetup/index.ts new file mode 100644 index 0000000000..ec90581d71 --- /dev/null +++ b/src/views/AccountSetup/index.ts @@ -0,0 +1 @@ +export { default } from './AccountSetupView'; From 058e7dfa63438abeb4b17cd23e2f5060e5d8c68e Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Mon, 5 Oct 2026 13:15:02 +0200 Subject: [PATCH 07/13] refactor: rename component --- .../services/completeAccountSetup.test.ts | 11 ++++++++++- .../AccountSetup/services/completeAccountSetup.ts | 4 +++- src/views/Checkout/views/CheckoutSuccessView.tsx | 9 +++------ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/views/AccountSetup/services/completeAccountSetup.test.ts b/src/views/AccountSetup/services/completeAccountSetup.test.ts index 9c81b2f688..004ce7f9ff 100644 --- a/src/views/AccountSetup/services/completeAccountSetup.test.ts +++ b/src/views/AccountSetup/services/completeAccountSetup.test.ts @@ -1,5 +1,6 @@ import { CompleteAccountSetupPayload } from '@internxt/sdk/dist/auth/types'; import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; +import { LocalStorageItem } from 'app/core/types'; import { decryptText, decryptTextWithKey, passToHash } from 'app/crypto/services/utils'; import { planThunks } from 'app/store/slices/plan'; import { userThunks } from 'app/store/slices/user'; @@ -7,6 +8,7 @@ import { validateMnemonic } from 'bip39'; import { Buffer } from 'node:buffer'; import encryptedStorageService from 'services/encrypted-storage.service'; import envService from 'services/env.service'; +import localStorageService from 'services/local-storage.service'; import { getCompleteAccountSetupResponse } from 'testUtils/fixtures/accountSetup.fixtures'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { completeAccountSetup } from './completeAccountSetup'; @@ -23,7 +25,7 @@ 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() }, + default: { get: vi.fn(), set: vi.fn(), clear: vi.fn(), removeItem: vi.fn() }, })); vi.mock('app/store/slices/user', () => ({ initializeUserThunk: vi.fn(), @@ -101,5 +103,12 @@ describe('Completing the setup of a paid account', () => { ); expect(encryptedStorageService.setToken).not.toHaveBeenCalled(); expect(userThunks.setUserThunk).not.toHaveBeenCalled(); + expect(localStorageService.removeItem).not.toHaveBeenCalled(); + }); + + test('When the account is set up, then the checkout "check your email" screen is no longer shown on a later visit', async () => { + await completeAccountSetup({ setupToken: SETUP_TOKEN, password: PASSWORD, dispatch: vi.fn() }); + + expect(localStorageService.removeItem).toHaveBeenCalledWith(LocalStorageItem.CheckoutAccountSetupEmail); }); }); diff --git a/src/views/AccountSetup/services/completeAccountSetup.ts b/src/views/AccountSetup/services/completeAccountSetup.ts index 17df26a60c..6595992209 100644 --- a/src/views/AccountSetup/services/completeAccountSetup.ts +++ b/src/views/AccountSetup/services/completeAccountSetup.ts @@ -20,7 +20,7 @@ export const completeAccountSetup = async ({ setupToken, password, dispatch }: C const { user, token, newToken } = await authClient.completeAccountSetup({ token: setupToken, ...credentials }); const mnemonic = decryptTextWithKey(user.mnemonic, password); - return startNewAccountSession({ + const session = await startNewAccountSession({ registeredUser: { xUser: { ...user, mnemonic } as unknown as UserSettings, xToken: token, @@ -31,4 +31,6 @@ export const completeAccountSetup = async ({ setupToken, password, dispatch }: C redeemCodeObject: false, dispatch, }); + + return session; }; diff --git a/src/views/Checkout/views/CheckoutSuccessView.tsx b/src/views/Checkout/views/CheckoutSuccessView.tsx index bf4cecfdb5..3c2a74a731 100644 --- a/src/views/Checkout/views/CheckoutSuccessView.tsx +++ b/src/views/Checkout/views/CheckoutSuccessView.tsx @@ -23,7 +23,7 @@ export function removePaymentsStorage() { localStorageService.removeItem(LocalStorageItem.CouponCode); } -const CheckYourEmail = ({ email }: { email: string }) => { +const AccountSetupEmailSentCard = ({ email }: { email: string }) => { const { translate } = useTranslationContext(); return ( @@ -50,6 +50,7 @@ const CheckoutSuccessView = (): JSX.Element => { hasSession ? null : localStorageService.get(LocalStorageItem.CheckoutAccountSetupEmail), ); + // `startNewAccountSession` already clears the storage, so no need to clear the CheckoutAccountSetupEmail variable here const onCheckoutSuccess = useCallback(async () => { if (hasTrackedRef.current) { return; @@ -71,10 +72,6 @@ const CheckoutSuccessView = (): JSX.Element => { return; } - if (hasSession) { - localStorageService.removeItem(LocalStorageItem.CheckoutAccountSetupEmail); - } - userStoragePolling(); navigationService.push(AppView.Drive); @@ -83,7 +80,7 @@ const CheckoutSuccessView = (): JSX.Element => { useEffectAsync(onCheckoutSuccess, []); if (accountSetupEmail) { - return ; + return ; } return
; From 63da712886e9d51ab528f2ac9f81ea2d00606843 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 6 Oct 2026 12:50:27 +0200 Subject: [PATCH 08/13] fix: icon name and use arrow function instead --- src/views/AccountSetup/AccountSetupView.tsx | 6 ++++-- src/views/AccountSetup/components/AccountSetupForm.tsx | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/views/AccountSetup/AccountSetupView.tsx b/src/views/AccountSetup/AccountSetupView.tsx index 47ff89d095..1d63d81b28 100644 --- a/src/views/AccountSetup/AccountSetupView.tsx +++ b/src/views/AccountSetup/AccountSetupView.tsx @@ -5,7 +5,7 @@ import { AccountSetupLayout } from './components/AccountSetupLayout'; import { InvalidLinkMessage } from './components/InvalidLinkMessage'; import { AccountSetupStatus, useAccountSetup } from './hooks/useAccountSetup'; -export default function AccountSetupView(): JSX.Element { +const AccountSetupView = (): JSX.Element => { const { token } = useParams<{ token: string }>(); const { status, isSubmitting, hasSubmitFailed, setUpAccount } = useAccountSetup(token); @@ -16,4 +16,6 @@ export default function AccountSetupView(): JSX.Element { }; return {contentByStatus[status]}; -} +}; + +export default AccountSetupView; diff --git a/src/views/AccountSetup/components/AccountSetupForm.tsx b/src/views/AccountSetup/components/AccountSetupForm.tsx index 114d6ee1d6..9fc25882ce 100644 --- a/src/views/AccountSetup/components/AccountSetupForm.tsx +++ b/src/views/AccountSetup/components/AccountSetupForm.tsx @@ -1,5 +1,5 @@ import { Button } from '@internxt/ui'; -import { WarningCircle } from '@phosphor-icons/react'; +import { WarningCircleIcon } from '@phosphor-icons/react'; import { IFormValues } from 'app/core/types'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import PasswordInput from 'components/PasswordInput'; @@ -20,7 +20,7 @@ interface AccountSetupFormProps { const FieldError = ({ message }: Readonly<{ message: string }>): JSX.Element => (
- +
{message}
From 7bc9e4391e3b87697430a866d7ac2f5df332f32f Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 6 Oct 2026 13:19:31 +0200 Subject: [PATCH 09/13] chore(deps): bump SDK version --- package.json | 2 +- src/app/i18n/locales/de.json | 2 +- src/app/i18n/locales/en.json | 2 +- src/app/i18n/locales/es.json | 2 +- src/app/i18n/locales/fr.json | 2 +- src/app/i18n/locales/it.json | 2 +- src/app/i18n/locales/pt.json | 73 ++++++++++++++++++++++++++++++++++- src/app/i18n/locales/ru.json | 2 +- src/app/i18n/locales/tw.json | 2 +- src/app/i18n/locales/zh.json | 2 +- src/views/Drive/DriveView.tsx | 19 +++++++-- yarn.lock | 8 ++-- 12 files changed, 101 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index c18cbb8ba3..26027b493c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "@iconscout/react-unicons": "^2.2.5", "@internxt/css-config": "1.1.1", "@internxt/lib": "=1.5.1", - "@internxt/sdk": "1.21.1", + "@internxt/sdk": "1.22.1", "@internxt/ui": "=0.1.21", "@marsidev/react-turnstile": "^1.6.1", "@phosphor-icons/react": "^2.1.10", diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 99b60d1e2e..f473857f5c 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -57,7 +57,7 @@ "auth": { "accountSetupPending": { "title": "Schließen Sie die Einrichtung Ihres Kontos ab", - "message": "Ihr Konto ist bereits bezahlt. Öffnen Sie die E-Mail, die wir Ihnen gesendet haben, um Ihr Passwort festzulegen und die Einrichtung abzuschließen.", + "message": "Sie haben mit dieser E-Mail bereits einen Tarif gekauft. Verwenden Sie den Link, den wir Ihnen gesendet haben, um Ihr Passwort festzulegen.", "resend": "E-Mail erneut senden", "emailSent": "E-Mail gesendet. Sehen Sie in Ihrem Posteingang nach.", "resendFailed": "Wir konnten die E-Mail nicht senden. Bitte versuchen Sie es erneut." diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 291f218691..1ea48ceea2 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -78,7 +78,7 @@ "auth": { "accountSetupPending": { "title": "Finish setting up your account", - "message": "Your account is already paid. Open the email we sent you to set your password and finish setting it up.", + "message": "You already bought a plan with this email. Use the link we sent you to create your password.", "resend": "Resend the email", "emailSent": "Email sent. Check your inbox.", "resendFailed": "We couldn't send the email. Please try again." diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index ec70d43807..9454ca9fd0 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -57,7 +57,7 @@ "auth": { "accountSetupPending": { "title": "Termina de configurar tu cuenta", - "message": "Tu cuenta ya está pagada. Abre el correo que te enviamos para crear tu contraseña y terminar de configurarla.", + "message": "Ya has comprado un plan con este email. Usa el enlace que te enviamos para crear tu contraseña.", "resend": "Reenviar el correo", "emailSent": "Correo enviado. Revisa tu bandeja de entrada.", "resendFailed": "No hemos podido enviar el correo. Inténtalo de nuevo." diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 944e5a2d14..65e5b4910a 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -57,7 +57,7 @@ "auth": { "accountSetupPending": { "title": "Terminez la configuration de votre compte", - "message": "Votre compte est déjà payé. Ouvrez l'e-mail que nous vous avons envoyé pour définir votre mot de passe et terminer sa configuration.", + "message": "Vous avez déjà acheté un forfait avec cet e-mail. Utilisez le lien que nous vous avons envoyé pour créer votre mot de passe.", "resend": "Renvoyer l'e-mail", "emailSent": "E-mail envoyé. Consultez votre boîte de réception.", "resendFailed": "Nous n'avons pas pu envoyer l'e-mail. Veuillez réessayer." diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 3cf4a78885..0d1da831f0 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -78,7 +78,7 @@ "auth": { "accountSetupPending": { "title": "Completa la configurazione del tuo account", - "message": "Il tuo account è già pagato. Apri l'email che ti abbiamo inviato per impostare la password e completare la configurazione.", + "message": "Hai già acquistato un piano con questa email. Usa il link che ti abbiamo inviato per creare la tua password.", "resend": "Invia di nuovo l'email", "emailSent": "Email inviata. Controlla la tua casella di posta.", "resendFailed": "Non è stato possibile inviare l'email. Riprova." diff --git a/src/app/i18n/locales/pt.json b/src/app/i18n/locales/pt.json index 9f7f950477..ae273643c1 100644 --- a/src/app/i18n/locales/pt.json +++ b/src/app/i18n/locales/pt.json @@ -76,6 +76,13 @@ "environment": "Dia Mundial do Meio Ambiente" }, "auth": { + "accountSetupPending": { + "title": "Conclua a configuração da sua conta", + "message": "Você já comprou um plano com este e-mail. Use o link que enviamos para criar sua senha.", + "resend": "Reenviar o e-mail", + "emailSent": "E-mail enviado. Verifique sua caixa de entrada.", + "resendFailed": "Não foi possível enviar o e-mail. Tente novamente." + }, "emailFloatingLabel": "Endereço de e-mail", "email": "Endereço de e-mail", "passwordFloatingLabel": "Senha", @@ -291,7 +298,70 @@ "businessPlan": "O plano empresarial não está disponível", "postalCodeRequired": "Informe seu CEP para continuar", "countryRequired": "Não foi possível determinar seu país de cobrança. Tente novamente mais tarde.", - "missingPaymentDetails": "Faltam dados de pagamento para confirmar a transação" + "missingPaymentDetails": "Faltam dados de pagamento para confirmar a transação", + "paymentFailed": "Falha no pagamento" + } + }, + "urgentCheckout": { + "header": { + "tagline": "Sua nuvem privada", + "europeanBased": "Com sede na Europa", + "gdprCompliant": "Em conformidade com o RGPD", + "trustedByMillions": "Milhões confiam em nós" + }, + "hero": { + "offerPill": "{{percent}}% OFF — Oferta exclusiva por tempo limitado", + "offerPillNoDiscount": "Oferta exclusiva por tempo limitado", + "title": "Mantenha seus arquivos, fotos e vídeos", + "titleHighlight": "100% privados.", + "subtitle": "Tenha {{storage}} de armazenamento em nuvem privada por apenas {{price}} no primeiro mês.", + "imageAlt": "Internxt Drive em um notebook e em um celular", + "points": { + "privacy": { + "title": "Só você pode acessar seus arquivos", + "description": "A criptografia de conhecimento zero faz com que nem mesmo a Internxt possa acessar seu conteúdo." + }, + "storage": { + "title": "Sua nuvem privada, protegida", + "description": "Guarde suas fotos, vídeos, documentos e arquivos pessoais com privacidade integrada." + }, + "offer": { + "title": "{{percent}}% OFF — por tempo limitado", + "titleNoDiscount": "Oferta exclusiva — por tempo limitado", + "description": "Tenha {{storage}} por apenas {{price}}. Esta oferta exclusiva não vai durar." + } + }, + "badges": { + "basedInEu": "Com sede na UE", + "openSource": "Código aberto", + "audited": "Auditado pela Securitum", + "privacyPriority": "Sua privacidade. Nossa prioridade." + } + }, + "countdown": { + "title": "Sua oferta exclusiva de {{percent}}% OFF expira em:", + "titleNoDiscount": "Sua oferta exclusiva expira em:", + "footer": "Tenha {{storage}} por {{price}} antes que esta oferta acabe." + }, + "summary": { + "discountApplied": "{{percent}}% OFF aplicado", + "estimatedTax": "Impostos estimados" + }, + "form": { + "emailLabel": "Seu endereço de e-mail", + "emailPlaceholder": "voce@email.com", + "createPasswordLabel": "Crie uma senha", + "passwordLabel": "Sua senha", + "passwordPlaceholder": "Senha", + "togglePasswordVisibility": "Mostrar ou ocultar a senha", + "incompleteDetails": "Informe seu e-mail e sua senha para continuar.", + "encryptedTitle": "Seus arquivos são criptografados e privados.", + "encryptedDescription": "A Internxt não pode acessar seu conteúdo." + }, + "cta": { + "button": "Tenha {{storage}} por {{price}}", + "renewalNoteMonthly": "Primeiro mês por {{price}}, depois renova por {{renewalPrice}}/mês. Cancele quando quiser.", + "renewalNoteYearly": "Primeiro ano por {{price}}, depois renova por {{renewalPrice}}/ano. Cancele quando quiser." } }, "general": { @@ -1102,6 +1172,7 @@ }, "storageIsFullDescription": "Você não pode enviar, sincronizar nem fazer backup de arquivos.", "noFilePreview": "Não há pré-visualização disponível para este arquivo", + "filePreviewTooLarge": "Arquivo grande demais para visualizar (mais de {{limit}})", "errorMovingToTrash": "Ocorreu um erro ao mover os itens para a lixeira.", "errorDeletingFromTrash": "Ocorreu um erro ao excluir os itens da lixeira.", "connectionLostError": "Conexão com a internet perdida", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 0a449d07bd..374031a394 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -57,7 +57,7 @@ "auth": { "accountSetupPending": { "title": "Завершите настройку аккаунта", - "message": "Ваш аккаунт уже оплачен. Откройте письмо, которое мы вам отправили, чтобы задать пароль и завершить настройку.", + "message": "Вы уже купили тариф с этим адресом. Перейдите по ссылке из нашего письма, чтобы задать пароль.", "resend": "Отправить письмо ещё раз", "emailSent": "Письмо отправлено. Проверьте почту.", "resendFailed": "Не удалось отправить письмо. Попробуйте ещё раз." diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index b3d965447e..f6f11149fb 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -78,7 +78,7 @@ "auth": { "accountSetupPending": { "title": "完成帳戶設定", - "message": "您的帳戶已付款。請開啟我們寄給您的電子郵件,設定密碼並完成帳戶設定。", + "message": "您已使用此電子郵件購買方案。請使用我們寄給您的連結設定密碼。", "resend": "重新寄送電子郵件", "emailSent": "電子郵件已寄出,請查看收件匣。", "resendFailed": "無法寄送電子郵件,請再試一次。" diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index 3dc382b3ec..6243884deb 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -78,7 +78,7 @@ "auth": { "accountSetupPending": { "title": "完成账户设置", - "message": "您的账户已付款。请打开我们发送给您的邮件,设置密码并完成账户设置。", + "message": "您已使用此邮箱购买了套餐。请使用我们发送给您的链接设置密码。", "resend": "重新发送邮件", "emailSent": "邮件已发送,请查收收件箱。", "resendFailed": "无法发送邮件,请重试。" diff --git a/src/views/Drive/DriveView.tsx b/src/views/Drive/DriveView.tsx index a91a99a13d..38d438dec1 100644 --- a/src/views/Drive/DriveView.tsx +++ b/src/views/Drive/DriveView.tsx @@ -26,7 +26,20 @@ import DriveExplorer from 'views/Drive/components/DriveExplorer/DriveExplorer'; import encryptedStorageService from 'services/encrypted-storage.service'; import { Loader } from '@internxt/ui'; -type FileMetaWithThumbnails = FileMeta & Partial>; +type DriveThumbnail = DriveFileData['thumbnails'][number]; +type FileMetaThumbnail = NonNullable[number]; + +const toDriveThumbnail = (thumbnail: FileMetaThumbnail): DriveThumbnail => ({ + id: thumbnail.id, + file_id: thumbnail.fileId, + type: thumbnail.type, + max_width: thumbnail.maxWidth, + max_height: thumbnail.maxHeight, + size: thumbnail.size, + bucket_id: thumbnail.bucketId, + bucket_file: thumbnail.bucketFile, + encrypt_version: thumbnail.encryptVersion, +}); export interface DriveViewProps { namePath: FolderPath[]; @@ -157,7 +170,7 @@ const DriveView = (props: DriveViewProps) => { const showFile = async (fileUUID: string, workspacesToken?: string) => { try { - const fileMeta: FileMetaWithThumbnails = await fileService.getFile(fileUUID, workspacesToken); + const fileMeta = await fileService.getFile(fileUUID, workspacesToken); const listedFile = folderLevels[fileMeta.folderUuid]?.find((item) => item.uuid === fileMeta.uuid); dispatch(uiActions.setIsFileViewerOpen(true)); /* @@ -186,7 +199,7 @@ const DriveView = (props: DriveViewProps) => { status: fileMeta.status, uuid: fileMeta.uuid, updatedAt: fileMeta.updatedAt, - thumbnails: listedFile?.thumbnails ?? fileMeta.thumbnails ?? [], + thumbnails: listedFile?.thumbnails ?? fileMeta.thumbnails?.map(toDriveThumbnail) ?? [], currentThumbnail: listedFile?.currentThumbnail ?? null, }), ); diff --git a/yarn.lock b/yarn.lock index dc6d34e556..54a07ed4d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1939,10 +1939,10 @@ resolved "https://registry.yarnpkg.com/@internxt/prettier-config/-/prettier-config-2.0.1.tgz#a25e7a16f62a990aed2bd1804e04140cc8c33e77" integrity sha512-sCnOMX7GR7BYDJ5OH1RiV/JaOBLE3uhL6QN6tYhMKi7QXK/cnM/77qmDlIEBlhTNIFEMLFljSkc0vZEvf6m2xA== -"@internxt/sdk@1.21.1": - version "1.21.1" - resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.21.1.tgz#4f6ee22a7815e05f2e6d4b8f528b117d738ed37f" - integrity sha512-Pw5bq4j/hmgT5ANDr88S7mUbSsNSPdwzr1m+a92pVa6I2GYYA2+erWI4eKHZpXadylP4o5Yst0pn9W2nbKRQ6Q== +"@internxt/sdk@1.22.1": + version "1.22.1" + resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.22.1.tgz#8e197658f33f293d62db44e02eebafe2a8c9f7ce" + integrity sha512-O5J/3BwSSPFQefSzb4LbQGVIuzxb8sR7B0tbb3XmjXV90EcWXpqIpixF35mLijab/+0ffa2MbnNyPB4n6I9F/Q== dependencies: axios "^1.16.0" From ea032facbc1258e22ec8f3db98e7ca8fe6f927d8 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 6 Oct 2026 13:30:58 +0200 Subject: [PATCH 10/13] chore: update SDK verison --- package.json | 2 +- src/views/Drive/DriveView.tsx | 19 ++++++++++++++++--- yarn.lock | 8 ++++---- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index c18cbb8ba3..26027b493c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "@iconscout/react-unicons": "^2.2.5", "@internxt/css-config": "1.1.1", "@internxt/lib": "=1.5.1", - "@internxt/sdk": "1.21.1", + "@internxt/sdk": "1.22.1", "@internxt/ui": "=0.1.21", "@marsidev/react-turnstile": "^1.6.1", "@phosphor-icons/react": "^2.1.10", diff --git a/src/views/Drive/DriveView.tsx b/src/views/Drive/DriveView.tsx index a91a99a13d..38d438dec1 100644 --- a/src/views/Drive/DriveView.tsx +++ b/src/views/Drive/DriveView.tsx @@ -26,7 +26,20 @@ import DriveExplorer from 'views/Drive/components/DriveExplorer/DriveExplorer'; import encryptedStorageService from 'services/encrypted-storage.service'; import { Loader } from '@internxt/ui'; -type FileMetaWithThumbnails = FileMeta & Partial>; +type DriveThumbnail = DriveFileData['thumbnails'][number]; +type FileMetaThumbnail = NonNullable[number]; + +const toDriveThumbnail = (thumbnail: FileMetaThumbnail): DriveThumbnail => ({ + id: thumbnail.id, + file_id: thumbnail.fileId, + type: thumbnail.type, + max_width: thumbnail.maxWidth, + max_height: thumbnail.maxHeight, + size: thumbnail.size, + bucket_id: thumbnail.bucketId, + bucket_file: thumbnail.bucketFile, + encrypt_version: thumbnail.encryptVersion, +}); export interface DriveViewProps { namePath: FolderPath[]; @@ -157,7 +170,7 @@ const DriveView = (props: DriveViewProps) => { const showFile = async (fileUUID: string, workspacesToken?: string) => { try { - const fileMeta: FileMetaWithThumbnails = await fileService.getFile(fileUUID, workspacesToken); + const fileMeta = await fileService.getFile(fileUUID, workspacesToken); const listedFile = folderLevels[fileMeta.folderUuid]?.find((item) => item.uuid === fileMeta.uuid); dispatch(uiActions.setIsFileViewerOpen(true)); /* @@ -186,7 +199,7 @@ const DriveView = (props: DriveViewProps) => { status: fileMeta.status, uuid: fileMeta.uuid, updatedAt: fileMeta.updatedAt, - thumbnails: listedFile?.thumbnails ?? fileMeta.thumbnails ?? [], + thumbnails: listedFile?.thumbnails ?? fileMeta.thumbnails?.map(toDriveThumbnail) ?? [], currentThumbnail: listedFile?.currentThumbnail ?? null, }), ); diff --git a/yarn.lock b/yarn.lock index dc6d34e556..54a07ed4d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1939,10 +1939,10 @@ resolved "https://registry.yarnpkg.com/@internxt/prettier-config/-/prettier-config-2.0.1.tgz#a25e7a16f62a990aed2bd1804e04140cc8c33e77" integrity sha512-sCnOMX7GR7BYDJ5OH1RiV/JaOBLE3uhL6QN6tYhMKi7QXK/cnM/77qmDlIEBlhTNIFEMLFljSkc0vZEvf6m2xA== -"@internxt/sdk@1.21.1": - version "1.21.1" - resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.21.1.tgz#4f6ee22a7815e05f2e6d4b8f528b117d738ed37f" - integrity sha512-Pw5bq4j/hmgT5ANDr88S7mUbSsNSPdwzr1m+a92pVa6I2GYYA2+erWI4eKHZpXadylP4o5Yst0pn9W2nbKRQ6Q== +"@internxt/sdk@1.22.1": + version "1.22.1" + resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.22.1.tgz#8e197658f33f293d62db44e02eebafe2a8c9f7ce" + integrity sha512-O5J/3BwSSPFQefSzb4LbQGVIuzxb8sR7B0tbb3XmjXV90EcWXpqIpixF35mLijab/+0ffa2MbnNyPB4n6I9F/Q== dependencies: axios "^1.16.0" From eac761d5c1c9ec722b527f50fda3e4b739780777 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 6 Oct 2026 13:33:28 +0200 Subject: [PATCH 11/13] feat: missing pt keys --- src/app/i18n/locales/pt.json | 66 +++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/app/i18n/locales/pt.json b/src/app/i18n/locales/pt.json index 9f7f950477..6723cf8594 100644 --- a/src/app/i18n/locales/pt.json +++ b/src/app/i18n/locales/pt.json @@ -291,7 +291,70 @@ "businessPlan": "O plano empresarial não está disponível", "postalCodeRequired": "Informe seu CEP para continuar", "countryRequired": "Não foi possível determinar seu país de cobrança. Tente novamente mais tarde.", - "missingPaymentDetails": "Faltam dados de pagamento para confirmar a transação" + "missingPaymentDetails": "Faltam dados de pagamento para confirmar a transação", + "paymentFailed": "Falha no pagamento" + } + }, + "urgentCheckout": { + "header": { + "tagline": "Sua nuvem privada", + "europeanBased": "Com sede na Europa", + "gdprCompliant": "Em conformidade com o RGPD", + "trustedByMillions": "Milhões confiam em nós" + }, + "hero": { + "offerPill": "{{percent}}% OFF — Oferta exclusiva por tempo limitado", + "offerPillNoDiscount": "Oferta exclusiva por tempo limitado", + "title": "Mantenha seus arquivos, fotos e vídeos", + "titleHighlight": "100% privados.", + "subtitle": "Tenha {{storage}} de armazenamento em nuvem privada por apenas {{price}} no primeiro mês.", + "imageAlt": "Internxt Drive em um notebook e em um celular", + "points": { + "privacy": { + "title": "Só você pode acessar seus arquivos", + "description": "A criptografia de conhecimento zero faz com que nem mesmo a Internxt possa acessar seu conteúdo." + }, + "storage": { + "title": "Sua nuvem privada, protegida", + "description": "Guarde suas fotos, vídeos, documentos e arquivos pessoais com privacidade integrada." + }, + "offer": { + "title": "{{percent}}% OFF — por tempo limitado", + "titleNoDiscount": "Oferta exclusiva — por tempo limitado", + "description": "Tenha {{storage}} por apenas {{price}}. Esta oferta exclusiva não vai durar." + } + }, + "badges": { + "basedInEu": "Com sede na UE", + "openSource": "Código aberto", + "audited": "Auditado pela Securitum", + "privacyPriority": "Sua privacidade. Nossa prioridade." + } + }, + "countdown": { + "title": "Sua oferta exclusiva de {{percent}}% OFF expira em:", + "titleNoDiscount": "Sua oferta exclusiva expira em:", + "footer": "Tenha {{storage}} por {{price}} antes que esta oferta acabe." + }, + "summary": { + "discountApplied": "{{percent}}% OFF aplicado", + "estimatedTax": "Impostos estimados" + }, + "form": { + "emailLabel": "Seu endereço de e-mail", + "emailPlaceholder": "voce@email.com", + "createPasswordLabel": "Crie uma senha", + "passwordLabel": "Sua senha", + "passwordPlaceholder": "Senha", + "togglePasswordVisibility": "Mostrar ou ocultar a senha", + "incompleteDetails": "Informe seu e-mail e sua senha para continuar.", + "encryptedTitle": "Seus arquivos são criptografados e privados.", + "encryptedDescription": "A Internxt não pode acessar seu conteúdo." + }, + "cta": { + "button": "Tenha {{storage}} por {{price}}", + "renewalNoteMonthly": "Primeiro mês por {{price}}, depois renova por {{renewalPrice}}/mês. Cancele quando quiser.", + "renewalNoteYearly": "Primeiro ano por {{price}}, depois renova por {{renewalPrice}}/ano. Cancele quando quiser." } }, "general": { @@ -1102,6 +1165,7 @@ }, "storageIsFullDescription": "Você não pode enviar, sincronizar nem fazer backup de arquivos.", "noFilePreview": "Não há pré-visualização disponível para este arquivo", + "filePreviewTooLarge": "Arquivo grande demais para visualizar (mais de {{limit}})", "errorMovingToTrash": "Ocorreu um erro ao mover os itens para a lixeira.", "errorDeletingFromTrash": "Ocorreu um erro ao excluir os itens da lixeira.", "connectionLostError": "Conexão com a internet perdida", From 48c50d56b6bb55963aa9f8d90e2805d96b974304 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 6 Oct 2026 16:50:27 +0200 Subject: [PATCH 12/13] fix: sonar issues --- .../AccountSetup/components/InvalidLinkMessage.tsx | 2 +- .../Login/components/AccountSetupPendingNotice.tsx | 10 +++++----- src/views/Signup/ShareGuestSignUpView.test.tsx | 2 ++ src/views/Signup/WorkspaceGuestSignUpView.test.tsx | 2 ++ 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/views/AccountSetup/components/InvalidLinkMessage.tsx b/src/views/AccountSetup/components/InvalidLinkMessage.tsx index 8bf16ffef5..3d70b32914 100644 --- a/src/views/AccountSetup/components/InvalidLinkMessage.tsx +++ b/src/views/AccountSetup/components/InvalidLinkMessage.tsx @@ -11,7 +11,7 @@ export const InvalidLinkMessage = (): JSX.Element => { const submitEmail = (event: FormEvent) => { event.preventDefault(); - resendEmail(email); + void resendEmail(email); }; if (isEmailSent) { diff --git a/src/views/Login/components/AccountSetupPendingNotice.tsx b/src/views/Login/components/AccountSetupPendingNotice.tsx index 5d686e5bbd..813395c192 100644 --- a/src/views/Login/components/AccountSetupPendingNotice.tsx +++ b/src/views/Login/components/AccountSetupPendingNotice.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { Button } from '@internxt/ui'; -import { CheckCircle, WarningCircle } from '@phosphor-icons/react'; +import { CheckCircleIcon, WarningCircleIcon } from '@phosphor-icons/react'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { resendAccountSetupEmail } from 'services/account-setup.service'; @@ -31,7 +31,7 @@ export const AccountSetupPendingNotice = ({ email }: AccountSetupPendingNoticePr
- +
{translate('auth.accountSetupPending.title')} @@ -40,10 +40,10 @@ export const AccountSetupPendingNotice = ({ email }: AccountSetupPendingNoticePr
{resendStatus === 'sent' ? ( -
- + + {translate('auth.accountSetupPending.emailSent')} -
+ ) : (