mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 00:49:42 -06:00
fix: issues in the email change feature (#5868)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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<typeof ZEditProfileNameFormSchema>;
|
||||
|
||||
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<TEditProfileNameForm> = 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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
@@ -60,9 +60,9 @@ export const checkUserExistsByEmail = reactCache(
|
||||
},
|
||||
});
|
||||
|
||||
return !!user;
|
||||
return !user;
|
||||
},
|
||||
[`checkUserExistsByEmail-${email}`],
|
||||
[`getIsEmailUnique-${email}`],
|
||||
{
|
||||
tags: [userCache.tag.byEmail(email)],
|
||||
}
|
||||
|
||||
@@ -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 <b>auch gelöscht.</b>",
|
||||
|
||||
@@ -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 <b>will be deleted as well.</b>",
|
||||
|
||||
@@ -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 <b>seront aussi supprimées.</b>",
|
||||
|
||||
@@ -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 <b>também serão apagadas.</b>",
|
||||
|
||||
@@ -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 <b>também serão eliminadas.</b>",
|
||||
|
||||
@@ -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": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string | string[]>;
|
||||
emailBlacklisted?: boolean;
|
||||
smsBlacklisted?: boolean;
|
||||
listIds?: number[];
|
||||
updateEnabled?: boolean;
|
||||
smtpBlacklistSender?: string[];
|
||||
};
|
||||
|
||||
type BrevoUpdateContact = {
|
||||
attributes?: Record<string, string>;
|
||||
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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,17 +5,15 @@ import { getTranslate } from "@/tolgee/server";
|
||||
export const SignupWithoutVerificationSuccessPage = async () => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">
|
||||
<FormWrapper>
|
||||
<h1 className="leading-2 mb-4 text-center font-bold">
|
||||
{t("auth.signup_without_verification_success.user_successfully_created")}
|
||||
</h1>
|
||||
<p className="text-center text-sm">
|
||||
{t("auth.signup_without_verification_success.user_successfully_created_description")}
|
||||
</p>
|
||||
<hr className="my-4" />
|
||||
<BackToLoginButton />
|
||||
</FormWrapper>
|
||||
</div>
|
||||
<FormWrapper>
|
||||
<h1 className="leading-2 mb-4 text-center font-bold">
|
||||
{t("auth.signup_without_verification_success.user_successfully_created")}
|
||||
</h1>
|
||||
<p className="text-center text-sm">
|
||||
{t("auth.signup_without_verification_success.user_successfully_created_description")}
|
||||
</p>
|
||||
<hr className="my-4" />
|
||||
<BackToLoginButton />
|
||||
</FormWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("EmailChangeSignIn", () => {
|
||||
|
||||
test("shows loading state initially", () => {
|
||||
render(<EmailChangeSignIn token="valid-token" />);
|
||||
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 () => {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<h1 className={`leading-2 mb-4 text-center font-bold ${status === "error" ? "text-red-600" : ""}`}>
|
||||
{status === "success"
|
||||
? t("auth.email-change.email_change_success")
|
||||
: t("auth.email-change.email_verification_failed")}
|
||||
{text.heading[status]}
|
||||
</h1>
|
||||
<p className="text-center text-sm">
|
||||
{status === "success"
|
||||
? t("auth.email-change.email_change_success_description")
|
||||
: t("auth.email-change.invalid_or_expired_token")}
|
||||
</p>
|
||||
<p className="text-center text-sm">{text.description[status]}</p>
|
||||
<hr className="my-4" />
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user