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/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/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/app/core/factory/sdk/index.ts b/src/app/core/factory/sdk/index.ts index 27f7c8973a..3fa43225ea 100644 --- a/src/app/core/factory/sdk/index.ts +++ b/src/app/core/factory/sdk/index.ts @@ -131,6 +131,11 @@ export class SdkFactory { return Checkout.client(envService.getVariable('payments'), appDetails, apiSecurity); } + public createCheckoutClientWithoutSession(): Checkout { + const appDetails = SdkFactory.getAppDetails(); + return Checkout.client(envService.getVariable('payments'), appDetails, { token: '' }); + } + public createBackupsClient(): Backups { const apiUrl = this.getApiUrl(); const appDetails = SdkFactory.getAppDetails(); diff --git a/src/app/core/types.ts b/src/app/core/types.ts index 3a2b414300..6040929561 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', @@ -157,6 +158,7 @@ export enum LocalStorageItem { StpatricksTheme = 'stpatricks_theme_enabled', AnniversaryTheme = 'anniversary_theme_enabled', SubscriptionEndingModalClosed = 'subscription_ending_modal_closed', + CheckoutAccountSetupEmail = 'checkout_account_setup_email', } export enum LocalStorageProtectedItem { diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 525fda07b0..3655df7e5d 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", @@ -272,6 +279,20 @@ "countryRequired": "Wir konnten Ihr Land nicht ermitteln. Bitte versuchen Sie es später erneut.", "missingPaymentDetails": "Fehlende Zahlungsdaten zur Bestätigung der Zahlung", "paymentFailed": "Zahlung fehlgeschlagen" + }, + "accountSetup": { + "passwordAfterPayment": "Du brauchst noch kein Passwort. Nach der Zahlung senden wir dir eine E-Mail, um die Einrichtung deines Kontos abzuschließen.", + "emailHasAccount": "Für diese E-Mail gibt es bereits ein Internxt-Konto. Melde dich an, um mit deinem Kauf fortzufahren.", + "pendingSetup": "Für {{email}} gibt es bereits einen bezahlten Tarif, der noch eingerichtet werden muss. Prüfe deine E-Mails, um die Einrichtung deines Kontos abzuschließen.", + "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.", + "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": { @@ -1543,6 +1564,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 4b4071d263..fdbbf55ba2 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", @@ -293,6 +300,20 @@ "countryRequired": "We couldn't determine your billing country. Please try again later.", "missingPaymentDetails": "Missing payment details to confirm the payment", "paymentFailed": "Payment failed" + }, + "accountSetup": { + "passwordAfterPayment": "You don't need a password yet. After paying, we'll send you an email to finish setting up your account.", + "emailHasAccount": "This email already has an Internxt account. Log in to continue with your purchase.", + "pendingSetup": "{{email}} already has a paid plan waiting to be set up. Check your email to finish setting up your account.", + "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.", + "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": { @@ -1567,6 +1588,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 00b84aa9bf..60b03797b5 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", @@ -272,6 +279,20 @@ "countryRequired": "No hemos podido determinar tu país de facturación. Inténtalo de nuevo más tarde.", "missingPaymentDetails": "Faltan los datos de pago para confirmar el pago", "paymentFailed": "Pago fallido" + }, + "accountSetup": { + "passwordAfterPayment": "Todavía no necesitas una contraseña. Después del pago te enviaremos un correo para terminar de configurar tu cuenta.", + "emailHasAccount": "Este correo ya tiene una cuenta de Internxt. Inicia sesión para continuar con tu compra.", + "pendingSetup": "{{email}} ya tiene un plan pagado pendiente de configurar. Revisa tu correo para terminar de configurar tu cuenta.", + "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.", + "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": { @@ -1547,6 +1568,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 f1e51ed8fe..7ace65a0ea 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", @@ -272,6 +279,20 @@ "countryRequired": "Nous n'avons pas pu déterminer votre pays. Veuillez réessayer plus tard.", "missingPaymentDetails": "Informations de paiement manquantes pour confirmer le paiement", "paymentFailed": "Paiement échoué" + }, + "accountSetup": { + "passwordAfterPayment": "Vous n'avez pas encore besoin de mot de passe. Après le paiement, nous vous enverrons un e-mail pour finaliser la configuration de votre compte.", + "emailHasAccount": "Cet e-mail a déjà un compte Internxt. Connectez-vous pour poursuivre votre achat.", + "pendingSetup": "{{email}} a déjà un forfait payé en attente de configuration. Consultez vos e-mails pour finaliser la configuration de votre compte.", + "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.", + "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": { @@ -1547,6 +1568,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 6a88e6cf06..01c462904f 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", @@ -293,6 +300,20 @@ "countryRequired": "Non siamo riusciti a determinare il tuo paese. Riprova più tardi.", "missingPaymentDetails": "Dati di pagamento mancanti per confermare il pagamento", "paymentFailed": "Pagamento non riuscito" + }, + "accountSetup": { + "passwordAfterPayment": "Non ti serve ancora una password. Dopo il pagamento ti invieremo un'email per completare la configurazione del tuo account.", + "emailHasAccount": "Questa email ha già un account Internxt. Accedi per continuare con l'acquisto.", + "pendingSetup": "{{email}} ha già un piano pagato in attesa di configurazione. Controlla la tua email per completare la configurazione del tuo account.", + "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.", + "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": { @@ -1567,6 +1588,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/pt.json b/src/app/i18n/locales/pt.json index 049865c53d..eb5cc2213e 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", @@ -293,6 +300,20 @@ "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", "paymentFailed": "Falha no pagamento" + }, + "accountSetup": { + "passwordAfterPayment": "Você ainda não precisa de senha. Depois do pagamento, enviaremos um e-mail para você concluir a configuração da sua conta.", + "emailHasAccount": "Este e-mail já tem uma conta Internxt. Faça login para continuar com a compra.", + "pendingSetup": "{{email}} já tem um plano pago aguardando configuração. Verifique seu e-mail para concluir a configuração da sua conta.", + "cryptoRequiresSignIn": "Faça login para pagar com criptomoedas.", + "resendEmail": "Reenviar e-mail", + "emailResent": "Enviamos o e-mail novamente", + "resendEmailError": "Não foi possível enviar o e-mail. Tente novamente mais tarde.", + "checkEmail": { + "title": "Verifique seu e-mail", + "description": "Seu pagamento foi concluído. Enviamos um link para concluir a configuração da sua conta para:", + "spamHint": "Não encontrou? Verifique sua pasta de spam." + } } }, "urgentCheckout": { @@ -1566,6 +1587,30 @@ } } }, + "accountSetup": { + "form": { + "title": "Configure sua conta", + "description": "Crie uma senha para concluir a configuração da sua conta Internxt.", + "confirmPassword": "Confirmar senha", + "passwordsDoNotMatch": "As senhas não coincidem", + "submit": "Criar conta", + "genericError": "Algo deu errado ao configurar sua conta. Tente novamente." + }, + "invalidLink": { + "title": "Este link não é mais válido", + "description": "O link expirou ou já foi usado. Informe seu e-mail e enviaremos um novo.", + "emailLabel": "E-mail", + "resendButton": "Enviar um novo link", + "resendError": "Não foi possível enviar o e-mail. Tente novamente.", + "emailSentTitle": "Verifique seu e-mail", + "emailSentDescription": "Se {{email}} tiver uma conta aguardando configuração, você receberá um novo link em breve." + }, + "accountAlreadyExists": { + "title": "Sua conta já está configurada", + "description": "Esta conta já existe. Faça login com seu e-mail e senha para acessá-la.", + "loginButton": "Ir para o login" + } + }, "blockedAccount": { "title": "Confirme que é você", "text1": "Houve tentativas de login malsucedidas demais.", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index bada5cb41e..ffc052880d 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": "Пароль", @@ -272,6 +279,20 @@ "countryRequired": "Не удалось определить вашу страну. Пожалуйста, повторите попытку позже.", "missingPaymentDetails": "Отсутствуют данные платежа для подтверждения оплаты", "paymentFailed": "Оплата не прошла" + }, + "accountSetup": { + "passwordAfterPayment": "Пароль пока не нужен. После оплаты мы отправим вам письмо, чтобы завершить настройку аккаунта.", + "emailHasAccount": "Для этого адреса уже есть аккаунт Internxt. Войдите, чтобы продолжить покупку.", + "pendingSetup": "Для {{email}} уже есть оплаченный тариф, ожидающий настройки. Проверьте почту, чтобы завершить настройку аккаунта.", + "cryptoRequiresSignIn": "Войдите, чтобы оплатить криптовалютой.", + "resendEmail": "Отправить письмо ещё раз", + "emailResent": "Мы отправили письмо ещё раз", + "resendEmailError": "Не удалось отправить письмо. Повторите попытку позже.", + "checkEmail": { + "title": "Проверьте почту", + "description": "Оплата прошла успешно. Мы отправили ссылку для завершения настройки аккаунта на адрес:", + "spamHint": "Не нашли письмо? Проверьте папку «Спам»." + } } }, "urgentCheckout": { @@ -1546,6 +1567,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 d06401d0d2..d05757332d 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": "密碼", @@ -293,6 +300,20 @@ "countryRequired": "我們無法確定您的國家/地區,請稍後再試", "missingPaymentDetails": "缺少確認付款所需的付款資訊", "paymentFailed": "付款失敗" + }, + "accountSetup": { + "passwordAfterPayment": "現在還不需要密碼。付款後,我們會寄一封電子郵件給你,以完成帳戶設定。", + "emailHasAccount": "此電子郵件已有 Internxt 帳戶。請登入以繼續購買。", + "pendingSetup": "{{email}} 已有一個待設定的付費方案。請查看你的電子郵件以完成帳戶設定。", + "cryptoRequiresSignIn": "請登入後使用加密貨幣付款。", + "resendEmail": "重新寄送郵件", + "emailResent": "我們已重新寄送郵件", + "resendEmailError": "無法寄送郵件,請稍後再試。", + "checkEmail": { + "title": "請查看你的電子郵件", + "description": "付款成功。我們已將完成帳戶設定的連結寄送至:", + "spamHint": "找不到郵件?請檢查垃圾郵件資料夾。" + } } }, "urgentCheckout": { @@ -1570,6 +1591,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 9293e5024f..ce83a78e8d 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": "密码", @@ -293,6 +300,20 @@ "countryRequired": "我们无法确定您的国家/地区,请稍后再试", "missingPaymentDetails": "缺少确认付款所需的支付信息", "paymentFailed": "付款失败" + }, + "accountSetup": { + "passwordAfterPayment": "现在还不需要密码。付款后,我们会向你发送一封电子邮件,以完成账户设置。", + "emailHasAccount": "该电子邮件已有 Internxt 账户。请登录以继续购买。", + "pendingSetup": "{{email}} 已有一个待设置的付费套餐。请查看你的电子邮件以完成账户设置。", + "cryptoRequiresSignIn": "请登录后使用加密货币付款。", + "resendEmail": "重新发送邮件", + "emailResent": "我们已重新发送邮件", + "resendEmailError": "无法发送邮件,请稍后重试。", + "checkEmail": { + "title": "请查看你的电子邮件", + "description": "付款成功。我们已将完成账户设置的链接发送至:", + "spamHint": "找不到邮件?请检查垃圾邮件文件夹。" + } } }, "urgentCheckout": { @@ -1567,6 +1588,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..ce78233a25 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", @@ -147,7 +154,7 @@ "layout": "empty", "path": "/checkout/success", "exact": false, - "auth": true + "auth": false }, { "id": "checkout-cancel", 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 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 8f798deb21..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'; @@ -74,6 +75,13 @@ type SignUpParams = { dispatch: AppDispatch; }; +type NewAccountSessionParams = { + registeredUser: Awaited>; + password: string; + redeemCodeObject: boolean; + dispatch: AppDispatch; +}; + type LogInParams = { email: string; password: string; @@ -149,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); }); }; @@ -567,9 +576,13 @@ export const unblockAccount = (token: string): Promise => { return authClient.unblockAccount(token); }; -export const signUp = async (params: SignUpParams) => { - const { doSignUp, email, password, token, redeemCodeObject, dispatch } = params; - const { xUser, xNewToken, mnemonic } = await doSignUp(email, password, token); +export const startNewAccountSession = async ({ + registeredUser, + password, + redeemCodeObject, + dispatch, +}: NewAccountSessionParams) => { + const { xUser, xNewToken, mnemonic } = registeredUser; localStorageService.clearExcept([...CHECKOUT_LOCAL_STORAGE_ITEMS, ...ATTRIBUTION_LOCAL_STORAGE_ITEMS]); @@ -601,6 +614,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/AccountSetupView.test.tsx b/src/views/AccountSetup/AccountSetupView.test.tsx new file mode 100644 index 0000000000..9062fd1337 --- /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(), clearExcept: vi.fn() }, +})); +vi.mock('app/store/slices/user', () => ({ + initializeUserThunk: vi.fn(), + userThunks: { setUserThunk: vi.fn(), initializeUserThunk: vi.fn() }, +})); +vi.mock('app/store/slices/plan', () => ({ planThunks: { initializeThunk: vi.fn() } })); +vi.mock('app/store/slices/workspaces/workspacesStore', () => ({ + workspaceThunks: { fetchWorkspaces: vi.fn(), checkAndSetLocalWorkspace: vi.fn() }, +})); +vi.mock('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..1d63d81b28 --- /dev/null +++ b/src/views/AccountSetup/AccountSetupView.tsx @@ -0,0 +1,21 @@ +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'; + +const AccountSetupView = (): JSX.Element => { + const { token } = useParams<{ token: string }>(); + const { status, isSubmitting, hasSubmitFailed, setUpAccount } = useAccountSetup(token); + + const contentByStatus: Record = { + form: , + invalidLink: , + accountAlreadyExists: , + }; + + return {contentByStatus[status]}; +}; + +export default AccountSetupView; 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..9fc25882ce --- /dev/null +++ b/src/views/AccountSetup/components/AccountSetupForm.tsx @@ -0,0 +1,109 @@ +import { Button } from '@internxt/ui'; +import { WarningCircleIcon } 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..3d70b32914 --- /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(); + void 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/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/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'; diff --git a/src/views/AccountSetup/services/completeAccountSetup.test.ts b/src/views/AccountSetup/services/completeAccountSetup.test.ts new file mode 100644 index 0000000000..2401bdab0e --- /dev/null +++ b/src/views/AccountSetup/services/completeAccountSetup.test.ts @@ -0,0 +1,115 @@ +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'; +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'; + +const { authClient, createAuthClient } = vi.hoisted(() => { + const authClient = { completeAccountSetup: vi.fn() }; + return { authClient, createAuthClient: vi.fn() }; +}); + +vi.mock('app/core/factory/sdk', () => ({ + SdkFactory: { getNewApiInstance: () => ({ createAuthClient }) }, +})); +vi.mock('services/encrypted-storage.service', () => ({ + default: { setToken: vi.fn(), getToken: vi.fn(), getUser: vi.fn(), clear: vi.fn() }, +})); +vi.mock('services/local-storage.service', () => ({ + default: { get: vi.fn(), set: vi.fn(), clear: vi.fn(), clearExcept: vi.fn(), removeItem: 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(); + expect(localStorageService.clearExcept).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() }); + + const [keysToPreserve] = vi.mocked(localStorageService.clearExcept).mock.calls[0]; + expect(keysToPreserve).not.toContain(LocalStorageItem.CheckoutAccountSetupEmail); + }); +}); diff --git a/src/views/AccountSetup/services/completeAccountSetup.ts b/src/views/AccountSetup/services/completeAccountSetup.ts new file mode 100644 index 0000000000..6595992209 --- /dev/null +++ b/src/views/AccountSetup/services/completeAccountSetup.ts @@ -0,0 +1,36 @@ +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); + + const session = await startNewAccountSession({ + registeredUser: { + xUser: { ...user, mnemonic } as unknown as UserSettings, + xToken: token, + xNewToken: newToken, + mnemonic, + }, + password, + redeemCodeObject: false, + dispatch, + }); + + return session; +}; diff --git a/src/views/Checkout/components/CheckoutUserAuth.test.tsx b/src/views/Checkout/components/CheckoutUserAuth.test.tsx new file mode 100644 index 0000000000..84331b79c6 --- /dev/null +++ b/src/views/Checkout/components/CheckoutUserAuth.test.tsx @@ -0,0 +1,122 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useForm } from 'react-hook-form'; +import { SdkFactory } from 'app/core/factory/sdk'; +import { IFormValues } from 'app/core/types'; +import enTranslations from 'app/i18n/locales/en.json'; +import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { AuthMethodTypes } from '../types'; +import { CheckoutUserAuth } from './CheckoutUserAuth'; + +const translate = (key: string, props?: Record) => { + const value = key.split('.').reduce((accumulator, part) => accumulator?.[part], enTranslations); + + if (typeof value !== 'string') { + throw new Error(`Missing translation for "${key}"`); + } + + return value.replace(/{{(\w+)}}/g, (_, placeholder) => String(props?.[placeholder] ?? '')); +}; + +vi.mock('app/i18n/provider/TranslationProvider', () => ({ + useTranslationContext: () => ({ translate }), +})); + +const resendAccountSetupEmail = vi.fn(); + +interface RenderOptions { + authMethod?: AuthMethodTypes; + isPasswordlessSignUp?: boolean; + pendingAccountSetupEmail?: string; +} + +const CheckoutUserAuthForm = ({ + authMethod = 'signUp', + isPasswordlessSignUp, + pendingAccountSetupEmail, +}: RenderOptions) => { + const { + register, + formState: { errors }, + } = useForm(); + + return ( + + ); +}; + +describe('Checkout account details', () => { + beforeEach(() => { + vi.clearAllMocks(); + globalThis.grecaptcha = { + ready: (callback: () => void) => callback(), + execute: vi.fn().mockResolvedValue('captcha_token'), + } as unknown as typeof globalThis.grecaptcha; + vi.spyOn(SdkFactory, 'getNewApiInstance').mockReturnValue({ + createAuthClient: () => ({ resendAccountSetupEmail }), + } as unknown as SdkFactory); + }); + + test('When a new buyer can pay without a password, then only the email is asked', () => { + render(); + + expect(screen.getByPlaceholderText('Email')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Password')).not.toBeInTheDocument(); + expect(screen.getByText(enTranslations.checkout.accountSetup.passwordAfterPayment)).toBeInTheDocument(); + }); + + test('When paying without a password is not available, then a new buyer is asked for a password', () => { + render(); + + expect(screen.getByPlaceholderText('Email')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Password')).toBeInTheDocument(); + }); + + test('When the buyer logs in inside the checkout, then the password is still asked', () => { + render(); + + expect(screen.getByPlaceholderText('Password')).toBeInTheDocument(); + }); + + test('When the email has a paid account waiting to be set up, then the buyer can ask for the setup email again', async () => { + const showNotificationSpy = vi.spyOn(notificationsService, 'show').mockReturnValue(''); + resendAccountSetupEmail.mockResolvedValue(undefined); + render(); + + expect(screen.getByText(/pending@internxt.com already has a paid plan waiting to be set up/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: enTranslations.checkout.accountSetup.resendEmail })); + + await waitFor(() => + expect(showNotificationSpy).toHaveBeenCalledWith({ + text: enTranslations.checkout.accountSetup.emailResent, + type: ToastType.Success, + }), + ); + expect(resendAccountSetupEmail).toHaveBeenCalledWith('pending@internxt.com'); + }); + + test('When the setup email cannot be sent again, then the buyer is told to try later', async () => { + const showNotificationSpy = vi.spyOn(notificationsService, 'show').mockReturnValue(''); + resendAccountSetupEmail.mockRejectedValue(new Error('Network error')); + render(); + + fireEvent.click(screen.getByRole('button', { name: enTranslations.checkout.accountSetup.resendEmail })); + + await waitFor(() => + expect(showNotificationSpy).toHaveBeenCalledWith({ + text: enTranslations.checkout.accountSetup.resendEmailError, + type: ToastType.Error, + }), + ); + }); +}); diff --git a/src/views/Checkout/components/CheckoutUserAuth.tsx b/src/views/Checkout/components/CheckoutUserAuth.tsx index 73ebceef40..f0690afd3f 100644 --- a/src/views/Checkout/components/CheckoutUserAuth.tsx +++ b/src/views/Checkout/components/CheckoutUserAuth.tsx @@ -5,6 +5,7 @@ import { IFormValues } from 'app/core/types'; import { Button, Avatar } from '@internxt/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { InputsComponent } from './InputsComponent'; +import { ResendAccountSetupEmailButton } from './ResendAccountSetupEmailButton'; interface CheckoutUserAuthProps { userData: { @@ -14,6 +15,8 @@ interface CheckoutUserAuthProps { }; authMethod: AuthMethodTypes; authError?: string; + isPasswordlessSignUp?: boolean; + pendingAccountSetupEmail?: string; errors: FieldErrors; onAuthMethodToggled: (authMethod: AuthMethodTypes) => void; register: UseFormRegister; @@ -25,6 +28,8 @@ export const CheckoutUserAuth = ({ errors, authMethod, authError, + isPasswordlessSignUp = false, + pendingAccountSetupEmail, onAuthMethodToggled, userData, onLogOut, @@ -67,8 +72,22 @@ export const CheckoutUserAuth = ({ ) : ( - + )} + {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 db7ccc3abe..cea304043a 100644 --- a/src/views/Checkout/hooks/useAuthCheckout.test.ts +++ b/src/views/Checkout/hooks/useAuthCheckout.test.ts @@ -281,4 +281,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 8cc2ca7fc7..f9476faede 100644 --- a/src/views/Checkout/hooks/useAuthCheckout.ts +++ b/src/views/Checkout/hooks/useAuthCheckout.ts @@ -117,6 +117,11 @@ export const useAuthCheckout = ({ changeAuthMethod }: Pick { + setAuthError(translate('checkout.accountSetup.emailHasAccount')); + changeAuthMethod('signIn'); + }; + const onLogOut = async () => { await databaseService.clear(); localStorageService.clearExcept([...CHECKOUT_LOCAL_STORAGE_ITEMS, ...ATTRIBUTION_LOCAL_STORAGE_ITEMS]); @@ -127,6 +132,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 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(); + vi.spyOn(SdkFactory, 'getNewApiInstance').mockReturnValue({ + createCheckoutClientWithoutSession: () => ({ createCustomerWithoutAccount }), + } as unknown as SdkFactory); + createCustomerWithoutAccount.mockResolvedValue({ customerId: 'cus_123', token: 'payments_token' }); + }); + + test('When 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 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..7a51c1d060 --- /dev/null +++ b/src/views/Checkout/hooks/usePasswordlessCheckout.ts @@ -0,0 +1,78 @@ +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 = !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/hooks/useUserPayment.test.ts b/src/views/Checkout/hooks/useUserPayment.test.ts index 305865ad37..c96b2ef36f 100644 --- a/src/views/Checkout/hooks/useUserPayment.test.ts +++ b/src/views/Checkout/hooks/useUserPayment.test.ts @@ -511,6 +511,185 @@ describe('Custom hook to handle payments', () => { expect(createPaymentIntentSpy).toHaveBeenCalled(); }); + test('When the buyer has no account yet, then the purchase is recorded as their first one without looking up their invoices', async () => { + const payment = useUserPayment(); + vi.spyOn(checkoutService, 'createSubscription').mockResolvedValue({ + clientSecret: 'client_secret', + type: 'payment', + paymentIntentId: 'pi_123', + subscriptionId: 'sub_123', + }); + vi.spyOn(paymentService, 'getInvoices').mockRejectedValue(new Error('Unauthorized')); + const localStorageSetSpy = vi.spyOn(localStorageService, 'set').mockImplementation(() => {}); + + await payment.handleUserPayment({ + customerId: 'customer_id', + priceId: 'price_id', + token: 'payments_token', + translate: vi.fn(), + currency: 'eur', + confirmationTokenId: 'ctoken_123', + selectedPlan: { price: { interval: 'year', type: UserType.Individual } } as any, + captchaToken: 'captcha_token', + userAddress: '1.1.1.1', + confirmPayment: vi.fn().mockResolvedValue({ error: undefined }), + confirmSetupIntent: vi.fn().mockResolvedValue({ error: undefined }), + gclidStored: null, + isFirstPurchase: true, + }); + + expect(paymentService.getInvoices).not.toHaveBeenCalled(); + expect(localStorageSetSpy).toHaveBeenCalledWith('isFirstPurchase', 'true'); + }); + + test('When a buyer without an account purchases a subscription, then it is created through the client without a Drive session', async () => { + const payment = useUserPayment(); + const createSubscriptionSpy = vi.spyOn(checkoutService, 'createSubscription').mockResolvedValue({ + clientSecret: 'client_secret', + type: 'payment', + paymentIntentId: 'pi_123', + subscriptionId: 'sub_123', + }); + vi.spyOn(localStorageService, 'set').mockImplementation(() => {}); + + await payment.handleUserPayment({ + customerId: 'customer_id', + priceId: 'price_id', + token: 'payments_token', + translate: vi.fn(), + currency: 'eur', + confirmationTokenId: 'ctoken_123', + selectedPlan: { price: { interval: 'year', type: UserType.Individual } } as any, + captchaToken: 'captcha_token', + userAddress: '1.1.1.1', + confirmPayment: vi.fn().mockResolvedValue({ error: undefined }), + confirmSetupIntent: vi.fn().mockResolvedValue({ error: undefined }), + gclidStored: null, + isFirstPurchase: true, + isPasswordlessSignUp: true, + }); + + expect(createSubscriptionSpy).toHaveBeenCalledWith( + { + customerId: 'customer_id', + priceId: 'price_id', + token: 'payments_token', + currency: 'eur', + captchaToken: 'captcha_token', + promoCodeId: undefined, + }, + { withoutSession: true }, + ); + }); + + test('When a buyer without an account purchases a lifetime plan, then the payment intent is created through the client without a Drive session', async () => { + const payment = useUserPayment(); + const createPaymentIntentSpy = vi.spyOn(checkoutService, 'createPaymentIntent').mockResolvedValue({ + clientSecret: 'client_secret', + invoiceStatus: 'open', + type: 'fiat', + id: 'pi_123', + }); + vi.spyOn(localStorageService, 'set').mockImplementation(() => {}); + + await payment.handleUserPayment({ + customerId: 'customer_id', + priceId: 'price_id', + token: 'payments_token', + translate: vi.fn(), + currency: 'eur', + confirmationTokenId: 'ctoken_123', + selectedPlan: { price: { interval: 'lifetime', type: UserType.Individual } } as any, + captchaToken: 'captcha_token', + userAddress: '1.1.1.1', + confirmPayment: vi.fn().mockResolvedValue({ error: undefined }), + confirmSetupIntent: vi.fn().mockResolvedValue({ error: undefined }), + gclidStored: null, + isFirstPurchase: true, + isPasswordlessSignUp: true, + }); + + expect(createPaymentIntentSpy).toHaveBeenCalledWith( + { + customerId: 'customer_id', + priceId: 'price_id', + token: 'payments_token', + currency: 'eur', + userAddress: '1.1.1.1', + captchaToken: 'captcha_token', + promoCodeId: undefined, + }, + { withoutSession: true }, + ); + }); + + test('When a logged-in buyer purchases a subscription, then it is created through the regular client', async () => { + const payment = useUserPayment(); + const createSubscriptionSpy = vi.spyOn(checkoutService, 'createSubscription').mockResolvedValue({ + clientSecret: 'client_secret', + type: 'payment', + paymentIntentId: 'pi_123', + subscriptionId: 'sub_123', + }); + vi.spyOn(localStorageService, 'set').mockImplementation(() => {}); + vi.spyOn(paymentService, 'getInvoices').mockResolvedValue([]); + + await payment.handleUserPayment({ + customerId: 'customer_id', + priceId: 'price_id', + token: 'user_token', + translate: vi.fn(), + currency: 'eur', + confirmationTokenId: 'ctoken_123', + selectedPlan: { price: { interval: 'year', type: UserType.Individual } } as any, + captchaToken: 'captcha_token', + userAddress: '1.1.1.1', + confirmPayment: vi.fn().mockResolvedValue({ error: undefined }), + confirmSetupIntent: vi.fn().mockResolvedValue({ error: undefined }), + gclidStored: null, + }); + + expect(createSubscriptionSpy).toHaveBeenCalledWith({ + customerId: 'customer_id', + priceId: 'price_id', + token: 'user_token', + currency: 'eur', + captchaToken: 'captcha_token', + promoCodeId: undefined, + }); + }); + + test('When the buyer cancels or fails the payment confirmation, then the error is raised and they are not sent to the success page', async () => { + const payment = useUserPayment(); + vi.spyOn(checkoutService, 'createSubscription').mockResolvedValue({ + clientSecret: 'client_secret', + type: 'payment', + paymentIntentId: 'pi_123', + subscriptionId: 'sub_123', + }); + vi.spyOn(localStorageService, 'set').mockImplementation(() => {}); + const navigationServiceSpy = vi.spyOn(navigationService, 'push').mockImplementation(() => {}); + + const paymentAttempt = payment.handleUserPayment({ + customerId: 'customer_id', + priceId: 'price_id', + token: 'payments_token', + translate: vi.fn(), + currency: 'eur', + confirmationTokenId: 'ctoken_123', + selectedPlan: { price: { interval: 'year', type: UserType.Individual } } as any, + captchaToken: 'captcha_token', + userAddress: '1.1.1.1', + confirmPayment: vi.fn().mockResolvedValue({ error: { message: 'Your card was declined' } }), + confirmSetupIntent: vi.fn(), + gclidStored: null, + isFirstPurchase: true, + }); + + await expect(paymentAttempt).rejects.toThrow('Your card was declined'); + expect(navigationServiceSpy).not.toHaveBeenCalledWith(AppView.CheckoutSuccess); + }); + test('When the plan is neither a subscription nor a lifetime, then the user is redirected to the Drive page directly', async () => { const payment = useUserPayment(); diff --git a/src/views/Checkout/hooks/useUserPayment.ts b/src/views/Checkout/hooks/useUserPayment.ts index 782da2459b..3b432d8ee7 100644 --- a/src/views/Checkout/hooks/useUserPayment.ts +++ b/src/views/Checkout/hooks/useUserPayment.ts @@ -34,27 +34,21 @@ export const checkIsFirstPurchase = async (): Promise => { }; export const useUserPayment = () => { - const getSubscriptionPaymentIntent = async ({ - customerId, - priceId, - token, - currency, - captchaToken, - promoCodeId, - }: CreateSubscriptionPayload) => { + const getSubscriptionPaymentIntent = async ( + { customerId, priceId, token, currency, captchaToken, promoCodeId }: CreateSubscriptionPayload, + isPasswordlessSignUp?: boolean, + ) => { const { type: paymentType, clientSecret: client_secret, subscriptionId, paymentIntentId, - } = await checkoutService.createSubscription({ - customerId, - priceId, - token, - currency, - captchaToken, - promoCodeId, - }); + } = isPasswordlessSignUp + ? await checkoutService.createSubscription( + { customerId, priceId, token, currency, captchaToken, promoCodeId }, + { withoutSession: true }, + ) + : await checkoutService.createSubscription({ customerId, priceId, token, currency, captchaToken, promoCodeId }); return { type: paymentType, @@ -64,24 +58,24 @@ export const useUserPayment = () => { }; }; - const getLifetimePaymentIntent = async ({ - customerId, - priceId, - currency, - token, - userAddress, - captchaToken, - promoCodeId, - }: CreatePaymentIntentPayload) => { - const paymentIntentResponse = await checkoutService.createPaymentIntent({ - customerId, - priceId, - currency, - userAddress, - token, - captchaToken, - promoCodeId: promoCodeId, - }); + const getLifetimePaymentIntent = async ( + { customerId, priceId, currency, token, userAddress, captchaToken, promoCodeId }: CreatePaymentIntentPayload, + isPasswordlessSignUp?: boolean, + ) => { + const paymentIntentResponse = isPasswordlessSignUp + ? await checkoutService.createPaymentIntent( + { customerId, priceId, currency, userAddress, token, captchaToken, promoCodeId }, + { withoutSession: true }, + ) + : await checkoutService.createPaymentIntent({ + customerId, + priceId, + currency, + userAddress, + token, + captchaToken, + promoCodeId, + }); if (paymentIntentResponse.type === PaymentType['CRYPTO']) { return { @@ -139,15 +133,19 @@ export const useUserPayment = () => { confirmPayment, confirmSetupIntent, isFirstPurchase, + isPasswordlessSignUp, }: ProcessPurchasePayload) => { - const subscription = await getSubscriptionPaymentIntent({ - customerId, - priceId, - token, - captchaToken, - promoCodeId: couponCodeData?.codeId, - currency, - }); + const subscription = await getSubscriptionPaymentIntent( + { + customerId, + priceId, + token, + captchaToken, + promoCodeId: couponCodeData?.codeId, + currency, + }, + isPasswordlessSignUp, + ); savePaymentDataInLocalStorage({ subscriptionId: subscription.subscriptionId, @@ -190,6 +188,7 @@ export const useUserPayment = () => { confirmPayment, openCryptoPaymentDialog, isFirstPurchase, + isPasswordlessSignUp, }: ProcessPurchasePayload) => { const { id: paymentIntentId, @@ -198,15 +197,18 @@ export const useUserPayment = () => { type, clientSecret, payload, - } = await getLifetimePaymentIntent({ - customerId, - priceId, - token, - captchaToken, - promoCodeId: couponCodeData?.codeId, - userAddress, - currency, - }); + } = await getLifetimePaymentIntent( + { + customerId, + priceId, + token, + captchaToken, + promoCodeId: couponCodeData?.codeId, + userAddress, + currency, + }, + isPasswordlessSignUp, + ); savePaymentDataInLocalStorage({ subscriptionId: undefined, @@ -262,9 +264,11 @@ export const useUserPayment = () => { confirmPayment, openCryptoPaymentDialog, confirmSetupIntent, + isFirstPurchase: isKnownFirstPurchase, + isPasswordlessSignUp, }: UseUserPaymentPayload) => { const planInterval = selectedPlan.price.interval; - const isFirstPurchase = await checkIsFirstPurchase(); + const isFirstPurchase = isKnownFirstPurchase ?? (await checkIsFirstPurchase()); if (gclidStored) { await sendConversionToAPI({ @@ -295,6 +299,7 @@ export const useUserPayment = () => { confirmPayment, confirmSetupIntent, isFirstPurchase, + isPasswordlessSignUp, }); break; @@ -314,6 +319,7 @@ export const useUserPayment = () => { openCryptoPaymentDialog, confirmSetupIntent, isFirstPurchase, + isPasswordlessSignUp, }); break; diff --git a/src/views/Checkout/services/checkout.service.test.ts b/src/views/Checkout/services/checkout.service.test.ts index 3812df8556..4287daec33 100644 --- a/src/views/Checkout/services/checkout.service.test.ts +++ b/src/views/Checkout/services/checkout.service.test.ts @@ -7,6 +7,7 @@ import { GetPriceByIdPayload, } from '@internxt/sdk/dist/payments/types'; import userService from 'services/user.service'; +import { SdkFactory } from 'app/core/factory/sdk'; vi.mock('app/drive/services/size.service', () => ({ bytesToString: (bytes: number) => `${bytes} B`, @@ -80,6 +81,19 @@ vi.mock('app/core/factory/sdk', () => ({ }), verifyCryptoPayment: vi.fn().mockResolvedValue(true), }), + createCheckoutClientWithoutSession: vi.fn().mockReturnValue({ + createSubscription: vi.fn().mockResolvedValue({ + type: 'payment', + clientSecret: 'no_session_client_secret', + subscriptionId: 'sub_no_session', + paymentIntentId: 'py_no_session', + }), + createPaymentIntent: vi.fn().mockResolvedValue({ + clientSecret: 'no_session_client_secret', + id: 'py_no_session_id', + invoiceStatus: 'paid', + }), + }), }), }, })); @@ -244,6 +258,36 @@ describe('Checkout Service tests', () => { paymentIntentId: 'py_123', }); }); + + it('When a buyer without an account creates a subscription, then it is created through the client without a Drive session', async () => { + const createSubPayload: CreateSubscriptionPayload = { + customerId: 'cus_123', + priceId: 'price_123', + token: 'payments_token', + captchaToken: 'captcha_token', + }; + + const createSubResponse = await checkoutService.createSubscription(createSubPayload, { withoutSession: true }); + + const sessionClient = await SdkFactory.getNewApiInstance().createCheckoutClient(); + const sessionlessClient = SdkFactory.getNewApiInstance().createCheckoutClientWithoutSession(); + + expect(sessionlessClient.createSubscription).toHaveBeenCalledWith({ + customerId: createSubPayload.customerId, + priceId: createSubPayload.priceId, + token: createSubPayload.token, + currency: undefined, + captchaToken: createSubPayload.captchaToken, + promoCodeId: undefined, + }); + expect(sessionClient.createSubscription).not.toHaveBeenCalled(); + expect(createSubResponse).toStrictEqual({ + type: 'payment', + clientSecret: 'no_session_client_secret', + subscriptionId: 'sub_no_session', + paymentIntentId: 'py_no_session', + }); + }); }); describe('Create a payment intent', () => { @@ -285,6 +329,40 @@ describe('Checkout Service tests', () => { invoiceStatus: 'paid', }); }); + + it('When a buyer without an account creates a payment intent, then it is created through the client without a Drive session', async () => { + const createInvoicePayload: CreatePaymentIntentPayload = { + customerId: 'cus_123', + priceId: 'price_123', + token: 'payments_token', + currency: 'eur', + captchaToken: 'captcha_token', + userAddress: '1.1.1.1', + }; + + const createInvoiceResponse = await checkoutService.createPaymentIntent(createInvoicePayload, { + withoutSession: true, + }); + + const sessionClient = await SdkFactory.getNewApiInstance().createCheckoutClient(); + const sessionlessClient = SdkFactory.getNewApiInstance().createCheckoutClientWithoutSession(); + + expect(sessionlessClient.createPaymentIntent).toHaveBeenCalledWith({ + customerId: createInvoicePayload.customerId, + priceId: createInvoicePayload.priceId, + token: createInvoicePayload.token, + currency: createInvoicePayload.currency, + captchaToken: createInvoicePayload.captchaToken, + userAddress: createInvoicePayload.userAddress, + promoCodeId: undefined, + }); + expect(sessionClient.createPaymentIntent).not.toHaveBeenCalled(); + expect(createInvoiceResponse).toStrictEqual({ + clientSecret: 'no_session_client_secret', + id: 'py_no_session_id', + invoiceStatus: 'paid', + }); + }); }); describe('Fetch promotion code by name', () => { diff --git a/src/views/Checkout/services/checkout.service.ts b/src/views/Checkout/services/checkout.service.ts index b1f751343a..f3bdc417d7 100644 --- a/src/views/Checkout/services/checkout.service.ts +++ b/src/views/Checkout/services/checkout.service.ts @@ -1,8 +1,11 @@ import { CouponCodeData, CreatedSubscriptionData } from '@internxt/sdk/dist/drive/payments/types/types'; +import { Checkout } from '@internxt/sdk/dist/payments'; import axios from 'axios'; import { SdkFactory } from 'app/core/factory/sdk'; import { CreateCustomerPayload, + CreateCustomerWithoutAccountPayload, + CreatedCustomer, CreatePaymentIntentPayload, CreateSubscriptionPayload, GetPriceByIdPayload, @@ -114,6 +117,11 @@ const createCustomer = async ({ } as CreateCustomerPayload); }; +const createCustomerWithoutAccount = async (payload: CreateCustomerWithoutAccountPayload): Promise => { + const checkoutClient = SdkFactory.getNewApiInstance().createCheckoutClientWithoutSession(); + return checkoutClient.createCustomerWithoutAccount(payload); +}; + const getPriceById = async ({ priceId, promoCodeName, @@ -133,15 +141,20 @@ const getPriceById = async ({ }); }; -const createSubscription = async ({ - customerId, - priceId, - token, - currency, - captchaToken, - promoCodeId, -}: CreateSubscriptionPayload): Promise => { - const checkoutClient = await SdkFactory.getNewApiInstance().createCheckoutClient(); +export interface CheckoutClientOptions { + withoutSession?: boolean; +} + +const getCheckoutClient = async (options?: CheckoutClientOptions): Promise => + options?.withoutSession + ? SdkFactory.getNewApiInstance().createCheckoutClientWithoutSession() + : SdkFactory.getNewApiInstance().createCheckoutClient(); + +const createSubscription = async ( + { customerId, priceId, token, currency, captchaToken, promoCodeId }: CreateSubscriptionPayload, + options?: CheckoutClientOptions, +): Promise => { + const checkoutClient = await getCheckoutClient(options); return checkoutClient.createSubscription({ customerId, priceId, @@ -152,16 +165,11 @@ const createSubscription = async ({ }); }; -export const createPaymentIntent = async ({ - customerId, - priceId, - token, - currency, - captchaToken, - userAddress, - promoCodeId, -}: CreatePaymentIntentPayload): Promise => { - const checkoutClient = await SdkFactory.getNewApiInstance().createCheckoutClient(); +export const createPaymentIntent = async ( + { customerId, priceId, token, currency, captchaToken, userAddress, promoCodeId }: CreatePaymentIntentPayload, + options?: CheckoutClientOptions, +): Promise => { + const checkoutClient = await getCheckoutClient(options); return checkoutClient.createPaymentIntent({ customerId, priceId, @@ -293,6 +301,7 @@ const trackIncompleteCheckout = async (selectedPlan: PriceWithTax | undefined, p const checkoutService = { fetchPromotionCodeByName, createCustomer, + createCustomerWithoutAccount, createPaymentIntent, getPriceById, createSubscription, diff --git a/src/views/Checkout/types/index.ts b/src/views/Checkout/types/index.ts index acd61ed05c..4d3e576872 100644 --- a/src/views/Checkout/types/index.ts +++ b/src/views/Checkout/types/index.ts @@ -110,6 +110,7 @@ export interface ProcessPurchasePayload { currentSelectedPlan: PriceWithTax; couponCodeData?: CouponCodeData; isFirstPurchase?: boolean; + isPasswordlessSignUp?: boolean; } export interface UseUserPaymentPayload { @@ -127,6 +128,8 @@ export interface UseUserPaymentPayload { captchaToken: string; translate: Translate; couponCodeData?: CouponCodeData; + isFirstPurchase?: boolean; + isPasswordlessSignUp?: boolean; } export enum PlanInterval { diff --git a/src/views/Checkout/views/CheckoutSuccessView.test.tsx b/src/views/Checkout/views/CheckoutSuccessView.test.tsx index 30f12e29c6..a3ca896958 100644 --- a/src/views/Checkout/views/CheckoutSuccessView.test.tsx +++ b/src/views/Checkout/views/CheckoutSuccessView.test.tsx @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { render, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import CheckoutSuccessView from './CheckoutSuccessView'; -import { AppView } from 'app/core/types'; +import { AppView, LocalStorageItem } from 'app/core/types'; import { PURCHASE_LOCAL_STORAGE_ITEMS } from 'services/storage-keys'; const mocks = vi.hoisted(() => ({ localStorageRemoveItem: vi.fn(), + localStorageGet: vi.fn(), + getSessionToken: vi.fn(), navigationPush: vi.fn(), retrievePaymentIntent: vi.fn(), retrieveSetupIntent: vi.fn(), @@ -19,7 +21,13 @@ const mocks = vi.hoisted(() => ({ vi.mock('app/store/hooks', () => ({ useAppDispatch: () => vi.fn() })); vi.mock('services/navigation.service', () => ({ default: { push: mocks.navigationPush } })); -vi.mock('services/local-storage.service', () => ({ default: { removeItem: mocks.localStorageRemoveItem } })); +vi.mock('services/local-storage.service', () => ({ + default: { removeItem: mocks.localStorageRemoveItem, get: mocks.localStorageGet }, +})); +vi.mock('services/encrypted-storage.service', () => ({ default: { getToken: mocks.getSessionToken } })); +vi.mock('../components/ResendAccountSetupEmailButton', () => ({ + ResendAccountSetupEmailButton: () => , +})); vi.mock('app/analytics/meta.service', () => ({ default: { trackPurchase: mocks.metaTrackPurchase } })); vi.mock('app/analytics/ga.service', () => ({ default: { trackPurchase: mocks.gaTrackPurchase } })); vi.mock('app/analytics/impact.service', () => ({ trackPaymentConversion: mocks.trackPaymentConversion })); @@ -94,9 +102,44 @@ const expectCheckoutFinished = () => { describe('Checkout success view', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.getSessionToken.mockReturnValue('session_token'); + mocks.localStorageGet.mockReturnValue(null); landOn(SUCCESS_PATH); }); + describe('When a buyer without an account finishes paying', () => { + beforeEach(() => { + mocks.getSessionToken.mockReturnValue(undefined); + mocks.localStorageGet.mockImplementation((key: string) => + key === LocalStorageItem.CheckoutAccountSetupEmail ? 'new.buyer@internxt.com' : null, + ); + }); + + test('then they are asked to check the email the setup link was sent to, and they stay on the page', async () => { + render(); + + expect(await screen.findByText('checkout.accountSetup.checkEmail.title')).toBeInTheDocument(); + expect(screen.getByText('new.buyer@internxt.com')).toBeInTheDocument(); + expect(screen.getByText('checkout.accountSetup.checkEmail.spamHint')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'checkout.accountSetup.resendEmail' })).toBeInTheDocument(); + await waitFor(() => expectPurchaseTracked()); + PURCHASE_LOCAL_STORAGE_ITEMS.forEach((item) => expect(mocks.localStorageRemoveItem).toHaveBeenCalledWith(item)); + expect(mocks.userStoragePolling).not.toHaveBeenCalled(); + expect(mocks.navigationPush).not.toHaveBeenCalled(); + }); + }); + + describe('When a logged-in user finishes paying after a previous purchase without an account', () => { + test('then they are taken to Drive instead of being asked to check their email', async () => { + mocks.localStorageGet.mockReturnValue('old.buyer@internxt.com'); + + await renderAndWaitForRedirect(); + + expect(screen.queryByText('checkout.accountSetup.checkEmail.title')).not.toBeInTheDocument(); + expectCheckoutFinished(); + }); + }); + describe('When the customer paid without Stripe redirecting back (crypto, lifetime with a 100% off coupon)', () => { test('then the purchase is tracked without asking Stripe and the checkout is finished', async () => { await renderAndWaitForRedirect(); diff --git a/src/views/Checkout/views/CheckoutSuccessView.tsx b/src/views/Checkout/views/CheckoutSuccessView.tsx index bb111e6913..c2258311d0 100644 --- a/src/views/Checkout/views/CheckoutSuccessView.tsx +++ b/src/views/Checkout/views/CheckoutSuccessView.tsx @@ -1,23 +1,45 @@ import useEffectAsync from 'hooks/useEffectAsync'; import navigationService from 'services/navigation.service'; -import { AppView } from 'app/core/types'; +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 { CheckCircleIcon } 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 { PURCHASE_LOCAL_STORAGE_ITEMS } from 'services/storage-keys'; import metaService from 'app/analytics/meta.service'; import { userStoragePolling } from 'utils/userStoragePolling.utils'; -import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; import { paymentService } from '../services'; import envService from 'services/env.service'; +import { ResendAccountSetupEmailButton } from '../components/ResendAccountSetupEmailButton'; export function removePaymentsStorage() { PURCHASE_LOCAL_STORAGE_ITEMS.forEach((item) => localStorageService.removeItem(item)); } +const AccountSetupEmailSentCard = ({ email }: { email: string }) => { + const { translate } = useTranslationContext(); + + return ( +
+
+ +
+

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

+

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

+

{email}

+
+ +

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

+
+
+ ); +}; + const hasStripeReportedPaymentFailed = async (): Promise => { const params = new URLSearchParams(globalThis.location.search); const setupIntentSecret = params.get('setup_intent_client_secret'); @@ -56,7 +78,12 @@ const CheckoutSuccessView = (): JSX.Element => { const dispatch = useAppDispatch(); const { translate } = useTranslationContext(); const hasTrackedRef = useRef(false); + const hasSession = Boolean(encryptedStorageService.getToken()); + const [accountSetupEmail] = useState(() => + 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; @@ -76,6 +103,10 @@ const CheckoutSuccessView = (): JSX.Element => { console.error('Analytics error:', err); } + if (accountSetupEmail) { + return; + } + userStoragePolling(); navigationService.push(AppView.Drive); @@ -83,6 +114,10 @@ const CheckoutSuccessView = (): JSX.Element => { useEffectAsync(onCheckoutSuccess, []); + if (accountSetupEmail) { + return ; + } + return
; }; 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} /> )} 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/src/views/Home/components/DateCalendar.tsx b/src/views/Home/components/DateCalendar.tsx index 26f8a46bea..5d6ab7e635 100644 --- a/src/views/Home/components/DateCalendar.tsx +++ b/src/views/Home/components/DateCalendar.tsx @@ -20,10 +20,8 @@ const REGIONAL_DAYJS_LOCALES = new Set(['zh-tw', 'pt-br']); export const getCalendarLocale = (language: string): string => { const lowerCaseLanguage = language.toLowerCase(); - - return REGIONAL_DAYJS_LOCALES.has(lowerCaseLanguage) - ? lowerCaseLanguage - : lowerCaseLanguage.split('-')[0]; + + return REGIONAL_DAYJS_LOCALES.has(lowerCaseLanguage) ? lowerCaseLanguage : lowerCaseLanguage.split('-')[0]; }; const getDayClassName = (isSelected: boolean, isCurrentMonth: boolean, isDisabled: boolean) => { 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..813395c192 --- /dev/null +++ b/src/views/Login/components/AccountSetupPendingNotice.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { Button } from '@internxt/ui'; +import { CheckCircleIcon, WarningCircleIcon } 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..939cd01781 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'; @@ -44,6 +45,8 @@ vi.mock('@phosphor-icons/react', () => ({ Info: () =>
Mocked Info Icon
, WarningCircle: () =>
Mocked Warning Circle Icon
, CheckCircle: () =>
Mocked Check Circle Icon
, + CheckCircleIcon: () =>
Mocked Check Circle Icon
, + WarningCircleIcon: () =>
Mocked Warning Circle Icon
, Eye: () =>
Mocked Eye Icon
, EyeSlash: () =>
Mocked Eye Slash Icon
, MagnifyingGlass: () =>
Mocked Magnifyin Glass Icon
, @@ -93,6 +96,8 @@ vi.mock('./hooks/useGuestSignupState', () => ({ setInvitationId: vi.fn(), showPasswordIndicator: true, setShowPasswordIndicator: vi.fn(), + pendingSetupEmail: null, + setPendingSetupEmail: vi.fn(), user: null, mnemonic: null, })), @@ -181,6 +186,7 @@ vi.mock('react-redux', () => ({ vi.mock('../../utils', () => ({ onChangePasswordHandler: vi.fn(), + generateCaptchaToken: vi.fn(), })); vi.mock('services/workspace.service', () => ({ @@ -330,3 +336,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..b5015a43ce 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, }), @@ -85,6 +88,8 @@ describe('onSubmit', () => { Info: () =>
Mocked Info Icon
, WarningCircle: () =>
Mocked Warning Circle Icon
, CheckCircle: () =>
Mocked Check Circle Icon
, + CheckCircleIcon: () =>
Mocked Check Circle Icon
, + WarningCircleIcon: () =>
Mocked Warning Circle Icon
, Eye: () =>
Mocked Eye Icon
, EyeSlash: () =>
Mocked Eye Slash Icon
, MagnifyingGlass: () =>
Mocked Magnifyin Glass Icon
, @@ -323,4 +328,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 && } +