diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts index 3b31d78ce0..ec65c43ee2 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts @@ -1,7 +1,7 @@ "use server"; import { - checkUserExistsByEmail, + getIsEmailUnique, verifyUserPassword, } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user"; import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants"; @@ -10,13 +10,13 @@ import { getFileNameWithIdFromUrl } from "@/lib/storage/utils"; import { updateUser } from "@/lib/user/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; import { rateLimit } from "@/lib/utils/rate-limit"; +import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; import { sendVerificationNewEmail } from "@/modules/email"; import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { AuthenticationError, AuthorizationError, - InvalidInputError, OperationNotAllowedError, TooManyRequestsError, } from "@formbricks/types/errors"; @@ -37,10 +37,11 @@ export const updateUserAction = authenticatedActionClient const inputEmail = parsedInput.email?.trim().toLowerCase(); let payload: TUserUpdateInput = { - name: parsedInput.name, - locale: parsedInput.locale, + ...(parsedInput.name && { name: parsedInput.name }), + ...(parsedInput.locale && { locale: parsedInput.locale }), }; + // Only process email update if a new email is provided and it's different from current email if (inputEmail && ctx.user.email !== inputEmail) { // Check rate limit try { @@ -61,20 +62,26 @@ export const updateUserAction = authenticatedActionClient throw new AuthorizationError("Incorrect credentials"); } - const doesUserExist = await checkUserExistsByEmail(inputEmail); + // Check if the new email is unique, no user exists with the new email + const isEmailUnique = await getIsEmailUnique(inputEmail); - if (doesUserExist) { - throw new InvalidInputError("This email is already in use"); - } - - if (EMAIL_VERIFICATION_DISABLED) { - payload.email = inputEmail; - } else { - await sendVerificationNewEmail(ctx.user.id, inputEmail); + // If the new email is unique, proceed with the email update + if (isEmailUnique) { + if (EMAIL_VERIFICATION_DISABLED) { + payload.email = inputEmail; + await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail }); + } else { + await sendVerificationNewEmail(ctx.user.id, inputEmail); + } } } - return await updateUser(ctx.user.id, payload); + // Only proceed with updateUser if we have actual changes to make + if (Object.keys(payload).length > 0) { + await updateUser(ctx.user.id, payload); + } + + return true; }); const ZUpdateAvatarAction = z.object({ diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx index fa27b0dd0e..81add2ba14 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx @@ -21,11 +21,13 @@ import { useState } from "react"; import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { z } from "zod"; -import { TUser, TUserUpdateInput, ZUser } from "@formbricks/types/user"; +import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user"; import { updateUserAction } from "../actions"; // Schema & types -const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }); +const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({ + email: ZUserEmail.transform((val) => val?.trim().toLowerCase()), +}); type TEditProfileNameForm = z.infer; export const EditProfileDetailsForm = ({ @@ -80,9 +82,9 @@ export const EditProfileDetailsForm = ({ if (updatedUserResult?.data) { if (!emailVerificationDisabled) { - toast.success(t("auth.verification-requested.verification_email_successfully_sent", { email })); + toast.success(t("auth.verification-requested.new_email_verification_success")); } else { - toast.success(t("environments.settings.profile.profile_updated_successfully")); + toast.success(t("environments.settings.profile.email_change_initiated")); await signOut({ redirect: false }); router.push(`/email-change-without-verification-success`); return; @@ -98,11 +100,6 @@ export const EditProfileDetailsForm = ({ }; const onSubmit: SubmitHandler = async (data) => { - if (data.email !== user.email && data.email.toLowerCase() === user.email.toLowerCase()) { - toast.error(t("auth.email-change.email_already_exists")); - return; - } - if (data.email !== user.email) { setShowModal(true); } else { diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.test.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.test.ts index ad43ed19a9..a220e658ac 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.test.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.test.ts @@ -2,7 +2,7 @@ import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; -import { checkUserExistsByEmail, verifyUserPassword } from "./user"; +import { getIsEmailUnique, verifyUserPassword } from "./user"; // Mock dependencies vi.mock("@/lib/user/cache", () => ({ @@ -116,27 +116,27 @@ describe("User Library Tests", () => { }); }); - describe("checkUserExistsByEmail", () => { + describe("getIsEmailUnique", () => { const email = "test@example.com"; - test("should return true if user exists", async () => { + test("should return false if user exists", async () => { mockPrismaUserFindUnique.mockResolvedValue({ id: "some-user-id", } as any); - const result = await checkUserExistsByEmail(email); - expect(result).toBe(true); + const result = await getIsEmailUnique(email); + expect(result).toBe(false); expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ where: { email }, select: { id: true }, }); }); - test("should return false if user does not exist", async () => { + test("should return true if user does not exist", async () => { mockPrismaUserFindUnique.mockResolvedValue(null); - const result = await checkUserExistsByEmail(email); - expect(result).toBe(false); + const result = await getIsEmailUnique(email); + expect(result).toBe(true); expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ where: { email }, select: { id: true }, diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.ts index 7096fd6b67..f8c533f3e0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.ts @@ -47,7 +47,7 @@ export const verifyUserPassword = async (userId: string, password: string): Prom return true; }; -export const checkUserExistsByEmail = reactCache( +export const getIsEmailUnique = reactCache( async (email: string): Promise => cache( async () => { @@ -60,9 +60,9 @@ export const checkUserExistsByEmail = reactCache( }, }); - return !!user; + return !user; }, - [`checkUserExistsByEmail-${email}`], + [`getIsEmailUnique-${email}`], { tags: [userCache.tag.byEmail(email)], } diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 3958b866f8..2bb29c457e 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -13,6 +13,8 @@ "email_change_success": "E-Mail erfolgreich geändert", "email_change_success_description": "Du hast deine E-Mail-Adresse erfolgreich geändert. Bitte logge dich mit deiner neuen E-Mail-Adresse ein.", "email_verification_failed": "E-Mail-Bestätigung fehlgeschlagen", + "email_verification_loading": "E-Mail-Bestätigung läuft...", + "email_verification_loading_description": "Wir aktualisieren Ihre E-Mail-Adresse in unserem System. Dies kann einige Sekunden dauern.", "invalid_or_expired_token": "E-Mail-Änderung fehlgeschlagen. Dein Token ist ungültig oder abgelaufen.", "new_email": "Neue E-Mail", "old_email": "Alte E-Mail" @@ -88,6 +90,7 @@ "verification-requested": { "invalid_email_address": "Ungültige E-Mail-Adresse", "invalid_token": "Ungültiges Token ☹️", + "new_email_verification_success": "Wenn die Adresse gültig ist, wurde eine Bestätigungs-E-Mail gesendet.", "no_email_provided": "Keine E-Mail bereitgestellt", "please_click_the_link_in_the_email_to_activate_your_account": "Bitte klicke auf den Link in der E-Mail, um dein Konto zu aktivieren.", "please_confirm_your_email_address": "Bitte bestätige deine E-Mail-Adresse", @@ -1149,11 +1152,13 @@ "disable_two_factor_authentication": "Zwei-Faktor-Authentifizierung deaktivieren", "disable_two_factor_authentication_description": "Wenn Du die Zwei-Faktor-Authentifizierung deaktivieren musst, empfehlen wir, sie so schnell wie möglich wieder zu aktivieren.", "each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Jeder Backup-Code kann genau einmal verwendet werden, um Zugang ohne deinen Authenticator zu gewähren.", + "email_change_initiated": "Deine Anfrage zur Änderung der E-Mail wurde eingeleitet.", "enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren", "enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.", "file_size_must_be_less_than_10mb": "Dateigröße muss weniger als 10MB sein.", "invalid_file_type": "Ungültiger Dateityp. Nur JPEG-, PNG- und WEBP-Dateien sind erlaubt.", "lost_access": "Zugriff verloren", + "new_email_update_success": "Deine Anfrage zur Änderung der E-Mail wurde erhalten.", "or_enter_the_following_code_manually": "Oder gib den folgenden Code manuell ein:", "organization_identification": "Hilf deiner Organisation, Dich auf Formbricks zu identifizieren", "organizations_delete_message": "Du bist der einzige Besitzer dieser Organisationen, also werden sie auch gelöscht.", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index c0e18db118..3c10a7a3ea 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -13,6 +13,8 @@ "email_change_success": "Email changed successfully", "email_change_success_description": "You have successfully changed your email address. Please log in with your new email address.", "email_verification_failed": "Email verification failed", + "email_verification_loading": "Email verification in progress...", + "email_verification_loading_description": "We are updating your email address in our system. This may take a few seconds.", "invalid_or_expired_token": "Email change failed. Your token is invalid or expired.", "new_email": "New Email", "old_email": "Old Email" @@ -88,6 +90,7 @@ "verification-requested": { "invalid_email_address": "Invalid email address", "invalid_token": "Invalid token ☹️", + "new_email_verification_success": "If the address is valid, a verification email has been sent.", "no_email_provided": "No email provided", "please_click_the_link_in_the_email_to_activate_your_account": "Please click the link in the email to activate your account.", "please_confirm_your_email_address": "Please confirm your email address", @@ -1149,11 +1152,13 @@ "disable_two_factor_authentication": "Disable two factor authentication", "disable_two_factor_authentication_description": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.", "each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Each backup code can be used exactly once to grant access without your authenticator.", + "email_change_initiated": "Your email change request has been initiated.", "enable_two_factor_authentication": "Enable two factor authentication", "enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.", "file_size_must_be_less_than_10mb": "File size must be less than 10MB.", "invalid_file_type": "Invalid file type. Only JPEG, PNG, and WEBP files are allowed.", "lost_access": "Lost access", + "new_email_update_success": "Your email change request was received.", "or_enter_the_following_code_manually": "Or enter the following code manually:", "organization_identification": "Assist your organization in identifying you on Formbricks", "organizations_delete_message": "You are the only owner of these organizations, so they will be deleted as well.", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 547ab95f9f..8084571e28 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -13,6 +13,8 @@ "email_change_success": "E-mail changé avec succès", "email_change_success_description": "Vous avez changé votre adresse e-mail avec succès. Veuillez vous connecter avec votre nouvelle adresse e-mail.", "email_verification_failed": "Échec de la vérification de l'email", + "email_verification_loading": "Vérification de l'email en cours...", + "email_verification_loading_description": "Nous mettons à jour votre adresse email dans notre système. Cela peut prendre quelques secondes.", "invalid_or_expired_token": "Échec du changement d'email. Votre jeton est invalide ou expiré.", "new_email": "Nouvel Email", "old_email": "Ancien Email" @@ -88,6 +90,7 @@ "verification-requested": { "invalid_email_address": "Adresse e-mail invalide", "invalid_token": "Jeton non valide ☹️", + "new_email_verification_success": "Si l'adresse est valide, un email de vérification a été envoyé.", "no_email_provided": "Aucun e-mail fourni", "please_click_the_link_in_the_email_to_activate_your_account": "Veuillez cliquer sur le lien dans l'e-mail pour activer votre compte.", "please_confirm_your_email_address": "Veuillez confirmer votre adresse e-mail.", @@ -1149,11 +1152,13 @@ "disable_two_factor_authentication": "Désactiver l'authentification à deux facteurs", "disable_two_factor_authentication_description": "Si vous devez désactiver l'authentification à deux facteurs, nous vous recommandons de la réactiver dès que possible.", "each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Chaque code de sauvegarde peut être utilisé exactement une fois pour accorder l'accès sans votre authentificateur.", + "email_change_initiated": "Votre demande de changement d'email a été initiée.", "enable_two_factor_authentication": "Activer l'authentification à deux facteurs", "enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.", "file_size_must_be_less_than_10mb": "La taille du fichier doit être inférieure à 10 Mo.", "invalid_file_type": "Type de fichier invalide. Seuls les fichiers JPEG, PNG et WEBP sont autorisés.", "lost_access": "Accès perdu", + "new_email_update_success": "Votre demande de changement d'email a été reçue.", "or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :", "organization_identification": "Aidez votre organisation à vous identifier sur Formbricks", "organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles seront aussi supprimées.", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index ac878a9dd3..dc0c959b7a 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -13,6 +13,8 @@ "email_change_success": "E-mail alterado com sucesso", "email_change_success_description": "Você alterou seu endereço de e-mail com sucesso. Por favor, faça login com seu novo endereço de e-mail.", "email_verification_failed": "Falha na verificação do e-mail", + "email_verification_loading": "Verificação de e-mail em andamento...", + "email_verification_loading_description": "Estamos atualizando seu endereço de e-mail em nosso sistema. Isso pode levar alguns segundos.", "invalid_or_expired_token": "Falha na alteração do e-mail. Seu token é inválido ou expirou.", "new_email": "Novo Email", "old_email": "Email Antigo" @@ -88,6 +90,7 @@ "verification-requested": { "invalid_email_address": "Endereço de email inválido", "invalid_token": "Token inválido ☹️", + "new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.", "no_email_provided": "Nenhum e-mail fornecido", "please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clica no link do e-mail pra ativar sua conta.", "please_confirm_your_email_address": "Por favor, confirme seu endereço de e-mail", @@ -1149,11 +1152,13 @@ "disable_two_factor_authentication": "Desativar a autenticação de dois fatores", "disable_two_factor_authentication_description": "Se você precisar desativar a 2FA, recomendamos reativá-la o mais rápido possível.", "each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.", + "email_change_initiated": "Sua solicitação de alteração de e-mail foi iniciada.", "enable_two_factor_authentication": "Ativar autenticação de dois fatores", "enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.", "file_size_must_be_less_than_10mb": "O tamanho do arquivo deve ser menor que 10MB.", "invalid_file_type": "Tipo de arquivo inválido. Só são permitidos arquivos JPEG, PNG e WEBP.", "lost_access": "Perdi o acesso", + "new_email_update_success": "Sua solicitação de alteração de e-mail foi recebida.", "or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:", "organization_identification": "Ajude sua organização a te identificar no Formbricks", "organizations_delete_message": "Você é o único dono dessas organizações, então elas também serão apagadas.", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 5199c359d5..41536de224 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -13,6 +13,8 @@ "email_change_success": "Email alterado com sucesso", "email_change_success_description": "Alterou com sucesso o seu endereço de email. Por favor, inicie sessão com o seu novo endereço de email.", "email_verification_failed": "Falha na verificação do email", + "email_verification_loading": "Verificação do email em progresso...", + "email_verification_loading_description": "Estamos a atualizar o seu endereço de email no nosso sistema. Isto pode demorar alguns segundos.", "invalid_or_expired_token": "Falha na alteração do email. O seu token é inválido ou expirou.", "new_email": "Novo Email", "old_email": "Email Antigo" @@ -88,6 +90,7 @@ "verification-requested": { "invalid_email_address": "Endereço de email inválido", "invalid_token": "Token inválido ☹️", + "new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.", "no_email_provided": "Nenhum email fornecido", "please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clique no link no email para ativar a sua conta.", "please_confirm_your_email_address": "Por favor, confirme o seu endereço de email", @@ -1149,11 +1152,13 @@ "disable_two_factor_authentication": "Desativar autenticação de dois fatores", "disable_two_factor_authentication_description": "Se precisar de desativar a autenticação de dois fatores, recomendamos que a reative o mais rapidamente possível.", "each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.", + "email_change_initiated": "O seu pedido de alteração de email foi iniciado.", "enable_two_factor_authentication": "Ativar autenticação de dois fatores", "enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.", "file_size_must_be_less_than_10mb": "O tamanho do ficheiro deve ser inferior a 10MB.", "invalid_file_type": "Tipo de ficheiro inválido. Apenas são permitidos ficheiros JPEG, PNG e WEBP.", "lost_access": "Perdeu o acesso", + "new_email_update_success": "O seu pedido de alteração de email foi recebido.", "or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:", "organization_identification": "Ajude a sua organização a identificá-lo no Formbricks", "organizations_delete_message": "É o único proprietário destas organizações, por isso também serão eliminadas.", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 08bbd64bd2..1807b6f09c 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -13,6 +13,8 @@ "email_change_success": "電子郵件已成功更改", "email_change_success_description": "您已成功更改電子郵件地址。請使用您的新電子郵件地址登入。", "email_verification_failed": "電子郵件驗證失敗", + "email_verification_loading": "電子郵件驗證進行中...", + "email_verification_loading_description": "我們正在系統中更新您的電子郵件地址。這可能需要幾秒鐘。", "invalid_or_expired_token": "電子郵件更改失敗。您的 token 無效或已過期。", "new_email": "新 電子郵件", "old_email": "舊 電子郵件" @@ -88,6 +90,7 @@ "verification-requested": { "invalid_email_address": "無效的電子郵件地址", "invalid_token": "無效的權杖 ☹️", + "new_email_verification_success": "如果地址有效,驗證電子郵件已發送。", "no_email_provided": "未提供電子郵件", "please_click_the_link_in_the_email_to_activate_your_account": "請點擊電子郵件中的連結以啟用您的帳戶。", "please_confirm_your_email_address": "請確認您的電子郵件地址", @@ -1149,11 +1152,13 @@ "disable_two_factor_authentication": "停用雙重驗證", "disable_two_factor_authentication_description": "如果您需要停用 2FA,我們建議您盡快重新啟用它。", "each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "每個備份碼只能使用一次,以便在沒有驗證器的情況下授予存取權限。", + "email_change_initiated": "您的 email 更改請求已啟動。", "enable_two_factor_authentication": "啟用雙重驗證", "enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。", "file_size_must_be_less_than_10mb": "檔案大小必須小於 10MB。", "invalid_file_type": "無效的檔案類型。僅允許 JPEG、PNG 和 WEBP 檔案。", "lost_access": "無法存取", + "new_email_update_success": "您的 email 更改請求已收到。", "or_enter_the_following_code_manually": "或手動輸入下列程式碼:", "organization_identification": "協助您的組織在 Formbricks 上識別您", "organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 將被刪除。", diff --git a/apps/web/modules/auth/lib/brevo.test.ts b/apps/web/modules/auth/lib/brevo.test.ts index 2448e69c6b..bc6b92a86b 100644 --- a/apps/web/modules/auth/lib/brevo.test.ts +++ b/apps/web/modules/auth/lib/brevo.test.ts @@ -1,8 +1,7 @@ import { validateInputs } from "@/lib/utils/validate"; -import { Response } from "node-fetch"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { logger } from "@formbricks/logger"; -import { createBrevoCustomer } from "./brevo"; +import { createBrevoCustomer, updateBrevoCustomer } from "./brevo"; vi.mock("@/lib/constants", () => ({ BREVO_API_KEY: "mock_api_key", @@ -42,18 +41,87 @@ describe("createBrevoCustomer", () => { await createBrevoCustomer({ id: "123", email: "test@example.com" }); + expect(validateInputs).toHaveBeenCalled(); expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error sending user to Brevo"); }); - test("should log the error response if fetch status is not 200", async () => { + test("should log the error response if fetch status is not 201", async () => { const loggerSpy = vi.spyOn(logger, "error"); vi.mocked(global.fetch).mockResolvedValueOnce( - new Response("Bad Request", { status: 400, statusText: "Bad Request" }) + new global.Response("Bad Request", { status: 400, statusText: "Bad Request" }) ); await createBrevoCustomer({ id: "123", email: "test@example.com" }); + expect(validateInputs).toHaveBeenCalled(); expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error sending user to Brevo"); }); }); + +describe("updateBrevoCustomer", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should return early if BREVO_API_KEY is not defined", async () => { + vi.doMock("@/lib/constants", () => ({ + BREVO_API_KEY: undefined, + BREVO_LIST_ID: "123", + })); + + const { updateBrevoCustomer } = await import("./brevo"); // Re-import to get the mocked version + + const result = await updateBrevoCustomer({ id: "user123", email: "test@example.com" }); + + expect(result).toBeUndefined(); + expect(global.fetch).not.toHaveBeenCalled(); + expect(validateInputs).not.toHaveBeenCalled(); + }); + + test("should log an error if fetch fails", async () => { + const loggerSpy = vi.spyOn(logger, "error"); + + vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Fetch failed")); + + await updateBrevoCustomer({ id: "user123", email: "test@example.com" }); + + expect(validateInputs).toHaveBeenCalled(); + expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error updating user in Brevo"); + }); + + test("should log the error response if fetch status is not 204", async () => { + const loggerSpy = vi.spyOn(logger, "error"); + + vi.mocked(global.fetch).mockResolvedValueOnce( + new global.Response("Bad Request", { status: 400, statusText: "Bad Request" }) + ); + + await updateBrevoCustomer({ id: "user123", email: "test@example.com" }); + + expect(validateInputs).toHaveBeenCalled(); + expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error updating user in Brevo"); + }); + + test("should successfully update a Brevo customer", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(new global.Response(null, { status: 204 })); + + await updateBrevoCustomer({ id: "user123", email: "test@example.com" }); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.brevo.com/v3/contacts/user123?identifierType=ext_id", + expect.objectContaining({ + method: "PUT", + headers: { + Accept: "application/json", + "api-key": "mock_api_key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + attributes: { EMAIL: "test@example.com" }, + }), + }) + ); + expect(validateInputs).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/auth/lib/brevo.ts b/apps/web/modules/auth/lib/brevo.ts index 0b52812921..233888ff29 100644 --- a/apps/web/modules/auth/lib/brevo.ts +++ b/apps/web/modules/auth/lib/brevo.ts @@ -4,6 +4,26 @@ import { logger } from "@formbricks/logger"; import { ZId } from "@formbricks/types/common"; import { TUserEmail, ZUserEmail } from "@formbricks/types/user"; +type BrevoCreateContact = { + email?: string; + ext_id?: string; + attributes?: Record; + emailBlacklisted?: boolean; + smsBlacklisted?: boolean; + listIds?: number[]; + updateEnabled?: boolean; + smtpBlacklistSender?: string[]; +}; + +type BrevoUpdateContact = { + attributes?: Record; + emailBlacklisted?: boolean; + smsBlacklisted?: boolean; + listIds?: number[]; + unlinkListIds?: number[]; + smtpBlacklistSender?: string[]; +}; + export const createBrevoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => { if (!BREVO_API_KEY) { return; @@ -12,7 +32,7 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU validateInputs([id, ZId], [email, ZUserEmail]); try { - const requestBody: any = { + const requestBody: BrevoCreateContact = { email, ext_id: id, updateEnabled: false, @@ -34,7 +54,7 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU body: JSON.stringify(requestBody), }); - if (res.status !== 200) { + if (res.status !== 201) { const errorText = await res.text(); logger.error({ errorText }, "Error sending user to Brevo"); } @@ -42,3 +62,36 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU logger.error(error, "Error sending user to Brevo"); } }; + +export const updateBrevoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => { + if (!BREVO_API_KEY) { + return; + } + + validateInputs([id, ZId], [email, ZUserEmail]); + + try { + const requestBody: BrevoUpdateContact = { + attributes: { + EMAIL: email, + }, + }; + + const res = await fetch(`https://api.brevo.com/v3/contacts/${id}?identifierType=ext_id`, { + method: "PUT", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "api-key": BREVO_API_KEY, + }, + body: JSON.stringify(requestBody), + }); + + if (res.status !== 204) { + const errorText = await res.text(); + logger.error({ errorText }, "Error updating user in Brevo"); + } + } catch (error) { + logger.error(error, "Error updating user in Brevo"); + } +}; diff --git a/apps/web/modules/auth/signup-without-verification-success/page.tsx b/apps/web/modules/auth/signup-without-verification-success/page.tsx index 43a233c7ed..687cdb9f8f 100644 --- a/apps/web/modules/auth/signup-without-verification-success/page.tsx +++ b/apps/web/modules/auth/signup-without-verification-success/page.tsx @@ -5,17 +5,15 @@ import { getTranslate } from "@/tolgee/server"; export const SignupWithoutVerificationSuccessPage = async () => { const t = await getTranslate(); return ( -
- -

