diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index d6d38cd68d..53058b55bb 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -55,6 +55,13 @@ "valentines": "Valentinstag" }, "auth": { + "accountSetupPending": { + "title": "Schließen Sie die Einrichtung Ihres Kontos ab", + "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." + }, "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 9565322b5c..19273f6e47 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -76,6 +76,13 @@ "environment": "World Environment Day" }, "auth": { + "accountSetupPending": { + "title": "Finish setting up your account", + "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." + }, "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 4cce496410..79211c9184 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -55,6 +55,13 @@ "environment": "Día Mundial del Medio Ambiente" }, "auth": { + "accountSetupPending": { + "title": "Termina de configurar tu cuenta", + "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." + }, "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 eb59fc41b2..7c4d971e90 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -55,6 +55,13 @@ "environment": "Journée Mondiale de l'Environnement" }, "auth": { + "accountSetupPending": { + "title": "Terminez la configuration de votre compte", + "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." + }, "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 e7d2b48a34..7dbee2fb86 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -76,6 +76,13 @@ "environment": "Giornata Mondiale dell'Ambiente" }, "auth": { + "accountSetupPending": { + "title": "Completa la configurazione del tuo account", + "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." + }, "emailFloatingLabel": "Indirizzo email", "email": "Email", "passwordFloatingLabel": "Password", diff --git a/src/app/i18n/locales/pt.json b/src/app/i18n/locales/pt.json index b9c0eb6bcc..224acb5bd9 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", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index adeec2680e..09ec045291 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -55,6 +55,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 28d495209f..3d804dd383 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -76,6 +76,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 ab3a4ff3b6..180341a9ec 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -76,6 +76,13 @@ "environment": "世界环境日" }, "auth": { + "accountSetupPending": { + "title": "完成账户设置", + "message": "您已使用此邮箱购买了套餐。请使用我们发送给您的链接设置密码。", + "resend": "重新发送邮件", + "emailSent": "邮件已发送,请查收收件箱。", + "resendFailed": "无法发送邮件,请重试。" + }, "emailFloatingLabel": "电子邮件", "email": "电子邮件", "passwordFloatingLabel": "密码", diff --git a/src/services/auth.service.test.ts b/src/services/auth.service.test.ts index 670335bee9..2e90ccecc9 100644 --- a/src/services/auth.service.test.ts +++ b/src/services/auth.service.test.ts @@ -18,6 +18,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'; @@ -946,6 +948,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 4150ae8ab7..0efd711c68 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 { ATTRIBUTION_LOCAL_STORAGE_ITEMS, CHECKOUT_LOCAL_STORAGE_ITEMS } from 'services/storage-keys'; @@ -156,6 +157,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 && } +