Merge branch 'main' of https://github.com/formbricks/formbricks into survey-height-tweaks

This commit is contained in:
Johannes
2025-05-26 23:28:46 +07:00
25 changed files with 282 additions and 78 deletions

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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 },

View File

@@ -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)],
}

View File

@@ -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>",

View File

@@ -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>",

View File

@@ -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>",

View File

@@ -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>",

View File

@@ -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>",

View File

@@ -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>",

View File

@@ -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();
});
});

View File

@@ -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");
}
};

View File

@@ -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>
);
};

View File

@@ -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;
});

View File

@@ -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 () => {

View File

@@ -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" />
</>
);

View File

@@ -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 });
}

View File

@@ -5,7 +5,7 @@ interface LabelProps {
export function Label({ text, htmlForId }: Readonly<LabelProps>) {
return (
<label htmlFor={htmlForId} className="fb-text-subheading fb-font-normal fb-text-sm">
<label htmlFor={htmlForId} className="fb-text-subheading fb-font-normal fb-text-sm fb-block" dir="auto">
{text}
</label>
);

View File

@@ -65,6 +65,7 @@ vi.mock("@/lib/utils", () => ({
}
return choices.map((choice: { id: string }) => choice.id);
}),
isRTL: vi.fn((text) => text.includes("rtl")),
}));
describe("MultipleChoiceMultiQuestion", () => {

View File

@@ -6,7 +6,7 @@ import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, getShuffledChoicesIds } from "@/lib/utils";
import { cn, getShuffledChoicesIds, isRTL } from "@/lib/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
@@ -140,6 +140,12 @@ export function MultipleChoiceMultiQuestion({
: question.required;
};
const otherOptionDir = useMemo(() => {
const placeholder = getLocalizedValue(question.otherOptionPlaceholder, languageCode);
if (!otherValue) return isRTL(placeholder) ? "rtl" : "ltr";
return "auto";
}, [languageCode, question.otherOptionPlaceholder, otherValue]);
return (
<form
key={question.id}
@@ -267,7 +273,7 @@ export function MultipleChoiceMultiQuestion({
{otherSelected ? (
<input
ref={otherSpecify}
dir="auto"
dir={otherOptionDir}
id={`${otherOption.id}-label`}
maxLength={250}
name={question.id}
@@ -279,7 +285,9 @@ export function MultipleChoiceMultiQuestion({
}}
className="placeholder:fb-text-placeholder fb-border-border fb-bg-survey-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-mt-3 fb-flex fb-h-10 fb-w-full fb-border fb-px-3 fb-py-2 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50"
placeholder={
getLocalizedValue(question.otherOptionPlaceholder, languageCode) ?? "Please specify"
getLocalizedValue(question.otherOptionPlaceholder, languageCode).length > 0
? getLocalizedValue(question.otherOptionPlaceholder, languageCode)
: "Please specify"
}
required={question.required}
aria-labelledby={`${otherOption.id}-label`}

View File

@@ -62,6 +62,7 @@ vi.mock("@/lib/ttc", () => ({
vi.mock("@/lib/utils", () => ({
cn: vi.fn((...args) => args.filter(Boolean).join(" ")),
getShuffledChoicesIds: vi.fn((choices) => choices.map((choice: any) => choice.id)),
isRTL: vi.fn((text) => text.includes("rtl")),
}));
// Test data

View File

@@ -6,7 +6,7 @@ import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, getShuffledChoicesIds } from "@/lib/utils";
import { cn, getShuffledChoicesIds, isRTL } from "@/lib/utils";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
@@ -100,6 +100,12 @@ export function MultipleChoiceSingleQuestion({
}
}, [otherSelected]);
const otherOptionDir = useMemo(() => {
const placeholder = getLocalizedValue(question.otherOptionPlaceholder, languageCode);
if (!value) return isRTL(placeholder) ? "rtl" : "ltr";
return "auto";
}, [languageCode, question.otherOptionPlaceholder, value]);
return (
<form
key={question.id}
@@ -196,7 +202,7 @@ export function MultipleChoiceSingleQuestion({
document.getElementById(otherOption.id)?.focus();
}
}}>
<span className="fb-flex fb-items-center fb-text-sm">
<span className="fb-flex fb-items-center fb-text-sm" dir="auto">
<input
tabIndex={-1}
dir="auto"
@@ -212,10 +218,7 @@ export function MultipleChoiceSingleQuestion({
}}
checked={otherSelected}
/>
<span
id={`${otherOption.id}-label`}
className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium"
dir="auto">
<span id={`${otherOption.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
{getLocalizedValue(otherOption.label, languageCode)}
</span>
</span>
@@ -223,7 +226,7 @@ export function MultipleChoiceSingleQuestion({
<input
ref={otherSpecify}
id={`${otherOption.id}-label`}
dir="auto"
dir={otherOptionDir}
name={question.id}
pattern=".*\S+.*"
value={value}

View File

@@ -6,8 +6,9 @@ import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { isRTL } from "@/lib/utils";
import { type RefObject } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
@@ -79,6 +80,12 @@ export function OpenTextQuestion({
onSubmit({ [question.id]: value }, updatedTtc);
};
const dir = useMemo(() => {
const placeholder = getLocalizedValue(question.placeholder, languageCode);
if (!value) return isRTL(placeholder) ? "rtl" : "ltr";
return "auto";
}, [value, languageCode, question.placeholder]);
return (
<form key={question.id} onSubmit={handleOnSubmit} className="fb-w-full">
<ScrollableContainer>
@@ -104,7 +111,7 @@ export function OpenTextQuestion({
name={question.id}
id={question.id}
placeholder={getLocalizedValue(question.placeholder, languageCode)}
dir="auto"
dir={dir}
step="any"
required={question.required}
value={value ? value : ""}
@@ -134,7 +141,7 @@ export function OpenTextQuestion({
aria-label="textarea"
id={question.id}
placeholder={getLocalizedValue(question.placeholder, languageCode)}
dir="auto"
dir={dir}
required={question.required}
value={value}
onInput={(e) => {

View File

@@ -199,7 +199,7 @@ export function RankingQuestion({
)}>
{(idx + 1).toString()}
</span>
<div className="fb-grow fb-shrink fb-font-medium fb-text-sm">
<div className="fb-grow fb-shrink fb-font-medium fb-text-sm fb-text-start" dir="auto">
{getLocalizedValue(item.label, languageCode)}
</div>
</button>

View File

@@ -168,3 +168,12 @@ export const getDefaultLanguageCode = (survey: TJsEnvironmentStateSurvey): strin
// Function to convert file extension to its MIME type
export const getMimeType = (extension: TAllowedFileExtension): string => mimeTypes[extension];
/**
* Returns true if the string contains any RTL character.
* @param text The input string to test
*/
export function isRTL(text: string): boolean {
const rtlCharRegex = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
return rtlCharRegex.test(text);
}