- {t("auth.signup_without_verification_success.user_successfully_created")} -

-

- {t("auth.signup_without_verification_success.user_successfully_created_description")} -

-
- -
-
+ +

+ {t("auth.signup_without_verification_success.user_successfully_created")} +

+

+ {t("auth.signup_without_verification_success.user_successfully_created_description")} +

+
+ +
); }; diff --git a/apps/web/modules/auth/verify-email-change/actions.ts b/apps/web/modules/auth/verify-email-change/actions.ts index b6ac0209ba..73ec1fa9f3 100644 --- a/apps/web/modules/auth/verify-email-change/actions.ts +++ b/apps/web/modules/auth/verify-email-change/actions.ts @@ -2,6 +2,7 @@ import { verifyEmailChangeToken } from "@/lib/jwt"; import { actionClient } from "@/lib/utils/action-client"; +import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; import { updateUser } from "@/modules/auth/lib/user"; import { z } from "zod"; @@ -17,5 +18,6 @@ export const verifyEmailChangeAction = actionClient if (!user) { throw new Error("User not found or email update failed"); } + await updateBrevoCustomer({ id: user.id, email: user.email }); return user; }); diff --git a/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.test.tsx b/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.test.tsx index df88c30c9e..3dc5816e5a 100644 --- a/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.test.tsx +++ b/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.test.tsx @@ -28,7 +28,7 @@ describe("EmailChangeSignIn", () => { test("shows loading state initially", () => { render(); - expect(screen.getByText("auth.email-change.email_verification_failed")).toBeInTheDocument(); + expect(screen.getByText("auth.email-change.email_verification_loading")).toBeInTheDocument(); }); test("handles successful email change verification", async () => { diff --git a/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.tsx b/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.tsx index 0382df2a29..7b8c0d6db5 100644 --- a/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.tsx +++ b/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.tsx @@ -5,7 +5,11 @@ import { useTranslate } from "@tolgee/react"; import { signOut } from "next-auth/react"; import { useEffect, useState } from "react"; -export const EmailChangeSignIn = ({ token }: { token: string }) => { +interface EmailChangeSignInProps { + token: string; +} + +export const EmailChangeSignIn = ({ token }: EmailChangeSignInProps) => { const { t } = useTranslate(); const [status, setStatus] = useState<"success" | "error" | "loading">("loading"); @@ -37,18 +41,25 @@ export const EmailChangeSignIn = ({ token }: { token: string }) => { } }, [status]); + const text = { + heading: { + success: t("auth.email-change.email_change_success"), + error: t("auth.email-change.email_verification_failed"), + loading: t("auth.email-change.email_verification_loading"), + }, + description: { + success: t("auth.email-change.email_change_success_description"), + error: t("auth.email-change.invalid_or_expired_token"), + loading: t("auth.email-change.email_verification_loading_description"), + }, + }; + return ( <>

- {status === "success" - ? t("auth.email-change.email_change_success") - : t("auth.email-change.email_verification_failed")} + {text.heading[status]}

-

- {status === "success" - ? t("auth.email-change.email_change_success_description") - : t("auth.email-change.invalid_or_expired_token")} -

+

{text.description[status]}


); diff --git a/packages/js-core/src/lib/common/setup.ts b/packages/js-core/src/lib/common/setup.ts index d925f4c0c7..52fa4fb6a3 100644 --- a/packages/js-core/src/lib/common/setup.ts +++ b/packages/js-core/src/lib/common/setup.ts @@ -193,6 +193,7 @@ export const setup = async ( if (environmentStateResponse.ok) { environmentState = environmentStateResponse.data; + logger.debug(`Fetched ${environmentState.data.surveys.length.toString()} surveys from the backend`); } else { logger.error( `Error fetching environment state: ${environmentStateResponse.error.code} - ${environmentStateResponse.error.responseMessage ?? ""}` @@ -257,7 +258,9 @@ export const setup = async ( }); const surveyNames = filteredSurveys.map((s) => s.name); - logger.debug(`Fetched ${surveyNames.length.toString()} surveys during sync: ${surveyNames.join(", ")}`); + logger.debug( + `${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}` + ); } catch { logger.debug("Error during sync. Please try again."); } @@ -303,6 +306,7 @@ export const setup = async ( } const environmentState = environmentStateResponse.data; + logger.debug(`Fetched ${environmentState.data.surveys.length.toString()} surveys from the backend`); const filteredSurveys = filterSurveys(environmentState, userState); config.update({ @@ -312,6 +316,11 @@ export const setup = async ( environment: environmentState, filteredSurveys, }); + + const surveyNames = filteredSurveys.map((s) => s.name); + logger.debug( + `${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}` + ); } catch (e) { await handleErrorOnFirstSetup(e as { code: string; responseMessage: string }); } diff --git a/packages/surveys/src/components/general/label.tsx b/packages/surveys/src/components/general/label.tsx index e161805db2..beee691d5a 100644 --- a/packages/surveys/src/components/general/label.tsx +++ b/packages/surveys/src/components/general/label.tsx @@ -5,7 +5,7 @@ interface LabelProps { export function Label({ text, htmlForId }: Readonly) { return ( -